35 Commits

Author SHA1 Message Date
gxwebsoft 8d8080c7bf feat(shopZone): 新增专区折扣系数功能及购物车链路支持
- HomeSection 模型增加 discountRate 字段,默认 1.00 表示不打折
- zoneEdit 组件新增折扣系数输入框,支持设置 0.01~1 之间的折扣率
- 后端 guilixu-java 增加专区折扣逻辑,成交价=市场价×折扣系数,保证后端价格计算安全
- 小程序 guilixu-taro 频道详情和商品详情支持专区折扣展示和传参
- 购物车新增 sectionId 字段,支持专区折扣价计算,完善购物车加购与价格计算逻辑
- 提供对应迁移 SQL 脚本更新数据库结构(专区折扣率与购物车 section_id)
- 修复历史订单商品支付状态,补偿专区销量统计数据
- MEMORY 文档补充详细开发与设计说明,明确双后端接口调度和业务逻辑划分
2026-08-18 10:53:15 +08:00
gxwebsoft 5943f1536d chore(env): 更换后台管理系统 API 接口地址为 guilixu-api.websoft.top
- 修改开发环境与生产环境的 VITE_API_URL 为 https://guilixu-api.websoft.top/api
- 更新内存文件中关于商城接口实际调用后端域名为 guilixu-api.websoft.top 的描述
- 修正后端待部署地址及下单接口调用链接指向 guilixu-api.websoft.top
- 统一接口调用基地址,明确 guilixu-java 为生效的主后端服务
- 记录用户纠正信息,强调运行时 ApiUrl 配置优先级与实际生效后端一致
2026-08-18 01:00:25 +08:00
gxwebsoft bec3a6f1b8 feat(special-zone): 走压缩URL处理专区商品图片
- 将专区商品抽屉已关联商品列表的图片src改为压缩URL,减少缩略图体积
- 调整banner图片压缩宽度为300,保证清晰度与加载速度平衡
- 统一调用getCompressedImageUrl方法,无需额外引入
- 优化图片加载性能,提升用户体验
2026-08-17 16:47:37 +08:00
gxwebsoft 65926a35b6 fix(special-zone): 修复专区商品抽屉分页点击无效
- 将分页配置从内联对象改为计算属性,避免分页参数被 Vue 不自动解包的问题
- goodsPagination 计算属性返回正确的分页参数并绑定 onChange 事件
- onChange 中调用 loadGoods 时修正 currentSectionId 使用 .value 获取
- 调整商品操作列宽度以适应界面显示
- 验证 vite build 通过,分页功能正常响应点击
2026-08-17 14:50:31 +08:00
gxwebsoft ced8c7cff7 feat(shop-zone): 新增专区销量统计功能
- 后端新增专区销量统计相关 VO 并扩展 ShopHomeSection 实体
- 新增批量查询专区销量汇总与商品排行的 Mapper 方法及接口
- Controller 增加获取专区销量统计的接口支持时间范围查询
- 前端接口定义新增专区销量统计类型及请求方法
- 专区管理页面列表新增销量件数、销售额列及销量详情按钮
- 实现专区销量统计弹窗支持时间筛选、汇总展示和排行显示
- 完成前端相关界面和交互设计,保证功能完整可用
- 统计口径基于已支付且未取消/退款订单商品实际成交数据
2026-08-17 12:40:08 +08:00
gxwebsoft fe97f49afe fix(shop): 修复商城商品导出及专区商品分页问题
- 修复专区商品抽屉分页中20/100条/页按键无效,增加动态分页参数支持
- 修复商城商品导出功能无反应问题,调整loading初始值保证导出流程执行
- 支持商品导出携带当前筛选条件,保证导出数据符合筛选结果
- 商品导出增加封面图路径列,导出URL文本方式避免跨域限制
- 优化UserSelectModal手机号码展示,改为优先展示真实号码字段
- 相关组件调整分页及导出事件参数,提升交互准确性与稳定性
2026-08-17 12:09:38 +08:00
gxwebsoft 6556944ffc fix(shop): 修复商城商品导出及专区商品分页问题
- 修复专区商品抽屉分页中20/100条/页按键无效,增加动态分页参数支持
- 修复商城商品导出功能无反应问题,调整loading初始值保证导出流程执行
- 支持商品导出携带当前筛选条件,保证导出数据符合筛选结果
- 商品导出增加封面图路径列,导出URL文本方式避免跨域限制
- 优化UserSelectModal手机号码展示,改为优先展示真实号码字段
- 相关组件调整分页及导出事件参数,提升交互准确性与稳定性
2026-08-17 11:34:39 +08:00
gxwebsoft 3c4e30c227 fix(user-select-modal): 专区白名单手机号改为不脱敏显示
- UserSelectModal.vue 中候选行手机号由 u.mobile 改为 u.phone || u.mobile || '-'
- 表格列手机号由 dataIndex: 'mobile' 改为 dataIndex: 'phone'
- 表格列 customRender 实现回退到 record.mobile 显示真实号码
- 无需后端改动,只需重新构建 admin 前端进行部署
2026-08-17 03:26:07 +08:00
gxwebsoft 47fb8d1bd0 feat(zone): 将专区白名单弹窗改为右侧抽屉
- 用户要求专区白名单入口交互与商品一致,改为右侧抽屉弹出
- UserSelectModal 组件外层由 a-modal 改为 a-drawer,保持内部逻辑不变
- 组件 props 和 emits 完全兼容,调用方式无需更改
- 前端重新构建即可,无需后端改动
2026-08-17 03:23:45 +08:00
gxwebsoft 4ba0427417 feat(special-zone): 优化专区白名单弹窗及添加/移除功能
- 修复白名单弹窗复选框不显示的问题,替换 ele-pro-table 为 antd a-table 实现
- 实现弹窗打开即加载已选用户并勾选,完善分页及搜索功能
- 解决弹窗搜索事件传参错误导致后端参数异常的问题
- 修复保存白名单时报唯一键冲突异常,改用物理删除规避逻辑删除死锁
- 重构白名单弹窗为商品式交互,支持即时单条添加和移除,无需覆盖式保存
- 后端新增接口支持白名单用户追加和删除,前端调用对应新增接口
- 统一用户模型字段,优化查询及展示,提升用户体验
- 清理前端无用代码,简化白名单弹窗调用逻辑,减少页面复杂度
2026-08-17 03:10:30 +08:00
gxwebsoft 6e57fba192 fix(special-zone): 修复白名单弹窗checkbox未显示问题并添加专区小程序码功能
- 修复 UserSelectModal 弹窗中复选框不显示的根因,正确传入 selection-type="checkbox"
- 新增后台接口获取专区小程序码图片流,支持正式/体验/开发版环境
- 管理后台专区列表新增二维码按钮,弹窗展示对应专区小程序码
- 小程序端支持扫码参数 scene 解析,实现扫码直接进入专区页面
- 优化二维码弹窗组件,支持切换小程序版本并动态加载二维码
- 重新构建前端及后端服务,确保新功能生效和兼容原有业务逻辑
2026-08-17 02:42:13 +08:00
gxwebsoft d458115da9 fix(special-zone): 修复白名单弹窗checkbox未显示问题并添加专区小程序码功能
- 修复 UserSelectModal 弹窗中复选框不显示的根因,正确传入 selection-type="checkbox"
- 新增后台接口获取专区小程序码图片流,支持正式/体验/开发版环境
- 管理后台专区列表新增二维码按钮,弹窗展示对应专区小程序码
- 小程序端支持扫码参数 scene 解析,实现扫码直接进入专区页面
- 优化二维码弹窗组件,支持切换小程序版本并动态加载二维码
- 重新构建前端及后端服务,确保新功能生效和兼容原有业务逻辑
2026-08-17 02:39:52 +08:00
gxwebsoft f986efce8c fix(api): 修复专区白名单和商品移除假成功问题
- 调整 .env.development 中 VITE_API_URL 配置为正式接口地址
- 修复 ShopGoods.sectionIds 字段更新时 null 被忽略导致移除专区商品假成功问题
- 通过 UpdateWrapper 显式设置 section_ids 字段避免 MyBatis-Plus 全局更新策略影响
- 补充后端下单接口白名单强校验,防止绕过前端受限专区限制
- 澄清买家用户体系为 sys_user,确认白名单弹窗使用 pageUsers 数据源正确
- 删除白名单弹窗 isStaff 限制,白名单覆盖所有 sys_user 用户
- 记录专区白名单功能设计与实现细节,明确前后端联动机制
- 提示“专区管理”菜单入口需后端菜单表动态下发,确保后台导航可见
2026-08-17 02:00:05 +08:00
gxwebsoft 4a7b217c2b feat(special-zone): 实现专区商品管理功能
- 新增添加商品到专区接口 addSectionGoods
- 新增从专区移除商品接口 removeSectionGoods
- 商品抽屉宽度调整并改造为支持搜索添加和移除商品
- 增加商品搜索输入框及搜索按钮,支持关键词搜索商品
- 搜索结果列表添加单条商品添加按钮和批量添加确认按钮
- 已关联商品列表新增移除商品操作按钮及确认弹窗
- 调整商品列表图片大小及表格滚动高度,提高界面整洁度
- 专区商品数据加载添加搜索结果过滤逻辑,避免重复添加
- 用户白名单弹窗移除 isStaff 限制,允许所有用户搜索
- 修复白名单用户选择框无法显示缺失勾选列宽度问题
- 采用 computed 管理多选配置及已选用户状态,提升响应性
2026-08-17 01:14:16 +08:00
gxwebsoft b5c67988a6 feat(shop): 新增首页专区管理功能
- 在商品模型中添加所属专区字段 sectionIds,支持逗号分隔字符串
- 商品编辑界面增加所属专区多选下拉框,联动专区数据
- 新增首页专区相关接口,包括分页查询、增删改查及权限校验等
- 实现首页专区列表页面,支持专区的增删改查及状态切换
- 新增专区白名单用户管理弹窗,支持多选用户设置白名单
- 实现专区商品列表抽屉,支持分页查看专区内商品
- 首页专区编辑弹窗支持标题、副标题、banner等多字段编辑及上传功能
- 完善专区管理界面交互和表格展示,支持搜索和状态筛选功能
2026-08-15 03:30:48 +08:00
gxwebsoft 053b776d9a feat(shopGoods): 支持商品分类多选及列表树形高度优化
- 商品模型增加 categoryIds 字段支持多分类,搜索参数同步支持多分类查询
- 商品列表新增分类列,展示商品所属分类名称
- 商品编辑及搜索组件商品分类选择改为多选,保持兼容单选主分类逻辑
- 商品分类树形列表SVG连线高度调小,提升界面紧凑度
- 优化分类图片尺寸,缩小预览图大小,提升视觉效果
- 更新用户推荐接口方法参数类型声明,增加类型安全保障
2026-08-14 19:38:02 +08:00
gxwebsoft 780606f05a feat(shopGoodsCategory): 新增商品分类编辑弹窗“链接地址”字段
- 在分类标识下方添加“链接地址”输入项,支持清除和占位提示
- 新增字段绑定到表单数据的path属性,确保表单数据同步更新
- 重置表单时初始化path字段为undefined,保证新增分类时清空该字段
- 修改仅限前端界面,后端数据库已有path字段,无需额外迁移
2026-08-14 18:14:22 +08:00
gxwebsoft a04a8baec7 feat(shopExpressTemplateDetail): 支持「不发货黑名单」配送模式
- 新增配送模式三态选择(0全国/1指定配送/2指定不发货)
- 编辑表单中增加“配送模式”单选,mode=2时复用多选地区组件选择不发货地区
- 保存时根据模式调整 regionMode 和 regionIds 字段,确保模式与地区同步
- 列表页配送地区列新增不发货黑名单显示,聚合城市码为省份名并支持省名去重
- 加载地区时建立城市ID到省份ID映射,实现城市码聚合显示省名功能
- 适配旧数据兼容性,支持单值旧数据和全国模式显示
- 表单校验强化,指定配送/不发货模式下必须选择有效地区
- 完成端到端链路功能闭环,前端支持黑名单配置,后端按租户生效配送限制
2026-08-12 14:36:43 +08:00
gxwebsoft 6a5b7ff711 feat(shopExpressTemplateDetail): 支持「不发货黑名单」配送模式
- 新增配送模式三态选择(0全国/1指定配送/2指定不发货)
- 编辑表单中增加“配送模式”单选,mode=2时复用多选地区组件选择不发货地区
- 保存时根据模式调整 regionMode 和 regionIds 字段,确保模式与地区同步
- 列表页配送地区列新增不发货黑名单显示,聚合城市码为省份名并支持省名去重
- 加载地区时建立城市ID到省份ID映射,实现城市码聚合显示省名功能
- 适配旧数据兼容性,支持单值旧数据和全国模式显示
- 表单校验强化,指定配送/不发货模式下必须选择有效地区
- 完成端到端链路功能闭环,前端支持黑名单配置,后端按租户生效配送限制
2026-08-12 10:27:47 +08:00
gxwebsoft 9b5875e6fb feat(shopExpressTemplateDetail): 支持「不发货黑名单」配送模式
- 新增配送模式三态选择(0全国/1指定配送/2指定不发货)
- 编辑表单中增加“配送模式”单选,mode=2时复用多选地区组件选择不发货地区
- 保存时根据模式调整 regionMode 和 regionIds 字段,确保模式与地区同步
- 列表页配送地区列新增不发货黑名单显示,聚合城市码为省份名并支持省名去重
- 加载地区时建立城市ID到省份ID映射,实现城市码聚合显示省名功能
- 适配旧数据兼容性,支持单值旧数据和全国模式显示
- 表单校验强化,指定配送/不发货模式下必须选择有效地区
- 完成端到端链路功能闭环,前端支持黑名单配置,后端按租户生效配送限制
2026-08-12 10:01:03 +08:00
gxwebsoft 07add26fe6 refactor(cmsArticle): 统一文章编辑器为单一 WangEditor 富文本模式
- 删除原有 TinyMCE 富文本和 MdEditor Markdown 双模式切换及相关逻辑
- 替换文章编辑弹窗内容详情为 RichTextEditor 组件
- 新增图片库和视频库选择按钮,支持插入图片和视频 HTML 标签
- 实现一键排版和首行缩进按钮,直接操作内容 HTML 字符串
- 回显时 Markdown 内容转换为 HTML,保存时固定 editor 类型为 1
- 移除无用的配置和样式,简化代码逻辑,提升编辑体验
- 留下文章编辑器类型设置字段待后续清理,展示端保持一致 HTML 渲染方式
2026-08-11 21:58:09 +08:00
gxwebsoft cf7d065379 feat(shop): 商品管理编辑弹窗替换为WangEditor富文本编辑器
- 用户反馈编辑弹窗的Markdown编辑器体验差,采用website-admin的富文本编辑器替代
- 新增依赖@wangeditor/editor、@wangeditor/editor-for-vue、markdown-it及类型声明
- 新建RichTextEditor组件,去除Nuxt专用包装,适配现有文件上传接口
- shopGoodsEdit.vue移除MdEditor及相关冗余代码,改用RichTextEditor显示内容
- 实现编辑时Markdown内容转换为HTML,保持数据库存储格式新旧兼容
- 保存逻辑保持不变,提交内容为HTML格式
- 确认小程序端可兼容新旧内容格式,潜在HTML图片链接误解析风险
- build验证通过,文件产出正常,图片上传小程序域名白名单需上线后确认
2026-08-11 18:52:40 +08:00
gxwebsoft b6ae3d71ef feat(shopGoods): 支持多规格价格区间及修复快递配送设置保存问题
- 商品列表价格列新增多规格价格区间显示,单规格依旧显示单值
- 后端新增非持久化最大价字段 priceMax/salePriceMax/dealerPriceMax,批量读取SKU价格区间
- 前端 API 和列表价格列渲染及导出支持价格区间显示
- 添加快递配送相关字段 canExpress 和 expressTemplateId 至后端实体并新增对应数据库字段
- 调整前端运费模板表单项位置及必填校验逻辑,修复保存不生效问题
- 修改 SKU 图片删除方法,避免误删主商品图片数组
2026-08-11 16:22:38 +08:00
gxwebsoft 8ab3326f26 feat(shopGoods): 支持多规格价格区间及修复快递配送设置保存问题
- 商品列表价格列新增多规格价格区间显示,单规格依旧显示单值
- 后端新增非持久化最大价字段 priceMax/salePriceMax/dealerPriceMax,批量读取SKU价格区间
- 前端 API 和列表价格列渲染及导出支持价格区间显示
- 添加快递配送相关字段 canExpress 和 expressTemplateId 至后端实体并新增对应数据库字段
- 调整前端运费模板表单项位置及必填校验逻辑,修复保存不生效问题
- 修改 SKU 图片删除方法,避免误删主商品图片数组
2026-08-11 15:46:52 +08:00
gxwebsoft c85b1351e3 fix(shop): 修复多规格SKU图片删除问题
- 修正删除SKU图片时错误操作,防止误删主商品图片数组
- 删除时清空对应SKU行的图片数组与主图字段
- 优化运费模板标签,明确必填状态提示
- 添加会员价字段buyingPrice,更新相关TS接口及数据库结构说明
2026-08-11 11:44:32 +08:00
gxwebsoft 9de1df84ca fix(shop): 修复多规格SKU会员价保存失败问题
- 后端 ShopGoodsSku 实体类添加 buyingPrice 字段,解决数据丢失
- 前端接口定义增加 buyingPrice,保证数据完整同步
- mp-java 后端模块同步添加 buyingPrice 字段实现数据一致
- 需手动执行 SQL 新增数据库表 shop_goods_sku 的 buying_price 列
2026-08-11 10:55:51 +08:00
gxwebsoft 706db16337 fix(shopGoods): 修复SKU表格库存列和运费模板校验缺失
- 补充多规格SKU表格中缺失的划线价和库存列定义
- 添加快递配送开启时运费模板前端必填校验
- 在保存逻辑中增加运费模板缺失时的错误提示
- 运费模板表单项动态显示必填标记及占位提示
- 保持与后端校验逻辑一致,提升用户体验
2026-08-11 10:52:29 +08:00
gxwebsoft 40bcc77c50 fix(shopExpressTemplateDetail): 修正配送区域多选字段对接后端机制
- model中省市多选字段由provinceIds/cityIds改为regionMode/regionIds
- RegionMultiSelect组件去除update:provinceIds事件,改仅emit update:value
- 编辑弹窗中城市选择变化同步更新form.regionIds和form.regionMode字段
- 回显数据时从regionIds拆分赋值,不再使用provinceIds/cityIds
- 列表页customRender展示使用record.regionIds反查城市名
- 解决前端提交字段与后端实体不匹配导致数据未保存问题
- 确保本次改动完全兼容后端现有regionMode/regionIds机制,无需后端改动
2026-08-11 10:38:21 +08:00
gxwebsoft a228dcb96b fix(shopExpressTemplateDetail): 修正配送区域多选字段对接后端机制
- model中省市多选字段由provinceIds/cityIds改为regionMode/regionIds
- RegionMultiSelect组件去除update:provinceIds事件,改仅emit update:value
- 编辑弹窗中城市选择变化同步更新form.regionIds和form.regionMode字段
- 回显数据时从regionIds拆分赋值,不再使用provinceIds/cityIds
- 列表页customRender展示使用record.regionIds反查城市名
- 解决前端提交字段与后端实体不匹配导致数据未保存问题
- 确保本次改动完全兼容后端现有regionMode/regionIds机制,无需后端改动
2026-08-11 10:32:57 +08:00
gxwebsoft ae2ea4d841 feat(shopExpressTemplateDetail): 支持省市两级多选配送区域
- 新增 model 字段 provinceIds 和 cityIds,兼容旧单值 provinceId 和 cityId
- 引入 RegionMultiSelect 组件替代旧的单选级联选择器
- 编辑弹窗支持多选城市和省份,提交时转换为逗号分隔字符串
- 列表页配送区域列支持多选展示,超长文本截断并显示 tooltip
- 新增 RegionMultiSelect 组件,实现省市两级树形多选穿梭弹窗
- 通过 computed 和 watch 同步组件选中状态和表单数据
- 更新相关类型定义和导入,确保编辑及展示逻辑正确
- 新增样式优化,提示默认配送全国规则说明
2026-08-11 09:01:30 +08:00
gxwebsoft aae0c03dd8 feat(shopGoods): 优化商品列表加载及封面图性能
- image.ts 中 getCompressedImageUrl 增加缓存,避免列表重渲染时重复计算压缩 URL
- shopGoods 页面用原生 img 标签替换 antdv a-image,支持浏览器懒加载减少首屏网络压力
- shopGoods 页面数据加载流程优化,取消表格自动首屏加载,改为手动触发,合并请求避免重复调用
- 首屏分类数据延迟加载,不阻塞首屏渲染,提升页面响应速度
- search.vue 中用 onMounted 替代 watch immediate,防止首次入场重复触发请求
- 新增商品封面图样式,保证图片大小固定且样式统一
2026-08-06 18:18:05 +08:00
gxwebsoft 3bc856fc95 feat(shopGoods): 优化商品列表加载及封面图性能
- image.ts 中 getCompressedImageUrl 增加缓存,避免列表重渲染时重复计算压缩 URL
- shopGoods 页面用原生 img 标签替换 antdv a-image,支持浏览器懒加载减少首屏网络压力
- shopGoods 页面数据加载流程优化,取消表格自动首屏加载,改为手动触发,合并请求避免重复调用
- 首屏分类数据延迟加载,不阻塞首屏渲染,提升页面响应速度
- search.vue 中用 onMounted 替代 watch immediate,防止首次入场重复触发请求
- 新增商品封面图样式,保证图片大小固定且样式统一
2026-08-06 17:19:15 +08:00
gxwebsoft c553a71ea2 feat(dashboard): 新增后台今日数据概况及仪表盘页面
- 新增 shopOrderTotalAll 和 shopOrderOverview 接口,支持含未支付口径数据统计
- 新增 ShopOrderOverview 类型定义,包含今日销售额、订单数、新增用户数和优惠券使用数
- 新增后台专用仪表盘页面,展示用户数、订单数、总营业额和系统运行天数等核心数据
- 仪表盘页面实现新订单提醒功能,支持声音和语音播报
- 提供待付款、待发货订单数和退款申请动态展示,带点击跳转功能
- 新增今日数据概况区域,显示销售额、订单数、新增用户和优惠券使用情况
- 集成订阅和续费功能,支持支付二维码扫码支付及状态轮询
- 基本信息展示系统版本、授权状态以及订阅到期提醒和续费入口
- 快速入口模块方便访问订单管理、商品管理、优惠券管理等功能页
- 实现数据自动刷新机制,包括统计数据和含未支付订单的特殊统计
- 优化了界面样式和交互体验,提升了后台管理的可用性和操作便捷性
2026-08-01 22:11:27 +08:00
gxwebsoft a284a83b86 feat(dashboard): 新增后台今日数据概况及仪表盘页面
- 新增 shopOrderTotalAll 和 shopOrderOverview 接口,支持含未支付口径数据统计
- 新增 ShopOrderOverview 类型定义,包含今日销售额、订单数、新增用户数和优惠券使用数
- 新增后台专用仪表盘页面,展示用户数、订单数、总营业额和系统运行天数等核心数据
- 仪表盘页面实现新订单提醒功能,支持声音和语音播报
- 提供待付款、待发货订单数和退款申请动态展示,带点击跳转功能
- 新增今日数据概况区域,显示销售额、订单数、新增用户和优惠券使用情况
- 集成订阅和续费功能,支持支付二维码扫码支付及状态轮询
- 基本信息展示系统版本、授权状态以及订阅到期提醒和续费入口
- 快速入口模块方便访问订单管理、商品管理、优惠券管理等功能页
- 实现数据自动刷新机制,包括统计数据和含未支付订单的特殊统计
- 优化了界面样式和交互体验,提升了后台管理的可用性和操作便捷性
2026-08-01 22:10:28 +08:00
gxwebsoft bf60ba1d04 refactor(shop): 将商品价格相关文本统一修改为“商城价”
- 修改shopGoods、shopGoodsBrowse和shopGoodsSku模型中price字段注释
- 更新shopGoodsBrowse和shopGoodsSku页面表格中价格列标题
- 调整shopGoodsEdit组件中表单标签、输入框占位符和校验提示信息
- 同步修改shopGoodsSkuEdit组件中价格表单项的标签和占位符文本
- 保证所有相关文本表述一致,提升用户界面和代码的统一性
2026-07-30 12:45:27 +08:00
63 changed files with 6205 additions and 1137 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
VITE_APP_NAME=后台管理系统 VITE_APP_NAME=后台管理系统
#VITE_API_URL=http://127.0.0.1:9200/api #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 VITE_CMS_API_URL=https://cms-api.websoft.top/api
+1 -1
View File
@@ -1,3 +1,3 @@
VITE_APP_NAME=后台管理系统 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 VITE_CMS_API_URL=https://cms-api.websoft.top/api
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
dist.zip
.verify-build
.DS_Store
*.local
+1 -1
View File
@@ -259,7 +259,7 @@
- `AppSettingServiceImpl` 代码注释明说"平台配置不按租户隔离"、getByKey 不带租户条件,但拦截器在 SQL 层强制隔离,两者矛盾——这是历史遗留 bug。 - `AppSettingServiceImpl` 代码注释明说"平台配置不按租户隔离"、getByKey 不带租户条件,但拦截器在 SQL 层强制隔离,两者矛盾——这是历史遗留 bug。
- 修复(后端 `MybatisPlusConfig.java`):ignoreTable 列表在 `"app_subscription"` 后加 `"app_setting"`(一行改动)。改完 app_setting 所有查询忽略租户,与代码设计意图一致。 - 修复(后端 `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 连此库。 - 排查方法值得记录: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 支付标记 ## 续费"弹二维码自动完成支付"修复:清除旧 Redis 支付标记
+16
View File
@@ -0,0 +1,16 @@
# 2026-07-29
## websopy-javacms 模块数据源路由修复(重要跨项目)
- 报错:`cms-template/current``Table 'db_websopy.cms_website' doesn't exist`jar 为 `websopy-api-1.5.0.jar`= `/Users/gxwebsoft/JAVA/websopy-java`)。
- 根因:websopy-java 原只有单一数据源,dev/prod/websopy 三个 profile 全指向 `db_websopy`;而 cms 模块(23 张 cms_* 表)实际在 `modules` 库。test profile 本就指向 `modules`(印证 cms 表在 modules)。
- 已确认方案 B(用户选「独立库 db_websopy」):多数据源,websopy 自有数据留 `db_websopy`,仅 cms 包走 `modules`
- 改动(在 websopy-java):
1. `pom.xml` 增加 `com.baomidou:dynamic-datasource-spring-boot-starter:3.4.1`(匹配 MP 3.4.3.3**不要用 3.5.x**,其对 MP 3.5,运行期可能 NoSuchMethod)。
2. `application-dev/prod/websopy/test.yml` 的单一 `spring.datasource` 改为 `spring.datasource.dynamic``primary: websopy`test 为 `modules`),`datasource.websopy` + `datasource.modules` 双库;modules 用库 `modules`、用户 `modules`、密码 `P7KsAyDXG8YdLnkA`(取自 test profile)。
3.`com/gxwebsoft/cms/mapper` 下全部 23 个 Mapper 接口加 `@DS("modules")`import `com.baomidou.dynamic.datasource.annotation.DS`),仅把 cms SQL 路由到 modules,不影响 websopy 自有表。
- 调用链:`cms-template/current``CmsWebsiteServiceImpl.getByTenantId()``baseMapper.getByTenantId()`(已注解)→ modules。✅
- 重新构建:websopy-java 本地无 mvn/docker,需在其环境 `mvn clean package` 或重 build docker 镜像后重启容器(prod profile `SPRING_PROFILES_ACTIVE=prod`)。
- ⚠️ 遗留问题(未处理,需确认):
- `shop/mapper/xml/ShopGoodsMapper.xml` 第 9 行 `LEFT JOIN cms_navigation`(无 schema 前缀),该 shop 查询走默认 websopy → 若 `cms_navigation` 不在 `db_websopy` 会失败;dynamic-datasource 无法跨单条 SQL 双库,需把 cms_navigation 复制到 db_websopy 或改成 `modules.cms_navigation`/`gxwebsoft_core.cms_navigation` 前缀。
- 前提假设:所有 cms_* 表都在 modules(依 test profile 整库连 modules 推断)。若实际只有部分 cms 表在 modules,应改为只给缺失的那几张 mapper 加 @DS
- 跨库写事务:cms 写操作经 @DS 走 modules 独立连接,与 websopy 事务不在一个事务里,写一致性需后续评估。
+27
View File
@@ -0,0 +1,27 @@
# 2026-08-01 工作记录
## 订单自动取消改造:超时时间改为后台「订单设置」驱动
### 需求
自动取消未付款订单的超时时间,需由管理后台「商城设置 → 订单设置 → 未付款自动关闭」控制;设置为 0 分钟 = 不自动取消。
### 改造要点(已全部落地)
- 后台设置存于 `ShopSetting``setting_key='shop_order'` 的 JSON 中,字段 `orderAutoCloseMins`(前端 order.vue 已支持保存)。
- 新增 `ShopSettingService.getOrderAutoCloseMinutes()`:参照 `getSalesAccumulateType()` 写法,从 `getCategoryValues("order")``orderAutoCloseMins`;0=禁用(合法返回);负数/读取失败兜底 30(常量 `DEFAULT_AUTO_CLOSE_MINUTES=30`)。
- `OrderAutoCancelTask.cancelExpiredOrders()`:先读 DB`==0` 直接 return(全局禁用,含租户特殊配置 tenantConfigs 一起跳过);`>0` 用它替换原先 YAML 的 `defaultTimeoutMinutes` 传给 `findExpiredUnpaidOrders`
- `OrderConfigProperties.AutoCancel.defaultTimeoutMinutes``@Deprecated`,仅作兜底。
- 前端 `order.vue``orderAutoCloseMins``min` 由 1 改 0placeholder/文案/校验提示改为「0 表示不自动取消」。
### 设计决策(用户已确认)
1. DB 未配置/异常兜底 = 30 分钟(与历史默认一致)
2. 现状 YAML `tenantConfigs`(租户特殊超时)保留不做改造;0=全局禁用时 tenantConfigs 也不执行
3. 不引入缓存,定时任务每次查一次 DB(开销极小),改后台 1~5 分钟内生效
4. 前端交互全部调整(min=0 + 文案 + 校验)
### 涉及文件
- JAVA: `ShopSettingService.java``ShopSettingServiceImpl.java``task/OrderAutoCancelTask.java``config/OrderConfigProperties.java`
- 前端: `src/views/shop/shopSetting/components/order.vue`
### 未做
- 未编译验证(本地无 maven/mvnw 不可用);改动为标准增量,已逐文件核对语法。
- SQL / 取消逻辑 / 库存回退 / 优惠券退还 / 销量回滚 全部未动。
+90
View File
@@ -0,0 +1,90 @@
# 2026-08-05 商品管理页(shopGoods)加载优化分析
## 关键结论(修正 2026-08-04 误判)
- 后端 `mp-java``ShopGoodsMapper.xml` 已支持 `sceneType` 参数:
- `on_sale``AND a.status = 0`
- `pending``AND a.status != 0`
- `sold_out``AND a.stock = 0`
- 前端 `search.vue``where.sceneType``pageShopGoods` 是**有效的**(JS 运行时携带,TS 类型未声明但不影响运行)。
- 结论:「出售中/待上架/已售罄」状态筛选功能**正常**,**不是卡顿根因**。上轮判定"sceneType 后端不识别"为误判。
## getCount 合并进 pageShopGoods 的影响面
- 商品模块 `getCount``/shop/shop-goods/data`)仅被 `src/views/shop/shopGoods/components/search.vue` **一处**调用。
- `cms/cmsArticle``getCount` 是独立接口(`/cms/cms-article/data`),互不影响。
- `guilixu-taro`(用户端小程序)未调用 `/shop/shop-goods/data`
- 合并到 page 响应、保留 `/data` 接口 → 向后兼容,零影响其他端。
- 收益有限:当前 `getCount``page``reload()` 里已是并发触发,合并仅省一次 HTTP 往返,非量级差异。
## 首屏请求并发情况
- 首屏同一帧发 3 个请求:pageShopGoodsdatasource)、getCountsearch.vue reload)、listShopGoodsCategoryindex.vue 顶层 query())。
- `search.vue``watch(() => props.merchantId, ..., { immediate: true })``index.vue` 顶层 `query()` 存在时序竞争,但单次进入只触发一次,未构成死循环。
## 卡顿真因(待 profiling 确认,未执行改动)
- 头号嫌疑:商品封面 `<a-image>`Ant Design Vue)不支持原生 `loading="lazy"`,首屏多行图片一次性并发请求;另 `getCompressedImageUrl` 每次 v-for 渲染重算。
- 次因:首屏 3 并发 + watch immediate 竞争。
- 建议:让用户开 DevTools → Network 看 `pageShopGoods` 耗时与是否有重复/慢请求,再定改动优先级。
## 用户决策(本轮)
- 已售罄=库存为0(与后端一致,确认)。
- getCount 合并影响面小但收益有限,待用户定。
- 导出(listShopGoods 全量)本轮先不做。
- 澄清:要替换的是商品封面 `<a-image>`**不是 ele-pro-table 整体**;仅当前模块。
## 后端 /datagetCount)实现细节(影响合并决策)
- `ShopGoodsController.data()` 实际执行:
1. `count(status=0 AND stock>0)` → totalNum(出售中)
2. `count(status>0)` → totalNum2(待上架)
3. `count(stock=0)` → totalNum3(已售罄)
4. **`update shop_goods SET status=1 WHERE stock=0`** ← 写副作用!每次调 /data 都会把已售罄商品自动下架。
- `/page` 用 MyBatis-Plus `selectPageRel`,内部已含 1 次 count(total) + 1 次 select,共 2 SQL。
- 现状进入/筛选:page(2 SQL) + data(3 count + 1 update = 4 SQL) 并发。
- 翻页/排序:只调 page(2 SQL)**不触发 data 的 update**。
## getCount 合并进 /page 的后端消耗结论(负优化,不建议)
- 合并后每次 page 调用(含翻页/排序)都多跑 3 次 count + 1 次 update → 翻页/排序 SQL 从 2 飙到 6。
- 列表返回变慢:原来 page 的 2 SQL 快速回,data 的 4 SQL 异步在后;合并后列表要等额外 3 count + 1 update 算完才返回。
- 写副作用被高频触发:翻页/排序也跑 `UPDATE ... WHERE stock=0`,幂等但带来行锁竞争,且写操作混在读链路是反模式。
- 总 DB 读次数在「进入/筛选」场景不变,但「翻页/排序」场景陡增;净收益为负(仅省一次 HTTP 往返)。
- **结论:单纯合并是负优化,不执行。** 真要优化后端统计应:① 3 次 count 合成 1 次 GROUP BY 条件聚合;② 把 update 副作用从读接口剥离(移到定时任务/下单扣库存时);③ 再谈是否挂到 page 响应。前端侧保持并发(Promise.all)即可,零后端改动。
## 已执行的前端优化(2026-08-05
用户确认执行,已改 3 个文件:
### 1. 商品封面图懒加载(index.vue + image.ts)★ 最可能见效
- `src/views/shop/shopGoods/index.vue` 第 39 行附近:`<a-image :src :preview=false :width :height>` 替换为原生
`<img class="goods-thumb" loading="lazy" width="50" height="50" alt="">`
- 原因:antdv `a-image` 不支持转发原生 `loading="lazy"`,且即使 `:preview=false` 仍包一层容器/事件;换成原生 `<img>` 后由浏览器 IntersectionObserver 懒加载,首屏仅可视区~10 张图请求。
- 新增 `.goods-thumb` 样式(object-fit:cover、圆角 4px、暗色占位背景)。
- `src/utils/image.ts``getCompressedImageUrl``Map<string,string>` 缓存,避免列表重渲染(勾选/hover/滚动)时重复拼接 OSS URL 字符串。全局受益,纯函数无副作用。
### 2. 首屏请求收敛(index.vue + search.vue
- `index.vue``<ele-pro-table>``:init-load="false"`(关闭表格自身自动首屏加载)。
- `search.vue`:去掉 `watch(merchantId, {...immediate:true})`,改为 `onMounted` 主动触发一次 `reload()`(设置 where.merchantId + 发 getCount + emit search 触发表格首次加载)。
- 原因:原 `initLoad`(默认 true) + `watch immediate` 导致首屏重复发一次 `pageShopGoods`(自动加载一次 + watch 又 reload 一次);改后首屏只有 1 次 page + 1 次 getCount(并发),无重复。
- `index.vue``query()`(加载分类)从顶层同步调用改为 `onMounted(query)`,分类数据延迟到列表首屏渲染后异步加载,不阻塞首屏;去掉 `query()` 内无用的 `loading.value = true` 写入。
- 注:`loading` ref 仍保留(handleExport 用它做防重入,非死变量,上轮"loading 死变量"判断有误)。
### 验证
- 已跑 `vite build` 后台验证可编译(待结果)。
## 后端优化已执行(2026-08-05,用户确认"现在改"
改 mp-java 共 6 处:
1. `ShopGoodsMapper.xml`:新增 `selectCountSummary` —— `CASE WHEN` 聚合一条 SQL 返回 totalNum(出售中=status=0且stock>0)/totalNum2(待上架=status>0)/totalNum3(已售罄=stock=0),替代原 3 次 count;含 `param.merchantId` 可选条件,多租户插件自动加 tenant_id。
2. `ShopGoodsMapper.java`:加 `selectCountSummary` + `downSoldOutGoods``@InterceptorIgnore(tenantLine=true)` 跨租户 `UPDATE shop_goods SET status=1 WHERE stock=0 AND status!=1`,加 `status!=1` 避免重复写)。
3. `ShopGoodsService.java`/`Impl``getCountSummary(param)`(聚合统计,Map→int 安全转换)+ `downSoldOutGoods()`
4. `ShopGoodsController.data()`:改为调 `getCountSummary(param)`,删除 3 次 count 与 update 写操作(data 接口不再写库)。
5. 新建 `shop/task/GoodsSoldOutTask.java``@Component` + `@Scheduled(cron="${shop.goods.sold-out.cron:0 */5 * * * ?}")`,每 5 分钟全局下架已售罄商品(仿 CouponExpireTask 写法,@EnableScheduling 已在 WebSoftApplication 启用)。
- 编译验证:IntelliJ 内置 Maven `compile` 通过(target/classes 已生成 GoodsSoldOutTask/ServiceImpl/Mapper.class)。
- **语义变化(需用户知晓)**
- 下架时机:原"每次打开商品列表才下架当前租户" → 现"定时任务每 5 分钟全局下架所有租户"。5 分钟窗口内新售罄商品会短暂留在"出售中"列表(on_sale 只查 status=0),直至定时任务执行。
- 范围:原依赖请求线程租户上下文(只下当前租户)→ 现 `@InterceptorIgnore` 跨租户(所有商户已售罄都下架),更彻底一致。
- 频率可调:`shop.goods.sold-out.cron` 配置项。
- 若要求"出售中"列表立即可不显示 stock=0,可给 on_sale SQL 加 `AND a.stock > 0`(未做,超出剥离范围,待用户决定)。
- 注:Controller 改造后 `LambdaQueryWrapper`/`LambdaUpdateWrapper` import 可能 unused,但 Java 编译允许 unused import 且其他接口可能仍用,未清理(编译已通过)。
## ⚠️ 重要修正:guilixu-java 才是本次后端优化的目标端(2026-08-05 用户指出"改错路径"
- 用户明确:后端改动**应该**落在 `/Users/gxwebsoft/JAVA/guilixu-java`,且 **mp-java 也保留**(两端都要有)。
- 原因澄清:shopGoods 的 `ShopGoodsController.data()` / Service / Mapper / task 在 **mp-java 与 guilixu-java 都存在且结构一致(同 `com.gxwebsoft.shop` 包)**,所以"共享 shop 业务模块"的改动要**两端同步**。
- 已对 guilixu-java 等价移植上述 6 处改动(selectCountSummary SQL、selectCountSummary/downSoldOutGoods 方法、getCountSummary/downSoldOutGoods 方法体、data() 剥离写、新建 GoodsSoldOutTask),与 mp-java 完全一致。
- 验证:IntelliJ 内置 Maven `compile` 通过(target/classes 已生成 GoodsSoldOutTask/ServiceImpl/Mapper/Controller.class)。
- 教训:之前 MEMORY.md 说"guilixu-java 的 cms 是死代码、前端不调 guilixu-java"是**针对 cms 模块**的;**不能推广到所有 shop 模块**——shopGoods 等共享业务在两端都有活代码,改动需两端同步。
+19
View File
@@ -0,0 +1,19 @@
# 2026-08-06 工作日志
## 运费模板「不发货地区」需求分析
- 客户需求:运费模板要支持"排除地区/不发货地区"(黑名单模式),而非仅"指定配送"(白名单)。
- 现状问题:当前 `ShopExpressTemplateDetail``provinceId`+`cityId` 单值,级联选择器只能选 1 个省市,无排除逻辑。截图里的双栏 Transfer modal 前端代码库里不存在,需从零开发。
- 产出接口方案文档:`docs/运费模板-不发货地区-接口方案.md`,含表字段变更(region_mode/region_ids)、下单校验逻辑、6 个待后端确认问题。
- 后端在 mp-java 的 shop 模块(前端走 MODULES_API_URL)。用户下一步:先与后端对齐接口方案,再做前端改造。
- 地区 ID 为行政区划编码字符串(省110000/市110100/区110101),下单时可前缀匹配。
- 存储方案确认:region_ids 采用**逗号分隔单字段**VARCHAR(2000)),不新建关联表。理由:查询方向固定为"模板→明细"、排除地区少、改动最小可快速上线。已写入方案文档。
- 用户确认"分两步"策略:第1步接口方案已产出(docs/运费模板-不发货地区-接口方案.md),待后端回复 6 个确认问题后做第2步前端改造。
## 后端实现检查(用户要求检查 guilixu-java 是否实现完)
- **guilixu-java 已实现完整**entity 加 regionMode/regionIds、param 加查询字段、迁移 SQL(ALTER+历史数据迁移+索引+回滚)、DeliveryRegionChecker(黑名单命中抛"暂不配送")、RegionCodeResolver(名称→编码)、OrderBusinessService 第 662/668 行已接入校验。
- **用户纠正(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 前端尚不存在。
- 教训:判断"改了哪个后端才生效"要以**运行时的 ApiUrllocalStorage/localStorage 配置的 ApiUrl,本部署=guilixu-java**为准,不能默认 `MODULES_API_URL`=mp-java。guilixu-admin 的 商城走 guilixu-java。
+130
View File
@@ -0,0 +1,130 @@
# 2026-08-11 工作记录
## 运费模板配送地区改为「省+市两级多选穿梭弹窗」
### 需求背景
客户要求运费模板的配送地区选择改成仿截图的双栏穿梭弹窗(左侧候选 + 右侧已选),
选择粒度从原「省+市单选(级联)」升级为「多条规则覆盖多城市」,并保留「不选=全国」规则。
### 改动清单(纯前端,后端字段由用户确认可改)
1. **新建组件** `src/components/RegionMultiSelect/index.vue`
- 树形可展开:省级可展开看市级;省级"选择"=选该省全部市,市级可单独选。
- 左栏搜索(按省/市名过滤)、右栏已选列表(单项是"省/市"+取消)。
- 底部:全部选择 / 取消 / 确认。
- `v-model:value` 绑定已选**城市ID数组**;额外 emit `update:provinceIds`(由城市推导的省份ID数组)。
- 复用 `src/components/RegionsSelect/load-data.ts` 的 getRegionsData()public/json/regions-data.json,取省+市两级,丢弃区级)。
2. **model** `src/api/shop/shopExpressTemplateDetail/model/index.ts`
- 新增 `provinceIds?: string`(逗号分隔)、`cityIds?: string`(逗号分隔);保留旧 `provinceId/cityId` 兼容历史数据。
3. **编辑弹窗** `src/views/shop/shopExpressTemplateDetail/components/shopExpressTemplateDetailEdit.vue`
- 移除 `RegionsSelect type="provinceCity"`,改用 `RegionMultiSelect`
- 打开时从 `record.cityIds`(兼容旧 `cityId`)回填;保存时数组→逗号串提交 `cityIds/provinceIds`
4. **列表页** `src/views/shop/shopExpressTemplateDetail/index.vue`
- "配送地区"列 customRender 改为遍历 `cityIds` 多值反查 regionMap;超22字截断+tooltip;旧单值数据兼容。
- 新增 `h` 函数 import。
### 待办 / 风险
- antd 为 v4`var(--ant-color-border)` 在暗色下可能回退浅灰(边框略浅,不影响功能);如要完美暗色适配可后续改 less 变量。
- 构建验证(vite build)已通过。
## ⚠️ 重大修订(同日):前端字段名与后端不匹配,导致"没存进库"
### 根因
- 后端 `ShopExpressTemplateDetail` 实体**已有**配送区域多选机制:`regionMode`(0=全国 / 1=指定配送白名单 / 2=不发货黑名单)+ `regionIds`(逗号分隔的 6 位城市编码串)。
- 旧代码 `provinceId`/`cityId` 是单值历史字段,已被迁移脚本 `sql/shop_express_template_detail_region.sql` 迁成 `regionMode=1, regionIds=城市编码`
- 我第一版前端提交的是 `provinceIds`/`cityIds`,后端实体无此字段 → MyBatis-Plus 自动忽略未知字段 → 看起来"没存没变化"。
- ORM 是 MyBatis-Plusadd/update 用 `save()`/`updateById()` 自动生成 SQL,接收的是实体类本身(非 Param);新增字段只需在**实体加属性 + 表加列**,无需改 Mapper XML。
### 修复(对接后端已有的 regionMode/regionIds,无需后端改动)
1. model`provinceIds`/`cityIds` → 改为 `regionMode?``regionIds?`
2. `RegionMultiSelect` 组件:去掉 `update:provinceIds` emit,只 emit `update:value`(城市编码数组)。
3. 编辑弹窗:城市选择变化 → `form.regionIds = 逗号串``form.regionMode = 1`(有选)或 `0`(全国);回显从 `form.regionIds` 拆出。
4. 列表页:customRender 读 `record.regionIds` 反查城市名。
- 结论:**本需求纯前端即可闭环,后端无需改**。城市编码(`regions-data.json` 的 city value6位)与 `regionIds` 完全一致,下单校验 `DeliveryRegionChecker``regionIds` 直接生效。
## 修复:多规格SKU表格缺少库存列
- 文件:`src/views/shop/shopGoods/components/shopGoodsEdit.vue`
- 问题:`columns` 数组缺少 `stock`(库存) 和 `salePrice`(划线价) 列定义,导致多规格SKU明细表不显示库存输入框(批量设置栏有,但表格列没有)
- 修复:在 columns 数组中补齐 `salePrice``stock` 两列(bodyCell 模板已有渲染逻辑,只需加列定义)
## 修复:商品保存「快递配送时运费模板必选」前端校验缺失
- 文件:`src/views/shop/shopGoods/components/shopGoodsEdit.vue`
- 问题:后端有校验规则——`canExpress=1` 时要求 `expressTemplateId` 必填,但前端无对应校验,用户开启快递配送后直接提交被后端拦住,报错"运费模板必选"。
- 修复:在 `save()` 的 validate 通过后、请求前增加前端拦截:`canExpress=1 && !expressTemplateId``message.error('请选择运费模板')`
- UI 优化:运费模板 `<a-form-item>``:required="form.canExpress === 1"` 动态必填标记 + placeholder 提示文字。
## 修复:多规格SKU会员价(buyingPrice)保存失败
- **根因**:后端 `ShopGoodsSku` 实体类(guilixu-java + mp-java)和数据库表 `shop_goods_sku` 均无 `buying_price` 字段。前端提交的 buyingPrice 被 MyBatis-Plus 忽略 → 保存后丢失。
- **改动(三端同步)**
1. **guilixu-java** `entity/ShopGoodsSku.java`:加 `private BigDecimal buyingPrice;`
2. **mp-java** `entity/ShopGoodsSku.java`:同上(shop 模块两端同步)
3. **前端 TS 接口** `api/shop/shopGoodsSku/model/index.ts`:加 `buyingPrice?: string;`
- **需手动执行 SQL**`ALTER TABLE shop_goods_sku ADD COLUMN buying_price DECIMAL(10,2) DEFAULT NULL COMMENT '会员价' AFTER cost;`
## 修复:多规格SKU图片删不掉
- 文件:`src/views/shop/shopGoods/components/shopGoodsEdit.vue`
- 问题:`onDeleteSkuItem(index)` 误删 `images.value`(主商品图片数组),而非 `skuList.value[index].images`(对应SKU行图片)→ 点击 × 无反应
- 修复:改为清空 `skuList.value[index].images = []` + `image = ''`
## 商品列表「价格」列支持多规格价格区间(方案A)
- 需求:多规格商品各 SKU 价格不同,原列表价格列只显示主表 `price/salePrice/dealerPrice`(占位值),与 SKU 实际价格对不上。确认采用方案A:**多规格显示 ¥最低价 ~ ¥最高价,单规格保持单值**。
- 后端(**guilixu-java**,生效后端):
1. `entity/ShopGoods.java`:加 3 个**非持久化**字段 `priceMax / salePriceMax / dealerPriceMax`BigDecimal`@TableField(exist = false)`)。
2. `service/impl/ShopGoodsServiceImpl.java``pageRel`/`listRel` 查完后调 `fillSkuPriceRange(list)`——收集 `specs==1` 的商品,`shopGoodsSkuService.lambdaQuery().in(goodsId, ids)` 一次批量查 SKU,按 `price/salePrice/buyingPrice` 算 min/max 回填(主表 `price=min``priceMax=max`;市场价/会员价同理)。只填内存对象、不落库。
- 前端(**guilixu-admin**):
1. `api/shop/shopGoods/model/index.ts`ShopGoods 加 `priceMax? / salePriceMax? / dealerPriceMax?`
2. `views/shop/shopGoods/index.vue`priceGroup 列渲染改为「区间不相等时 ¥min ~ ¥max,否则单值」;导出 Excel 新增 `fmtPrice()` 同步处理。
- 验证:`guilixu-java` `mvn -o compile` 通过(本机无全局 mvn,临时用 /tmp/apache-maven-3.9.6 离线编译);`guilixu-admin` `vite build` 通过。
- 待确认:**mp-java 是否需要同步**——本部署 admin 商城走 guilixu-javamp-java 未参与;若 mp-java 侧也有管理端/列表调用 ShopGoods.listRel,则需同步 `fillSkuPriceRange` + 实体字段。
## 修复:商品「是否可以快递配送 / 运费模板」保存不成功(同根因)
- **根因**`ShopGoods` 实体(guilixu-java)与 `shop_goods` 表**都没有** `can_express` / `express_template_id` 字段。前端提交这两个值被 MyBatis-Plus 静默丢弃(与 8-11 多规格 buyingPrice 同一类问题)。
- **改动**
1. **guilixu-java** `entity/ShopGoods.java`:加 `private Integer canExpress;`(是否可以快递配送 0/1+ `private Integer expressTemplateId;`(运费模板ID,关联 shop_express_template.id)。`map-underscore-to-camel-case=true` 已开,自动映射 `can_express`/`express_template_id`
2. **迁移 SQL**(新建)`guilixu-java/sql/add_shop_goods_express_fields.sql``ALTER TABLE shop_goods ADD can_express TINYINT NOT NULL DEFAULT 1, ADD express_template_id INT NULL;`
3. **前端** `shopGoodsEdit.vue`:把"运费模板"表单项从「基本信息」页签移到「营销设置」页签内、"是否可以快递配送"开关**正下方**(用户要求)。
- **⚠️ 上线前置动作(否则不生效)**:必须在 db_guilixu 执行该 SQL 加列,且 guilixu-java 需重新编译部署(实体加了字段)。
- 此修复同时解决"运费模板"也存不进库的问题(同一根因,用户只报了 canExpress)。
- 前端 运费模板 必填校验(save 内 `canExpress===1 && !expressTemplateId`)与动态 label 已早先存在,逻辑无需改。
## 商品管理编辑弹窗:Markdown 编辑器换成 WangEditor 富文本
### 需求
用户反馈商品「编辑弹窗」的 Markdown 编辑器不好用,要求换成 website-admin 的 `app/pages/articles.vue` 那套编辑器。
### 方案(经评估后采用)
- articles.vue 用的是 `app/components/RichTextEditor.vue`WangEditor `@wangeditor/editor-for-vue`,输出 HTML),并非 Markdown。
- 采用「编辑时按需转换」策略:**老商品 content 是 Markdown,编辑回显时用 markdown-it 转成 HTML 再进富文本编辑器;已是 HTML 则原样返回**。零 DB 风险,存储格式新旧混存。
### 改动清单
1. **新增依赖**guilixu-adminpnpm):`@wangeditor/editor@^5``@wangeditor/editor-for-vue@^5``markdown-it@^14``@types/markdown-it@^14`(已写 package.json + node_modules)。
2. **新建组件** `src/components/RichTextEditor.vue`:从 website-admin 的同名组件复制,**删除 Nuxt 专属 `<ClientOnly>` 包装**guilixu-admin 是 Vite SPA 无此组件);图片上传用 `@/api/system/file``uploadFile`(返回 `res.url || res.path`admin 已有)。
3. **改造** `src/views/shop/shopGoods/components/shopGoodsEdit.vue`
- 移除 `MdEditor``md-editor-v3`+ 相关 import / `markdownToolbars` / `onMarkdownUploadImg` / `editorVisible` watch / `disabled` ref / `editorRef` / 文件库 `SelectData` 弹窗(无打开入口,死代码)/ `:deep(.md-editor-*)` 样式。
- 商品详情 tab 内改用 `<RichTextEditor v-model="content" :height="520" v-if="active==='content'"/>`(仅在 tab 激活时挂载,规避隐藏容器初始化宽度 0 问题)。
- 新增 `md = markdownit()``toEditorContent(raw)`,编辑回显 `content.value = toEditorContent(props.data.content)`
- 补回被误删的 `FileRecord` importchooseImage 等函数仍用)。
4. **保存逻辑不变**`save()` 已读 `content.value`,直接提交 HTML。
### 验证
- `pnpm build`vite build)通过,exit 0dist 正常产出。
### 风险 / 待确认
- **消费端(小程序 `guilixu-taro/src/pages/shop/product-detail.tsx`)已兼容两种格式**`parseContent``![alt](url)``<img>`,其余 HTML 直接 `RichText nodes` 渲染 → 新旧 content 都能展示,不崩。
- **潜在坑**:若 HTML content 里含裸 `.jpg` 链接(非 `<img>` 包裹,如 `<a href="https://x/x.jpg">`),`parseContent` 的兜底会把该 URL 误转成 `<img>` 破坏链接。WangEditor 产出的图片都是 `<img>`,正常商品详情极少触发,低概率。
- `uploadFile` 返回的 `url` 是否为小程序域名白名单内完整 URL 需上线后在真机确认(影响商品详情页图片显示)。
- 老商品仅在「重新编辑」时转 HTML;从未编辑过的老 Markdown 商品保持 Markdown,仍由小程序正确渲染。
## 文章管理编辑弹窗:统一换成 WangEditor 富文本(双模式→单模式)
- 用户要求文章模块编辑器"也换了",与商品模块一致。经确认采用「统一 WangEditor 单模式」,**删除原有的 TinyMCE 富文本 + MdEditor Markdown 双模式切换**。
- 改动文件:`src/views/cms/cmsArticle/components/articleEdit.vue`(复用已建的 `src/components/RichTextEditor.vue`)。
- 删除:双模式 `v-if="editor==1/2"` 渲染分支、`editor` ref、`onEditorTypeChange/performEditorSwitch/convertContentFormat/onEditor` 切换逻辑、`markdownToolbars``onMarkdownUploadImg``openMarkdownImageSelector/openMarkdownVideoSelector``insertMarkdownText`、TinyMCE `config`(含 custom_image_selector/quick_upload/auto_format/toggle_indent 等自定义按钮 setup)、`.mce-content-body` 样式、`uploadOss` import(仅在删块中使用)。
- 新增/改写:
- 内容详情改用 `<RichTextEditor ref="editorRef" v-model="content" :height="500">`
- 编辑器下方按钮区:`📷从图片库选择`/`🎬从视频库选择`(调 `editorRef.value.insertHtml` 插入 img/video,复用 SelectData 弹窗)、`🎨一键排版`/`📐首行缩进`(直接操作 `content.value` HTML 字符串,底层 smartFormatContent/addIndent/removeIndent 纯函数保留)。
- `md = markdownit()` + `toEditorContent(raw)`:回显时 Markdown→HTML,已是 HTML 原样返回。
- `save()` 固定 `editor: 1`(统一 HTML)。
- 验证:`pnpm build` 通过(exit 0)。
- 风险/待办:
- `src/views/cms/cmsWebsiteSettingEdit.vue` 仍有"编辑器类型 1 md-editor-v3, 2 tinymce-editor"设置字段,文章编辑已不再读取,**该设置现在对文章编辑无效**,建议后续清理(改动涉及系统设置暂未动)。
- 展示端 `guilixu-taro/src/pages/index/article-detail.tsx` 用 Taro `RichText` 渲染 HTML(目前是占位页),统一 HTML 安全。
- `uploadFile` 的 url 域名白名单同商品模块,需真机确认。
+25
View File
@@ -0,0 +1,25 @@
# 2026-08-12 工作记录
## 偏远地区不发货 — 如何让远端用户无法下单(tenant 10606
- 用户已 `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://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 下单跳过校验。
## 补上后台「不发货黑名单」UI 模式(已交付)
-`src/views/shop/shopExpressTemplateDetail/components/shopExpressTemplateDetailEdit.vue`:新增「配送模式」三态 radio(0全国/1指定配送/2指定不发货)mode=2 复用 `RegionMultiSelect` 选地区,保存写 region_mode=2 + region_ids(城市码逗号串);回显按 region_mode 初始化。
-`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_MATCHtenant 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` 用户端小程序真实 appidproject.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`。无需改代码,新订单号直接读最新配置。
+26
View File
@@ -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`
- 效果:二级/三级分类前的树形引导线高度降低,整行更紧凑。
+23
View File
@@ -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/zonetenant_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);若受限专区面向小程序买家,需调整用户来源。
+30
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
# 2026-08-17 工作日志
## 专区销量统计功能(/special/zone
- 调研:专区与商品靠 `ShopGoods.section_ids`(逗号串)关联;`ShopOrderGoods` 无 sectionId,有 `goodsId/totalNum/price/payStatus/orderStatus/tenantId`;商品有 `sales` 累计字段。
- 确认方案:方案B(订单真实成交聚合)+ 列表汇总列 + 独立统计弹窗,指标=销量件数+销售额。
- 后端 guilixu-javashop 模块)改动:
- ShopHomeSection 实体加 salesNum/salesAmount@TableField(exist=false)
- 新建 VOSectionSalesSummaryVO / ShopSectionGoodsRankVO / SectionSalesStatsVOvo 包)
- ShopHomeSectionMapper 加 selectSectionSalesSummary(批量)/ selectSectionGoodsRankTOP50
- ShopHomeSectionServiceImpl.pageRel 批量填充汇总 + 新增 getSectionSalesStats
- ShopHomeSectionController 新增 GET /{id}/stats?start=&end=
- 口径:payStatus=1 且 orderStatus NOT IN(2,6)tenantId 隔离,时间对应 create_time
- 前端 guilixu-admin
- shopZone API 加 getSectionSalesStatsmodel 加字段与 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` 已导入,无需额外引入。
+40
View File
@@ -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-number0.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 个文件零新报错。
+9 -39
View File
@@ -1,42 +1,12 @@
# 项目长期记忆 # 项目长期记忆
## 项目结构 ## 专区折扣系数功能(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(后端算,不信任前端价)。
- **管理后台**`/Users/gxwebsoft/VUE/guilixu-admin`Vue3 + Ant Design Vue - **后台 guilixu-admin**`zoneEdit.vue` 有「折扣系数」输入;`shopZone/model` HomeSection 有 `discountRate`
- **用户端小程序**`/Users/gxwebsoft/VUE/guilixu-taro`Taro + React - **小程序 guilixu-taro**`section-detail``product-detail``checkout` 透传 sectionId+discountRate,「立即购买」与「购物车」两条链路均按系数生效(购物车补充见下)。
- **商家端小程序**`/Users/gxwebsoft/VUE/xinlong-shop-taro`Taro + React - **购物车链路补充(同日)**`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。
### ⚠️ 跨项目教训(重要)
- 商家端发货/门店/订单相关功能在 **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)。shop 商户/优惠券、cms 全部接口**都已走 MODULES_API_URL**,即前端本就假定 cms/shop 由 mp-java(modules 服务)提供。
- **结论: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`),各自独立部署、独立库。
### shopGoods status 字段(上架/下架)
- `0` = 已上架 / 上架
- `1` = 待上架 / 下架
- `2` = 待审核,`3` = 审核不通过
- 编辑表单「状态」radio 绑定 `form.status`0=上架、1=下架。
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
## 双后端事实(沿用)
- 商城接口实际调 `https://guilixu-api.websoft.top`(= guilixu-java, db_guilixu)cms 走 mp-java。改商城相关先确认运行时 ApiUrl。详见历史 MEMORY。
@@ -0,0 +1,129 @@
# 运费模板「不发货地区(排除地区)」接口方案
> 目标:支持商家配置"除部分偏远地区外,全国均可配送"的场景,避免逐个添加可配送省份。
> 前端代码库:guilixu-adminshop 模块接口走 MODULES_API_URL,即 mp-java 的 shop 模块)
---
## 一、现状(改造前)
- 一条 `shop_express_template_detail` = 一种配送区域 + 一套运费规则。
- 当前**只能选 1 个省市组合**,字段为 `province_id` + `city_id`(单值)。
- 不选 = 默认全国。
- **无任何排除/不发货逻辑**。
## 二、需求(改造后)
配送区域支持两种模式:
| 模式 | 含义 | 典型场景 |
|------|------|----------|
| 白名单(指定配送) | 只发列表中的地区 | 仅发江浙沪 |
| 黑名单(指定不发货) | 发列表外的所有地区 | 除新疆/西藏/港澳台外全国发 |
新增「不发货地区」配置:商家框选不发货的省/市,保存后这些地区下单时报"暂不配送"。
## 三、数据库变更
`shop_express_template_detail` 表新增两个字段(存储方案选型见下方说明):
> **存储方案选型:推荐逗号分隔单字段,不新建关联表。**
> 理由:① 本业务查询模式是「模板→明细」,非「地区→模板」,无需按地区反查;② 排除地区通常仅几个省,VARCHAR(2000) 足够;③ 改动最小、前端直接透传编码字符串、可快速上线满足客户急迫配置需求。若未来需「按地区统计运费模板」等报表,再迁关联表。
```sql
ALTER TABLE shop_express_template_detail
ADD COLUMN region_mode TINYINT NOT NULL DEFAULT 0 COMMENT '配送区域模式: 0=全国, 1=指定配送(白名单), 2=指定不发货(黑名单)',
ADD COLUMN region_ids VARCHAR(2000) DEFAULT NULL COMMENT '地区编码列表(与regions-data.json一致),逗号分隔,如 110000,120000。region_mode=1或2时有效';
```
- `region_mode=0`(全国):`region_ids` 为空,兼容旧"不选=全国"行为。
- `region_mode=1/2``region_ids` 存选中的地区编码(省/市/区 6 位编码,与前端 `regions-data.json``value` 一致)。
### 旧数据兼容
历史数据 `province_id` / `city_id` 保留不删。迁移建议:
- 旧数据 `region_mode` 默认置 `1``region_ids` = `province_id[,city_id]`(有 city 才拼)。
- 前端编辑时若检测到旧字段(无 `region_mode`),按 `province_id/city_id` 回显到白名单模式。
## 四、region_ids 存储与匹配规则
- 存**最小选中粒度**编码:用户选到省只存省码,选到市存市码(省码可推导,不必冗余存)。
- 编码前缀关系:区码 `110101` ⊃ 市码 `110100` ⊃ 省码 `110000`
- 匹配算法(下单时使用):
- 收货地址解析出 省码 / 市码 / 区码。
- 命中判断:`region_ids` 中包含收货地的 省码 **或** 市码 **或** 区码 即视为命中该条明细。
- 推荐:前端提交时统一存**省+市两级**(不存区),简化逻辑;如需区县级排除再加。
## 五、下单配送校验逻辑(后端 + 小程序端双重校验)
```
收货地址 → 解析 {provinceCode, cityCode, districtCode}
1. 查该商品运费模板下所有 detail 明细
2. 遍历所有 region_mode=2(黑名单) 明细:
若 收货地省/市/区码 任一 ∈ 该明细.region_ids
→ 直接判定【不配送】,返回 "该地区暂不配送"
3. 若无黑名单命中,按现有逻辑选运费明细:
- region_mode=0(全国) 明细 → 匹配
- region_mode=1(白名单) 明细 → 收货地码 ∈ region_ids 才匹配
4. 无任何明细匹配 → 不配送(或走默认模板,按业务定)
```
> 优先级:**黑名单排除 > 白名单/全国匹配**。即一旦落入不发货列表,直接不可下单。
## 六、接口变更
涉及接口(mp-java `ShopExpressTemplateDetailController`):
| 接口 | METHOD & PATH | 变更 |
|------|---------------|------|
| 新增明细 | POST `/shop/shop-express-template-detail` | 入参加 `regionMode``regionIds` |
| 修改明细 | PUT `/shop/shop-express-template-detail` | 入参加 `regionMode``regionIds` |
| 明细详情 | GET `/shop/shop-express-template-detail/{id}` | 出参加 `regionMode``regionIds` |
| 明细列表 | GET `/shop/shop-express-template-detail/page` | 出参加 `regionMode``regionIds` |
入参示例(新增):
```json
{
"templateId": 12,
"type": true,
"regionMode": 2,
"regionIds": "650000,540000,810000,820000,710000",
"firstNum": "1",
"firstAmount": "0",
"extraNum": "1",
"extraAmount": "0",
"status": 0,
"sortNumber": 100
}
```
## 七、需后端确认的问题 —— 已确认(2026-08-06 后端已实现)
> 落地仓库:**guilixu-java**(不是 mp-java;两边 shop 模块原本字节一致,本次只改 guilixu-java)。
| # | 问题 | 结论 |
|---|------|------|
| 1 | 编码 vs 名称 | **存编码**。6 位行政区划码,与 `regions-data.json``value` 一致。 |
| 2 | 粒度 | **省/市/区三级都支持**。前端建议只提交省+市两级;后端匹配时省/市/区任一命中即算命中。 |
| 3 | 黑白名单优先级 | **确认:黑名单命中即不可下单**,优先级高于白名单/全国。允许同一模板下多种模式共存。 |
| 4 | 旧数据迁移 | **由后端 SQL 一次性迁移**,见 `sql/shop_express_template_detail_region.sql`。经核查现网 `province_id/city_id` 存的就是 6 位区划码(如 150000/150200),可直接拼接,无需映射表。 |
| 5 | 下单校验落点 | **后端统一做**,已接入 `OrderBusinessService.createOrder()` 第 3.2 步,防绕过。小程序端可另做前置提示。 |
| 6 | 多模板 | **按租户维度跨模板取并集**。因 `shop_goods` 目前未与运费模板建立关联字段,按"本店除偏远地区外全国发货"的语义处理;后续商品绑定模板后可加 `templateId` 过滤。 |
### 后端实现要点(前端需知)
- **新增字段**`regionMode`(Integer) + `regionIds`(String),已贯通 page / list / get / save / update 全部接口,直接传即可。
- **`regionIds` 格式**:逗号分隔的 6 位编码字符串,如 `"650000,540000,810000,820000,710000"`
- **`regionMode` 取值**`0`=全国、`1`=指定配送(白名单)、`2`=指定不发货(黑名单)。**默认 0**。
- **列表筛选**`regionMode` 支持精确筛选;`regionIds` 支持模糊包含筛选。
- **旧数据回显**:迁移 SQL 已把历史行刷成 `regionMode=1` + `regionIds`,前端**无需**再兼容 `province_id/city_id` 回显逻辑。
- **地址名称→编码**:后端 `RegionCodeResolver` 负责把 `shop_user_address` 里的省/市/区**名称**翻译成编码(该表只存名称不存编码)。已处理 `新疆维吾尔自治区``新疆``北京市``北京` 等后缀归一化。
- **拦截文案**:命中时后端抛 `BusinessException`,接口返回形如 `"新疆维吾尔自治区暂不配送,请更换收货地址"`,前端直接展示 message 即可。
- **放行兜底**:地址解析不出行政区划编码时(境外地址等)**放行不拦截**,避免误伤。
> ⚠️ 上线顺序:必须**先执行** `sql/shop_express_template_detail_region.sql`,再发布后端,否则查询会报未知列。
---
*前端侧对应改造:新建 `RegionsTransfer` 双栏选择组件 + 改造 `shopExpressTemplateDetailEdit.vue` 增加模式切换 + 列表回显。*
+22
View File
@@ -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;
+32
View File
@@ -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;
+52
View File
@@ -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 '%商城%';
-- ============================================================
+4
View File
@@ -32,6 +32,8 @@
"@bytemd/plugin-gfm": "^1.17.2", "@bytemd/plugin-gfm": "^1.17.2",
"@bytemd/plugin-highlight": "^1.17.4", "@bytemd/plugin-highlight": "^1.17.4",
"@bytemd/plugin-highlight-ssr": "^1.20.2", "@bytemd/plugin-highlight-ssr": "^1.20.2",
"@wangeditor/editor": "^5",
"@wangeditor/editor-for-vue": "^5",
"@wecom/jssdk": "^1.3.1", "@wecom/jssdk": "^1.3.1",
"ali-oss": "^6.18.0", "ali-oss": "^6.18.0",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
@@ -53,6 +55,7 @@
"js-md5": "^0.7.3", "js-md5": "^0.7.3",
"jsbarcode": "^3.11.5", "jsbarcode": "^3.11.5",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"markdown-it": "^14",
"md-editor-v3": "^5.2.3", "md-editor-v3": "^5.2.3",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -73,6 +76,7 @@
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
+395
View File
@@ -34,6 +34,12 @@ importers:
'@bytemd/plugin-highlight-ssr': '@bytemd/plugin-highlight-ssr':
specifier: ^1.20.2 specifier: ^1.20.2
version: 1.22.0(bytemd@1.22.0) version: 1.22.0(bytemd@1.22.0)
'@wangeditor/editor':
specifier: ^5
version: 5.1.23
'@wangeditor/editor-for-vue':
specifier: ^5
version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.3(typescript@5.9.3))
'@wecom/jssdk': '@wecom/jssdk':
specifier: ^1.3.1 specifier: ^1.3.1
version: 1.4.5 version: 1.4.5
@@ -97,6 +103,9 @@ importers:
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
markdown-it:
specifier: ^14
version: 14.1.0
md-editor-v3: md-editor-v3:
specifier: ^5.2.3 specifier: ^5.2.3
version: 5.8.5(vue@3.5.3(typescript@5.9.3)) version: 5.8.5(vue@3.5.3(typescript@5.9.3))
@@ -152,6 +161,9 @@ importers:
'@types/lodash-es': '@types/lodash-es':
specifier: ^4.17.6 specifier: ^4.17.6
version: 4.17.12 version: 4.17.12
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/node': '@types/node':
specifier: ^22.15.0 specifier: ^22.15.0
version: 22.19.18 version: 22.19.18
@@ -1371,6 +1383,9 @@ packages:
'@socket.io/component-emitter@3.1.2': '@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
'@transloadit/prettier-bytes@0.0.7':
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -1444,6 +1459,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/event-emitter@0.3.5':
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
'@types/file-saver@2.0.7': '@types/file-saver@2.0.7':
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
@@ -1566,6 +1584,23 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@uppy/companion-client@2.2.2':
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
'@uppy/core@2.3.4':
resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==}
'@uppy/store-default@2.1.1':
resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==}
'@uppy/utils@4.1.3':
resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==}
'@uppy/xhr-upload@2.1.3':
resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==}
peerDependencies:
'@uppy/core': ^2.3.3
'@vavt/copy2clipboard@1.0.3': '@vavt/copy2clipboard@1.0.3':
resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==} resolution: {integrity: sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==}
@@ -1654,6 +1689,93 @@ packages:
'@vue/shared@3.5.34': '@vue/shared@3.5.34':
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
'@wangeditor/basic-modules@1.1.7':
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.throttle: ^4.1.1
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/code-highlight@1.0.3':
resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/core@1.1.19':
resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==}
peerDependencies:
'@uppy/core': ^2.1.1
'@uppy/xhr-upload': ^2.0.3
dom7: ^3.0.0
is-hotkey: ^0.2.0
lodash.camelcase: ^4.3.0
lodash.clonedeep: ^4.5.0
lodash.debounce: ^4.0.8
lodash.foreach: ^4.5.0
lodash.isequal: ^4.5.0
lodash.throttle: ^4.1.1
lodash.toarray: ^4.4.0
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/editor-for-vue@5.1.12':
resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==}
peerDependencies:
'@wangeditor/editor': '>=5.1.0'
vue: ^3.0.5
'@wangeditor/editor@5.1.23':
resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==}
'@wangeditor/list-module@1.0.5':
resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/table-module@1.1.4':
resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.isequal: ^4.5.0
lodash.throttle: ^4.1.1
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/upload-image-module@1.0.2':
resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==}
peerDependencies:
'@uppy/core': ^2.0.3
'@uppy/xhr-upload': ^2.0.3
'@wangeditor/basic-modules': 1.x
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.foreach: ^4.5.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/video-module@1.1.4':
resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==}
peerDependencies:
'@uppy/core': ^2.1.4
'@uppy/xhr-upload': ^2.0.7
'@wangeditor/core': 1.x
dom7: ^3.0.0
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wecom/jssdk@1.4.5': '@wecom/jssdk@1.4.5':
resolution: {integrity: sha512-1SSmcDUid7QOQNNFaM4d884NXJQYuoO+oLZtM9TRO5uMXYj2s69hmuAoPUr3D3br6VDgv4H+tbIG5A5A+uB9lA==} resolution: {integrity: sha512-1SSmcDUid7QOQNNFaM4d884NXJQYuoO+oLZtM9TRO5uMXYj2s69hmuAoPUr3D3br6VDgv4H+tbIG5A5A+uB9lA==}
@@ -2256,6 +2378,9 @@ packages:
dom-scroll-into-view@2.0.1: dom-scroll-into-view@2.0.1:
resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==}
dom7@3.0.0:
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
dompurify@3.4.5: dompurify@3.4.5:
resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==} resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==}
@@ -2728,6 +2853,9 @@ packages:
humanize-ms@1.2.1: humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
i18next@20.6.1:
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
iconv-lite@0.6.3: iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2751,6 +2879,9 @@ packages:
immediate@3.0.6: immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2809,6 +2940,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
is-number@7.0.0: is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
@@ -2825,9 +2959,16 @@ packages:
resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-type-of@1.4.0: is-type-of@1.4.0:
resolution: {integrity: sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==} resolution: {integrity: sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-what@3.14.1: is-what@3.14.1:
resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==}
@@ -3024,6 +3165,12 @@ packages:
lodash-es@4.17.21: lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -3039,6 +3186,9 @@ packages:
lodash.flatten@4.4.0: lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
lodash.foreach@4.5.0:
resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==}
lodash.groupby@4.6.0: lodash.groupby@4.6.0:
resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==}
@@ -3064,6 +3214,12 @@ packages:
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
lodash.toarray@4.4.0:
resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==}
lodash.union@4.6.0: lodash.union@4.6.0:
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
@@ -3278,6 +3434,9 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mime-match@1.0.2:
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
mime-types@2.1.35: mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3332,6 +3491,9 @@ packages:
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
namespace-emitter@2.0.1:
resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3584,6 +3746,14 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
preact@10.29.8:
resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==}
peerDependencies:
preact-render-to-string: '>=5'
peerDependenciesMeta:
preact-render-to-string:
optional: true
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -3597,6 +3767,10 @@ packages:
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
hasBin: true hasBin: true
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
process-nextick-args@2.0.1: process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -3841,6 +4015,18 @@ packages:
simple-swizzle@0.2.4: simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
slate-history@0.66.0:
resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
peerDependencies:
slate: '>=0.65.3'
slate@0.72.8:
resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==}
snabbdom@3.6.4:
resolution: {integrity: sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==}
engines: {node: '>=12.17.0'}
socket.io-client@4.8.1: socket.io-client@4.8.1:
resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -3881,6 +4067,9 @@ packages:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
ssr-window@3.0.0:
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
statuses@1.5.0: statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3973,6 +4162,9 @@ packages:
through@2.3.8: through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -4311,6 +4503,9 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
wildcard@1.1.2:
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
win-release@1.1.1: win-release@1.1.1:
resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -5993,6 +6188,8 @@ snapshots:
'@socket.io/component-emitter@3.1.2': {} '@socket.io/component-emitter@3.1.2': {}
'@transloadit/prettier-bytes@0.0.7': {}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -6060,6 +6257,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/event-emitter@0.3.5': {}
'@types/file-saver@2.0.7': {} '@types/file-saver@2.0.7': {}
'@types/geojson@7946.0.16': {} '@types/geojson@7946.0.16': {}
@@ -6211,6 +6410,37 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@uppy/companion-client@2.2.2':
dependencies:
'@uppy/utils': 4.1.3
namespace-emitter: 2.0.1
'@uppy/core@2.3.4':
dependencies:
'@transloadit/prettier-bytes': 0.0.7
'@uppy/store-default': 2.1.1
'@uppy/utils': 4.1.3
lodash.throttle: 4.1.1
mime-match: 1.0.2
namespace-emitter: 2.0.1
nanoid: 3.3.11
preact: 10.29.8
transitivePeerDependencies:
- preact-render-to-string
'@uppy/store-default@2.1.1': {}
'@uppy/utils@4.1.3':
dependencies:
lodash.throttle: 4.1.1
'@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
dependencies:
'@uppy/companion-client': 2.2.2
'@uppy/core': 2.3.4
'@uppy/utils': 4.1.3
nanoid: 3.3.11
'@vavt/copy2clipboard@1.0.3': {} '@vavt/copy2clipboard@1.0.3': {}
'@vavt/util@2.1.1': {} '@vavt/util@2.1.1': {}
@@ -6365,6 +6595,116 @@ snapshots:
'@vue/shared@3.5.34': {} '@vue/shared@3.5.34': {}
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
is-url: 1.2.4
lodash.throttle: 4.1.1
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.4
'@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
prismjs: 1.30.0
slate: 0.72.8
snabbdom: 3.6.4
'@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@types/event-emitter': 0.3.5
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
dom7: 3.0.0
event-emitter: 0.3.5
html-void-elements: 2.0.1
i18next: 20.6.1
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.foreach: 4.5.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 3.3.11
scroll-into-view-if-needed: 2.2.31
slate: 0.72.8
slate-history: 0.66.0(slate@0.72.8)
snabbdom: 3.6.4
'@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.3(typescript@5.9.3))':
dependencies:
'@wangeditor/editor': 5.1.23
vue: 3.5.3(typescript@5.9.3)
'@wangeditor/editor@5.1.23':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.foreach: 4.5.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.4
transitivePeerDependencies:
- preact-render-to-string
'@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
slate: 0.72.8
snabbdom: 3.6.4
'@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.4
'@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
lodash.foreach: 4.5.0
slate: 0.72.8
snabbdom: 3.6.4
'@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.4)
dom7: 3.0.0
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.4
'@wecom/jssdk@1.4.5': {} '@wecom/jssdk@1.4.5': {}
acorn-jsx@5.3.2(acorn@8.15.0): acorn-jsx@5.3.2(acorn@8.15.0):
@@ -7006,6 +7346,10 @@ snapshots:
dom-scroll-into-view@2.0.1: {} dom-scroll-into-view@2.0.1: {}
dom7@3.0.0:
dependencies:
ssr-window: 3.0.0
dompurify@3.4.5: dompurify@3.4.5:
optionalDependencies: optionalDependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
@@ -7556,6 +7900,10 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
i18next@20.6.1:
dependencies:
'@babel/runtime': 7.28.4
iconv-lite@0.6.3: iconv-lite@0.6.3:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -7571,6 +7919,8 @@ snapshots:
immediate@3.0.6: {} immediate@3.0.6: {}
immer@9.0.21: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -7613,6 +7963,8 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-hotkey@0.2.0: {}
is-number@7.0.0: {} is-number@7.0.0: {}
is-path-inside@3.0.3: {} is-path-inside@3.0.3: {}
@@ -7621,12 +7973,16 @@ snapshots:
is-plain-object@3.0.1: {} is-plain-object@3.0.1: {}
is-plain-object@5.0.0: {}
is-type-of@1.4.0: is-type-of@1.4.0:
dependencies: dependencies:
core-util-is: 1.0.3 core-util-is: 1.0.3
is-class-hotfix: 0.0.6 is-class-hotfix: 0.0.6
isstream: 0.1.2 isstream: 0.1.2
is-url@1.2.4: {}
is-what@3.14.1: {} is-what@3.14.1: {}
is-wsl@2.2.0: is-wsl@2.2.0:
@@ -7792,6 +8148,10 @@ snapshots:
lodash-es@4.17.21: {} lodash-es@4.17.21: {}
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
@@ -7802,6 +8162,8 @@ snapshots:
lodash.flatten@4.4.0: {} lodash.flatten@4.4.0: {}
lodash.foreach@4.5.0: {}
lodash.groupby@4.6.0: {} lodash.groupby@4.6.0: {}
lodash.isboolean@3.0.3: {} lodash.isboolean@3.0.3: {}
@@ -7818,6 +8180,10 @@ snapshots:
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.throttle@4.1.1: {}
lodash.toarray@4.4.0: {}
lodash.union@4.6.0: {} lodash.union@4.6.0: {}
lodash.uniq@4.5.0: {} lodash.uniq@4.5.0: {}
@@ -8215,6 +8581,10 @@ snapshots:
mime-db@1.52.0: {} mime-db@1.52.0: {}
mime-match@1.0.2:
dependencies:
wildcard: 1.1.2
mime-types@2.1.35: mime-types@2.1.35:
dependencies: dependencies:
mime-db: 1.52.0 mime-db: 1.52.0
@@ -8265,6 +8635,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
thenify-all: 1.6.0 thenify-all: 1.6.0
namespace-emitter@2.0.1: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.6: {} nanoid@5.1.6: {}
@@ -8469,6 +8841,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
preact@10.29.8: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0: prettier-linter-helpers@1.0.0:
@@ -8477,6 +8851,8 @@ snapshots:
prettier@2.8.8: {} prettier@2.8.8: {}
prismjs@1.30.0: {}
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
property-information@6.5.0: {} property-information@6.5.0: {}
@@ -8784,6 +9160,19 @@ snapshots:
dependencies: dependencies:
is-arrayish: 0.3.4 is-arrayish: 0.3.4
slate-history@0.66.0(slate@0.72.8):
dependencies:
is-plain-object: 5.0.0
slate: 0.72.8
slate@0.72.8:
dependencies:
immer: 9.0.21
is-plain-object: 5.0.0
tiny-warning: 1.0.3
snabbdom@3.6.4: {}
socket.io-client@4.8.1: socket.io-client@4.8.1:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.2 '@socket.io/component-emitter': 3.1.2
@@ -8827,6 +9216,8 @@ snapshots:
dependencies: dependencies:
frac: 1.1.2 frac: 1.1.2
ssr-window@3.0.0: {}
statuses@1.5.0: {} statuses@1.5.0: {}
stream-http@2.8.2: stream-http@2.8.2:
@@ -8949,6 +9340,8 @@ snapshots:
through@2.3.8: {} through@2.3.8: {}
tiny-warning@1.0.3: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -9284,6 +9677,8 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
wildcard@1.1.2: {}
win-release@1.1.1: win-release@1.1.1:
dependencies: dependencies:
semver: 5.7.2 semver: 5.7.2
@@ -12,10 +12,14 @@ export interface ShopExpressTemplateDetail {
templateId?: number; templateId?: number;
/** 计费方式: true=按件, false=按重量 */ /** 计费方式: true=按件, false=按重量 */
type?: boolean; type?: boolean;
/** 省份ID */ /** 省份ID(旧单值字段,兼容历史数据) */
provinceId?: number; provinceId?: number;
/** 城市ID */ /** 城市ID(旧单值字段,兼容历史数据) */
cityId?: number; cityId?: number;
/** 配送区域模式: 0=全国, 1=指定配送(白名单), 2=指定不发货(黑名单) */
regionMode?: number;
/** 地区编码列表(6位行政区划码,逗号分隔),regionMode=1或2时有效 */
regionIds?: string;
/** 首件数量/重量 */ /** 首件数量/重量 */
firstNum?: string; firstNum?: string;
/** 首件价格 */ /** 首件价格 */
+12 -2
View File
@@ -31,8 +31,10 @@ export interface ShopGoods {
canExpress?: number; canExpress?: number;
// 商品分类 // 商品分类
category?: string; category?: string;
// 商品分类ID // 商品分类ID(主分类,向后兼容)
categoryId?: number; categoryId?: number;
// 商品分类ID列表(方案B:可归属多个分类)
categoryIds?: number[];
parentName?: string; parentName?: string;
categoryName?: string; categoryName?: string;
// 一级分类 // 一级分类
@@ -46,7 +48,7 @@ export interface ShopGoods {
position?: string; position?: string;
// 进货价格 // 进货价格
buyingPrice?: string; buyingPrice?: string;
// 商品价格 // 商城价
price?: string; price?: string;
originPrice?: string; originPrice?: string;
// 销售价格 // 销售价格
@@ -62,6 +64,10 @@ export interface ShopGoods {
memberMarketPrice?: string; memberMarketPrice?: string;
// 经销商价格 // 经销商价格
dealerPrice?: string; dealerPrice?: string;
// 价格区间最大值(多规格列表展示用,由后端按 SKU 计算;单规格/区间相等时为 undefined
priceMax?: string;
salePriceMax?: string;
dealerPriceMax?: string;
// 有赠品 // 有赠品
buyingGift?: boolean; buyingGift?: boolean;
// 有赠品 // 有赠品
@@ -145,6 +151,8 @@ export interface ShopGoods {
activityType?: number; activityType?: number;
// 配送方式:0送上门 1限自提 // 配送方式:0送上门 1限自提
deliveryMode?: number; deliveryMode?: number;
// 所属专区(逗号分隔的 sectionId 字符串,对应 shop_goods.section_ids 列)
sectionIds?: string;
} }
export interface BathSet { export interface BathSet {
@@ -170,6 +178,8 @@ export interface ShopGoodsCollectParam {
export interface ShopGoodsParam extends PageParam { export interface ShopGoodsParam extends PageParam {
parentId?: number; parentId?: number;
categoryId?: number; categoryId?: number;
// 方案B:按多个分类查询,与单值 categoryId 二选一,多值时优先
categoryIds?: number[];
goodsId?: number; goodsId?: number;
status?: number; status?: number;
goodsName?: string; goodsName?: string;
+1 -1
View File
@@ -21,7 +21,7 @@ export interface ShopGoodsBrowse {
goodsName?: string; goodsName?: string;
/** 商品封面图(关联查询) */ /** 商品封面图(关联查询) */
goodsImage?: string; goodsImage?: string;
/** 商品价格(关联查询) */ /** 商城价(关联查询) */
price?: string; price?: string;
/** 市场价(关联查询) */ /** 市场价(关联查询) */
salePrice?: string; salePrice?: string;
+3 -1
View File
@@ -12,12 +12,14 @@ export interface ShopGoodsSku {
sku?: string; sku?: string;
// 商品图片 // 商品图片
image?: string; image?: string;
// 商品价格 // 商城价
price?: string; price?: string;
// 市场价格 // 市场价格
salePrice?: string; salePrice?: string;
// 成本价 // 成本价
cost?: string; cost?: string;
// 会员价
buyingPrice?: string;
// 库存 // 库存
stock?: number; stock?: number;
// sku编码 // sku编码
+46
View File
@@ -173,3 +173,49 @@ export async function confirmOfflinePayment(
} }
return Promise.reject(new Error(res.data.message)); return Promise.reject(new Error(res.data.message));
} }
/**
* 后台今日数据概况(含未支付口径)
* 字段对齐后端 ShopOrderOverviewVo
* - todaySales 今日销售额(元,含未支付,按 total_price 求和)
* - todayOrders 今日订单数(含未支付)
* - todayUsers 今日新增用户数
* - couponUsedCount 今日使用优惠券的订单数
*/
export interface ShopOrderOverview {
/** 今日销售额(元),含未支付,按订单面额 total_price 求和 */
todaySales: number;
/** 今日订单数,含未支付 */
todayOrders: number;
/** 今日新增用户数 */
todayUsers: number;
/** 今日使用优惠券的订单数 */
couponUsedCount: number;
}
/**
* 统计订单总金额(含未支付,按订单面额 total_price 求和)
* 对标 /shop/shop-order/total,但不限制 pay_status,返回所有未删除订单的 total_price 之和
*/
export async function shopOrderTotalAll() {
const res = await request.get<ApiResult<number>>(
MODULES_API_URL + '/shop/shop-order/total-all'
);
if (res.data.code === 0) {
return res.data.data ?? 0;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 获取后台今日数据概况(含未支付口径)
*/
export async function shopOrderOverview() {
const res = await request.get<ApiResult<ShopOrderOverview>>(
MODULES_API_URL + '/shop/shop-order/overview'
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+74
View File
@@ -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));
}
+65
View File
@@ -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;
}
+2
View File
@@ -77,6 +77,8 @@ export interface ShopSetting {
payAlipayPrivateKey?: string; payAlipayPrivateKey?: string;
payAlipayPublicKey?: string; payAlipayPublicKey?: string;
payBalanceEnabled?: boolean | number; payBalanceEnabled?: boolean | number;
salesAccumulateType?: number;
} }
/** /**
+258
View File
@@ -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));
}
+122
View File
@@ -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;
}
+1 -1
View File
@@ -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>>( const res = await request.put<ApiResult<unknown>>(
SERVER_API_URL + '/system/user/recommend', SERVER_API_URL + '/system/user/recommend',
form form
+362
View File
@@ -0,0 +1,362 @@
<!-- +市两级多选穿梭弹窗 -->
<template>
<div class="region-multi-select">
<!-- 触发区 -->
<div class="rms-trigger">
<a-button class="rms-trigger-btn" @click="openModal">
{{ triggerText }}
</a-button>
<span v-if="modelValue.length" class="rms-summary">
已选 {{ modelValue.length }} 个城市
</span>
</div>
<!-- 弹窗 -->
<a-modal
v-model:visible="visible"
title="区域选择"
width="760px"
:mask-closable="false"
@cancel="handleCancel"
>
<div class="rms-body">
<!-- 左侧候选 -->
<div class="rms-panel">
<a-input-search
v-model:value="keyword"
placeholder="输入地区关键字"
allow-clear
class="rms-search"
/>
<div class="rms-scroll">
<div
v-for="p in filteredProvinces"
:key="p.value"
class="rms-province"
>
<div class="rms-province-row">
<span class="rms-expand" @click="toggleExpand(p.value)">
{{ isExpanded(p.value) ? '' : '' }}
</span>
<span class="rms-label" @click="toggleExpand(p.value)">
{{ p.label }}
</span>
<a-button
type="link"
size="small"
:disabled="isProvinceSelected(p)"
@click="selectProvince(p)"
>
{{ isProvinceSelected(p) ? '已选' : '选择' }}
</a-button>
</div>
<div v-if="isExpanded(p.value)" class="rms-city-list">
<div
v-for="c in p.cities"
:key="c.value"
class="rms-city-row"
>
<span class="rms-label">{{ c.label }}</span>
<a-button
type="link"
size="small"
:disabled="isCitySelected(c.value)"
@click="toggleCity(c.value)"
>
{{ isCitySelected(c.value) ? '已选' : '选择' }}
</a-button>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧已选 -->
<div class="rms-panel">
<div class="rms-selected-title">
已选择 ({{ selectedCities.length }})
</div>
<div class="rms-scroll">
<div
v-for="item in selectedList"
:key="item.cityValue"
class="rms-selected-item"
>
<span class="rms-selected-label">
{{ item.provinceLabel }} / {{ item.cityLabel }}
</span>
<a-button
type="link"
size="small"
danger
@click="removeCity(item.cityValue)"
>
取消
</a-button>
</div>
<a-empty
v-if="!selectedCities.length"
:image="simpleImage"
description="暂未选择"
/>
</div>
</div>
</div>
<template #footer>
<a-button @click="selectAll">全部选择</a-button>
<span class="rms-footer-spacer"></span>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" @click="handleConfirm">确认</a-button>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { Empty } from 'ant-design-vue';
import { getRegionsData } from '@/components/RegionsSelect/load-data';
const props = withDefaults(
defineProps<{
/** 已选城市ID数组(逗号分隔的行政区码) */
value?: string[];
placeholder?: string;
}>(),
{ value: () => [], placeholder: '选择配送地区' }
);
const emit = defineEmits<{
(e: 'update:value', value: string[]): void;
}>();
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
const visible = ref(false);
const keyword = ref('');
const expandedSet = ref<Set<string>>(new Set());
const selectedSet = ref<Set<string>>(new Set());
interface CityItem {
value: string;
label: string;
}
interface ProvinceItem {
value: string;
label: string;
cities: CityItem[];
}
const provinceList = ref<ProvinceItem[]>([]);
const cityToProvince = ref<Record<string, string>>({});
const labelMap = ref<Record<string, string>>({});
// 加载省+市两级数据(丢弃区级)
getRegionsData().then((data) => {
const list: ProvinceItem[] = [];
const c2p: Record<string, string> = {};
const lm: Record<string, string> = {};
(data ?? []).forEach((p) => {
lm[p.value] = p.label;
const cities: CityItem[] = (p.children ?? []).map((c) => {
c2p[c.value] = p.value;
lm[c.value] = c.label;
return { value: c.value, label: c.label };
});
list.push({ value: p.value, label: p.label, cities });
});
provinceList.value = list;
cityToProvince.value = c2p;
labelMap.value = lm;
});
const modelValue = computed(() => props.value ?? []);
const triggerText = computed(() =>
modelValue.value.length
? `已选 ${modelValue.value.length} 个城市`
: props.placeholder
);
const isExpanded = (v: string) => expandedSet.value.has(v);
const toggleExpand = (v: string) => {
const s = new Set(expandedSet.value);
if (s.has(v)) {
s.delete(v);
} else {
s.add(v);
}
expandedSet.value = s;
};
const isCitySelected = (v: string) => selectedSet.value.has(v);
const isProvinceSelected = (p: ProvinceItem) =>
p.cities.length > 0 && p.cities.every((c) => selectedSet.value.has(c.value));
const filteredProvinces = computed(() => {
const kw = keyword.value.trim();
if (!kw) {
return provinceList.value;
}
return provinceList.value
.map((p) => {
const matchProvince = p.label.includes(kw);
if (matchProvince) {
return p;
}
const cities = p.cities.filter((c) => c.label.includes(kw));
if (cities.length) {
return { ...p, cities };
}
return null;
})
.filter(Boolean) as ProvinceItem[];
});
const selectedCities = computed(() => Array.from(selectedSet.value));
const selectedList = computed(() =>
selectedCities.value.map((cv) => ({
cityValue: cv,
cityLabel: labelMap.value[cv] || cv,
provinceValue: cityToProvince.value[cv],
provinceLabel: labelMap.value[cityToProvince.value[cv]] || ''
}))
);
const selectProvince = (p: ProvinceItem) => {
const s = new Set(selectedSet.value);
p.cities.forEach((c) => s.add(c.value));
selectedSet.value = s;
};
const toggleCity = (cv: string) => {
const s = new Set(selectedSet.value);
if (s.has(cv)) {
s.delete(cv);
} else {
s.add(cv);
}
selectedSet.value = s;
};
const removeCity = (cv: string) => {
const s = new Set(selectedSet.value);
s.delete(cv);
selectedSet.value = s;
};
const selectAll = () => {
const s = new Set<string>();
provinceList.value.forEach((p) =>
p.cities.forEach((c) => s.add(c.value))
);
selectedSet.value = s;
};
const openModal = () => {
selectedSet.value = new Set(modelValue.value);
keyword.value = '';
visible.value = true;
};
const handleConfirm = () => {
emit('update:value', selectedCities.value);
visible.value = false;
};
const handleCancel = () => {
visible.value = false;
};
// 外部清空时同步内部选中态
watch(
() => props.value,
(v) => {
if (!visible.value) {
selectedSet.value = new Set(v ?? []);
}
}
);
</script>
<style scoped>
.region-multi-select {
width: 100%;
}
.rms-trigger {
display: flex;
align-items: center;
gap: 12px;
}
.rms-trigger-btn {
flex: 1;
text-align: left;
}
.rms-summary {
color: var(--ant-color-text-secondary, #999);
font-size: 12px;
white-space: nowrap;
}
.rms-body {
display: flex;
gap: 16px;
height: 420px;
}
.rms-panel {
flex: 1;
border: 1px solid var(--ant-color-border, #f0f0f0);
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.rms-search {
margin-bottom: 12px;
}
.rms-scroll {
flex: 1;
overflow-y: auto;
}
.rms-province-row {
display: flex;
align-items: center;
padding: 4px 0;
}
.rms-expand {
width: 20px;
cursor: pointer;
user-select: none;
color: var(--ant-color-text-secondary, #999);
}
.rms-label {
flex: 1;
cursor: default;
}
.rms-city-list {
padding-left: 24px;
}
.rms-city-row {
display: flex;
align-items: center;
padding: 2px 0;
}
.rms-selected-title {
font-weight: 600;
margin-bottom: 8px;
}
.rms-selected-item {
display: flex;
align-items: center;
padding: 4px 0;
border-bottom: 1px dashed var(--ant-color-border, #f5f5f5);
}
.rms-selected-label {
flex: 1;
}
.rms-footer-spacer {
flex: 1;
}
</style>
+205
View File
@@ -0,0 +1,205 @@
<template>
<div class="rich-text-editor">
<!-- 编辑器尚未初始化时的占位 -->
<div
v-if="!editorReady"
:style="{ height: editorHeight + 'px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }"
>
编辑器加载中...
</div>
<template v-else>
<component
:is="Toolbar"
v-if="editorRef"
:editor="editorRef"
:defaultConfig="toolbarConfig"
style="border-bottom: 1px solid #d9d9d9"
/>
<component
:is="Editor"
:defaultConfig="editorConfig"
:modelValue="modelValue"
@onCreated="handleCreated"
@onChange="handleChange"
:style="{ height: editorHeight + 'px', overflowY: 'hidden' }"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import { message } from 'ant-design-vue'
const editorReady = ref(false)
const editorRef = shallowRef<any>()
const Toolbar = shallowRef<any>(null)
const Editor = shallowRef<any>(null)
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
/** 编辑器高度(px),默认 380 */
height?: number
/** 图片上传大小限制(MB),默认 5 */
maxImageSize?: number
}>(),
{
modelValue: '',
placeholder: '请输入正文...',
height: 380,
maxImageSize: 5
}
)
const editorHeight = computed(() => props.height)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 工具栏配置
const toolbarConfig: Record<string, any> = {
// 视频上传未配置,仍排除;全屏按钮保留(更自然)
excludeKeys: ['group-video']
}
/** 上传图片并返回 URL */
async function doUploadImage(file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return ''
}
if (file.size / 1024 / 1024 > props.maxImageSize) {
message.error(`图片大小不能超过 ${props.maxImageSize}MB`)
return ''
}
// 动态导入 uploadFile 避免循环依赖
const { uploadFile } = await import('@/api/system/file')
const res = await uploadFile(file)
return res.url || res.path || ''
}
/** 在编辑器当前光标处插入图片 */
function insertEditorImage(editor: any, url: string, alt = '') {
if (!editor || !url) return
try {
if (typeof editor.focus === 'function') editor.focus()
if (typeof editor.insertNode === 'function') {
editor.insertNode({
type: 'image',
src: url,
alt,
href: url,
children: [{ text: '' }]
})
} else if (typeof editor.dangerouslyInsertHtml === 'function') {
editor.dangerouslyInsertHtml(`<img src="${url}" alt="${alt}" style="max-width:100%;"/>`)
}
} catch (e) {
message.error('图片插入失败')
}
}
// 编辑器配置
const editorConfig: Record<string, any> = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
try {
const url = await doUploadImage(file)
if (url) insertFn(url, file.name || 'image', url)
} catch (e: any) {
message.error(e?.message || '图片上传失败')
}
}
}
},
customPaste(editor: any, event: ClipboardEvent) {
const items = event.clipboardData?.items
if (!items || items.length === 0) return true
const imageFiles: File[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (!item) continue
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length === 0) return true
// 阻止默认粘贴,避免把 base64 大图片直接塞进编辑器
event.preventDefault()
// 异步上传所有粘贴的图片并插入
Promise.all(
imageFiles.map(async (file) => {
const url = await doUploadImage(file)
return { file, url }
})
).then((results) => {
results.forEach(({ url }) => {
if (url) insertEditorImage(editor, url, '粘贴图片')
})
})
return false
}
}
function handleCreated(editor: any) {
editorRef.value = editor
}
function handleChange(editor: any) {
emit('update:modelValue', editor.getHtml())
}
/** 在光标处插入 HTML(供「插入附件」等外部入口调用) */
function insertHtml(html: string) {
const editor = editorRef.value
if (editor && typeof editor.dangerouslyInsertHtml === 'function') {
editor.dangerouslyInsertHtml(html)
}
}
defineExpose({ insertHtml })
// 组件销毁时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor && typeof editor.destroy === 'function') {
try {
editor.destroy()
} catch {
// 已销毁或实例无效时忽略
}
}
editorRef.value = null
})
// 客户端才加载 WangEditorCSS + 组件)
onMounted(async () => {
// 并行加载 CSS 和组件
const [cssModule, wangEditorModule] = await Promise.all([
import('@wangeditor/editor/dist/css/style.css'),
import('@wangeditor/editor-for-vue')
])
Toolbar.value = wangEditorModule.Toolbar
Editor.value = wangEditorModule.Editor
editorReady.value = true
})
</script>
<style scoped>
.rich-text-editor {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
</style>
+60 -268
View File
@@ -1,21 +1,26 @@
/** /**
* 统计数据 store * 统计数据 store
* 说明:今日概况(销售额/订单数/新增会员/用券数)改为调用后端聚合接口
* /shop/shop-order/stats/overview,不再前端拉全量订单求和,也不再依赖 cms_statistics 表。
*/ */
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { pageUsers } from '@/api/system/user'; import { pageUsers } from '@/api/system/user';
import { pageShopOrder, shopOrderTotal, listShopOrder } from '@/api/shop/shopOrder'; import { pageShopOrder, shopOrderTotal } from '@/api/shop/shopOrder';
import { import { getShopOrderStatsOverview } from '@/api/shop/shopOrderStats';
addCmsStatistics, import { safeNumber } from '@/utils/type-guards';
listCmsStatistics,
updateCmsStatistics
} from '@/api/cms/cmsStatistics';
import { CmsStatistics } from '@/api/cms/cmsStatistics/model';
import { safeNumber, hasValidId } from '@/utils/type-guards';
export interface StatisticsState { export interface StatisticsState {
// 统计数据 // 统计数据
statistics: CmsStatistics | null; statistics: {
userCount: number;
orderCount: number;
totalSales: number;
todaySales: number;
monthSales: number;
todayOrders: number;
todayUsers: number;
} | null;
// 加载状态 // 加载状态
loading: boolean; loading: boolean;
// 最后更新时间 // 最后更新时间
@@ -40,65 +45,14 @@ export const useStatisticsStore = defineStore('statistics', {
}), }),
getters: { getters: {
/** userCount: (state): number => safeNumber(state.statistics?.userCount),
* 获取用户总数 orderCount: (state): number => safeNumber(state.statistics?.orderCount),
*/ totalSales: (state): number => safeNumber(state.statistics?.totalSales),
userCount: (state): number => { todaySales: (state): number => safeNumber(state.statistics?.todaySales),
return safeNumber(state.statistics?.userCount); 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),
* 获取订单总数
*/
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);
},
/**
* 检查缓存是否有效
*/
isCacheValid: (state): boolean => { isCacheValid: (state): boolean => {
if (!state.lastUpdateTime) return false; if (!state.lastUpdateTime) return false;
const now = Date.now(); const now = Date.now();
@@ -107,40 +61,35 @@ export const useStatisticsStore = defineStore('statistics', {
}, },
actions: { actions: {
/**
* 获取统计数据
* @param forceRefresh 是否强制刷新
*/
async fetchStatistics(forceRefresh = false) { async fetchStatistics(forceRefresh = false) {
// 如果缓存有效且不强制刷新,直接返回缓存数据
if (!forceRefresh && this.isCacheValid && this.statistics) { if (!forceRefresh && this.isCacheValid && this.statistics) {
return this.statistics; return this.statistics;
} }
this.loading = true; this.loading = true;
try { try {
// 并行获取各种统计数据,使用Promise.allSettled确保部分失败不影响整体 // 今日区间(与后端 dayjs startOf/endOf('day') 对齐)
const [usersResult, ordersResult, totalResult, statisticsResult] = 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([ await Promise.allSettled([
pageUsers({ page: 1, limit: 1 }), pageUsers({ page: 1, limit: 1 }),
pageShopOrder({ page: 1, limit: 1 }), pageShopOrder({ page: 1, limit: 1 }),
shopOrderTotal(), shopOrderTotal(),
listCmsStatistics({}) getShopOrderStatsOverview({ start: todayStart, end: todayEnd })
]); ]);
// 安全提取结果
const users = const users =
usersResult.status === 'fulfilled' ? usersResult.value : null; usersResult.status === 'fulfilled' ? usersResult.value : null;
const orders = const orders =
ordersResult.status === 'fulfilled' ? ordersResult.value : null; ordersResult.status === 'fulfilled' ? ordersResult.value : null;
const total = const total =
totalResult.status === 'fulfilled' ? totalResult.value : null; totalResult.status === 'fulfilled' ? totalResult.value : null;
const statisticsData = const overview =
statisticsResult.status === 'fulfilled' overviewResult.status === 'fulfilled' ? overviewResult.value : null;
? statisticsResult.value
: null;
// 记录失败的API调用
if (usersResult.status === 'rejected') { if (usersResult.status === 'rejected') {
console.error('❌ 用户API调用失败:', usersResult.reason); console.error('❌ 用户API调用失败:', usersResult.reason);
} }
@@ -150,178 +99,41 @@ export const useStatisticsStore = defineStore('statistics', {
if (totalResult.status === 'rejected') { if (totalResult.status === 'rejected') {
console.error('❌ 订单总额API调用失败:', totalResult.reason); console.error('❌ 订单总额API调用失败:', totalResult.reason);
} }
if (statisticsResult.status === 'rejected') { if (overviewResult.status === 'rejected') {
console.error('❌ 统计数据API调用失败:', statisticsResult.reason); console.error('❌ 今日概览API调用失败:', overviewResult.reason);
} }
// 添加调试日志 const userCount =
console.log('🔍 统计数据获取结果:', { users && typeof users === 'object' && 'count' in users
users: users, ? safeNumber((users as any).count)
orders: orders, : 0;
total: total, const orderCount =
statisticsData: statisticsData 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;
// 安全获取用户数量,添加更详细的验证 this.statistics = {
const userCount = (() => { userCount,
if (!users) { orderCount,
console.warn('⚠️ 用户API返回空数据'); totalSales,
return 0; todaySales,
} monthSales: todaySales,
if (typeof users === 'object' && 'count' in users) { todayOrders,
const count = users.count; todayUsers
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.couponUsedCount = couponUsedCount; this.couponUsedCount = couponUsedCount;
this.lastUpdateTime = Date.now(); this.lastUpdateTime = Date.now();
return statistics; return this.statistics;
} catch (error) { } catch (error) {
console.error('获取统计数据失败:', error); console.error('获取统计数据失败:', error);
throw error; throw error;
@@ -330,44 +142,27 @@ export const useStatisticsStore = defineStore('statistics', {
} }
}, },
/** updateStatistics(statistics: Partial<StatisticsState['statistics']>) {
* 更新统计数据
*/
updateStatistics(statistics: Partial<CmsStatistics>) {
if (this.statistics) { if (this.statistics) {
this.statistics = { ...this.statistics, ...statistics }; this.statistics = { ...this.statistics, ...statistics };
this.lastUpdateTime = Date.now(); this.lastUpdateTime = Date.now();
} }
}, },
/**
* 清除缓存
*/
clearCache() { clearCache() {
this.statistics = null; this.statistics = null;
this.lastUpdateTime = null; this.lastUpdateTime = null;
}, },
/**
* 强制刷新统计数据
*/
async forceRefresh() { async forceRefresh() {
console.log('🔄 强制刷新统计数据...');
this.clearCache(); this.clearCache();
return await this.fetchStatistics(true); return await this.fetchStatistics(true);
}, },
/**
* 设置缓存有效期
*/
setCacheExpiry(expiry: number) { setCacheExpiry(expiry: number) {
this.cacheExpiry = expiry; this.cacheExpiry = expiry;
}, },
/**
* 开始自动刷新
* @param interval 刷新间隔(毫秒),默认5分钟
*/
startAutoRefresh(interval = 5 * 60 * 1000) { startAutoRefresh(interval = 5 * 60 * 1000) {
this.stopAutoRefresh(); this.stopAutoRefresh();
this.refreshTimer = window.setInterval(() => { this.refreshTimer = window.setInterval(() => {
@@ -375,9 +170,6 @@ export const useStatisticsStore = defineStore('statistics', {
}, interval); }, interval);
}, },
/**
* 停止自动刷新
*/
stopAutoRefresh() { stopAutoRefresh() {
if (this.refreshTimer) { if (this.refreshTimer) {
clearInterval(this.refreshTimer); clearInterval(this.refreshTimer);
+21 -3
View File
@@ -76,18 +76,34 @@ function removeOssProcess(url: string): string {
* @param options 压缩选项(width/quality 不传则使用默认值) * @param options 压缩选项(width/quality 不传则使用默认值)
* @returns 处理后的图片 URL * @returns 处理后的图片 URL
*/ */
// 缓存已计算的压缩 URL,避免列表重渲染时(勾选、hover、滚动)重复拼接字符串
const compressedUrlCache = new Map<string, string>();
export function getCompressedImageUrl( export function getCompressedImageUrl(
url: string, url: string,
options?: ImageCompressOptions options?: ImageCompressOptions
): string { ): string {
if (!url) return '';
// 命中缓存直接返回,省去重复计算
const cacheKey = options ? `${url}|${JSON.stringify(options)}` : url;
const cached = compressedUrlCache.get(cacheKey);
if (cached !== undefined) return cached;
// 先补全为完整 URL // 先补全为完整 URL
const fullUrl = ensureFullUrl(url); const fullUrl = ensureFullUrl(url);
if (!fullUrl) return ''; if (!fullUrl) {
compressedUrlCache.set(cacheKey, '');
return '';
}
const { width, quality, enabled } = { ...DEFAULT_OPTIONS, ...options }; const { width, quality, enabled } = { ...DEFAULT_OPTIONS, ...options };
// 未启用压缩,原样返回 // 未启用压缩,原样返回
if (!enabled) return fullUrl; if (!enabled) {
compressedUrlCache.set(cacheKey, fullUrl);
return fullUrl;
}
// 优先使用调用方指定的 width/quality // 优先使用调用方指定的 width/quality
// 先剥离 URL 自带的 x-oss-process,再按本函数参数重新拼接,确保以调用方为准。 // 先剥离 URL 自带的 x-oss-process,再按本函数参数重新拼接,确保以调用方为准。
@@ -96,5 +112,7 @@ export function getCompressedImageUrl(
// 已有其他 query 参数用 & 拼接,否则用 ? // 已有其他 query 参数用 & 拼接,否则用 ?
const separator = baseUrl.includes('?') ? '&' : '?'; const separator = baseUrl.includes('?') ? '&' : '?';
return `${baseUrl}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`; const result = `${baseUrl}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`;
compressedUrlCache.set(cacheKey, result);
return result;
} }
@@ -80,58 +80,22 @@
/> />
</a-form-item> </a-form-item>
<a-form-item label="内容详情" name="content"> <a-form-item label="内容详情" name="content">
<!-- 富文本编辑器 --> <RichTextEditor
<div v-if="editor == 1"> ref="editorRef"
<tinymce-editor v-model="content"
ref="editorRef" :disabled="disabled"
class="editor-content" :height="500"
v-model:value="content" placeholder="支持直接粘贴或拖拽图片,也可点击下方按钮从图片库选择"
:disabled="disabled" />
:init="config" <div class="file-selector-tip">
placeholder="支持直接粘贴或拖拽图片,也可点击工具栏图片按钮从文件库选择" 💡 提示支持直接粘贴/拖拽上传图片点击下方按钮从图片库/视频库选择或使用排版首行缩进优化文章
/>
<div class="file-selector-tip">
💡
提示工具栏"图片"按钮从图片库选择"上传"按钮快速上传图片"视频"按钮从视频库选择"上传视频"按钮快速上传视频"一键排版"按钮自动优化文章格式"首行缩进"按钮切换段落缩进
</div>
</div> </div>
<a-space class="editor-toolbar-ext mt-2" wrap>
<!-- Markdown编辑器 --> <a-button size="small" @click="openImageSelector">📷 从图片库选择</a-button>
<div v-if="editor == 2"> <a-button size="small" @click="openVideoSelector">🎬 从视频库选择</a-button>
<!-- 📝 Markdown编辑器工具栏扩展 --> <a-button size="small" @click="handleAutoFormat">🎨 一键排版</a-button>
<div class="markdown-toolbar-extension"> <a-button size="small" @click="toggleParagraphIndent">📐 首行缩进</a-button>
<a-button </a-space>
type="primary"
size="small"
@click="openMarkdownImageSelector"
style="margin-right: 8px"
>
📷 从图片库选择
</a-button>
<a-button
type="default"
size="small"
@click="openMarkdownVideoSelector"
style="margin-right: 8px"
>
🎬 从视频库选择
</a-button>
</div>
<MdEditor
v-model="content"
:disabled="disabled"
height="500px"
:placeholder="'请输入Markdown内容...'"
:toolbars="markdownToolbars"
:onUploadImg="onMarkdownUploadImg"
/>
<div class="file-selector-tip">
💡
提示支持Markdown语法可以使用工具栏按钮从文件库选择图片/视频也可以直接拖拽上传文件
</div>
</div>
<a-space <a-space
class="py-2 flex items-center text-gray-400" class="py-2 flex items-center text-gray-400"
v-if="lang == 'zh_CN'" v-if="lang == 'zh_CN'"
@@ -317,11 +281,8 @@
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types'; import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { FormInstance, RuleObject } from 'ant-design-vue/es/form'; import { FormInstance, RuleObject } from 'ant-design-vue/es/form';
import { FileRecord } from '@/api/system/file/model'; import { FileRecord } from '@/api/system/file/model';
import TinymceEditor from '@/components/TinymceEditor/index.vue'; import RichTextEditor from '@/components/RichTextEditor.vue';
import { MdEditor } from 'md-editor-v3'; import markdownit from 'markdown-it';
import 'md-editor-v3/lib/style.css';
import 'md-editor-v3/lib/preview.css';
import { uploadOss } from '@/api/system/file';
import { CmsArticleCategory } from '@/api/cms/cmsArticleCategory/model'; import { CmsArticleCategory } from '@/api/cms/cmsArticleCategory/model';
import { CmsNavigation } from '@/api/cms/cmsNavigation/model'; import { CmsNavigation } from '@/api/cms/cmsNavigation/model';
import SourceSelect from '@/views/cms/cmsArticle/dictionary/source-select.vue'; import SourceSelect from '@/views/cms/cmsArticle/dictionary/source-select.vue';
@@ -335,8 +296,6 @@
const useForm = Form.useForm; const useForm = Form.useForm;
const setting = useWebsiteSettingStore(); const setting = useWebsiteSettingStore();
const { locale } = useI18n(); const { locale } = useI18n();
const editor = ref<number>(1);
// 是否开启响应式布局 // 是否开启响应式布局
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore); const { styleResponsive } = storeToRefs(themeStore);
@@ -486,138 +445,6 @@
} }
}; };
// 📝 编辑器类型切换处理
const onEditorTypeChange = (e: any) => {
const newEditorType = e.target.value;
const oldEditorType = editor.value;
// 如果编辑器类型没有变化,直接返回
if (newEditorType === oldEditorType) {
return;
}
// 如果当前有内容,提示用户确认切换
if (
content.value &&
content.value.trim() !== '' &&
content.value !== '<p><br></p>'
) {
Modal.confirm({
title: '🔄 切换编辑器类型',
content: '切换编辑器类型可能会影响内容格式,是否继续?',
okText: '确认切换',
cancelText: '取消',
onOk: () => {
performEditorSwitch(newEditorType, oldEditorType);
},
onCancel: () => {
// 恢复原来的编辑器类型
editor.value = oldEditorType;
}
});
} else {
// 没有内容直接切换
performEditorSwitch(newEditorType, oldEditorType);
}
};
// 执行编辑器切换
const performEditorSwitch = (newType: number, oldType: number) => {
try {
editor.value = newType;
// 保存编辑器类型偏好到本地存储
localStorage.setItem('cms_article_editor_preference', newType.toString());
// 显示切换成功提示
const editorName = newType === 1 ? '富文本编辑器' : 'Markdown编辑器';
message.success({
content: `✅ 已切换到${editorName}`,
duration: 2
});
// 如果有内容,尝试进行格式转换
if (content.value && content.value.trim() !== '') {
convertContentFormat(oldType, newType);
}
} catch (error) {
console.error('编辑器切换失败:', error);
message.error('编辑器切换失败,请重试');
// 恢复原来的编辑器类型
editor.value = oldType;
}
};
// 内容格式转换
const convertContentFormat = (fromType: number, toType: number) => {
if (!content.value) return;
try {
if (fromType === 1 && toType === 2) {
// 富文本转Markdown(简单转换)
let markdownContent = content.value
.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n')
.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n\n')
.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n\n')
.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
.replace(
/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi,
'![$2]($1)'
)
.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '![图片]($1)')
.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
.replace(/<br[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '') // 移除其他HTML标签
.replace(/\n{3,}/g, '\n\n') // 清理多余换行
.trim();
content.value = markdownContent;
message.info('已尝试将富文本内容转换为Markdown格式');
} else if (fromType === 2 && toType === 1) {
// Markdown转富文本(基本转换)
let htmlContent = content.value
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^#### (.*$)/gim, '<h4>$1</h4>')
.replace(/^##### (.*$)/gim, '<h5>$1</h5>')
.replace(/^###### (.*$)/gim, '<h6>$1</h6>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
'<img src="$2" alt="$1" style="max-width: 100%;" />'
)
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
// 包装在段落标签中
if (htmlContent && !htmlContent.startsWith('<')) {
htmlContent = '<p>' + htmlContent + '</p>';
}
content.value = htmlContent;
message.info('已尝试将Markdown内容转换为富文本格式');
}
} catch (error) {
console.error('内容格式转换失败:', error);
message.warning('内容格式转换失败,请手动调整内容格式');
}
};
const onEditor = (value: any) => {
editor.value = value.target.value;
};
const onComments = () => { const onComments = () => {
if (form.comments == undefined) { if (form.comments == undefined) {
@@ -737,7 +564,16 @@
}); });
}; };
const editorRef = ref<InstanceType<typeof TinymceEditor> | null>(null); const editorRef = ref<any>(null);
// markdown-it 实例:编辑回显时把老 Markdown 内容转成 HTML 喂给富文本编辑器
const md = markdownit();
const toEditorContent = (val: string): string => {
if (!val) return '';
// 已是 HTML(含标签)则原样返回,否则用 markdown-it 转 HTML
if (/<[a-z][\s\S]*>/i.test(val)) return val;
return md.render(val);
};
// 文件库选择弹窗状态 // 文件库选择弹窗状态
const showFileSelector = ref(false); const showFileSelector = ref(false);
@@ -747,324 +583,6 @@
const showVideoSelector = ref(false); const showVideoSelector = ref(false);
const videoSelectCallback = ref<((url: string) => void) | null>(null); const videoSelectCallback = ref<((url: string) => void) | null>(null);
// 📝 Markdown编辑器配置
const markdownToolbars = [
'bold',
'underline',
'italic',
'-',
'title',
'strikeThrough',
'sub',
'sup',
'quote',
'unorderedList',
'orderedList',
'task',
'-',
'codeRow',
'code',
'link',
'image',
'table',
'mermaid',
'katex',
'-',
'revoke',
'next',
'save',
'=',
'pageFullscreen',
'fullscreen',
'preview',
'previewOnly',
'htmlPreview',
'catalog'
] as any;
// 📝 Markdown编辑器图片上传处理
const onMarkdownUploadImg = async (
files: File[],
callback: (urls: string[]) => void
) => {
try {
const uploadPromises = files.map(async (file) => {
// 检查文件大小(限制为10MB
if (file.size > 10 * 1024 * 1024) {
message.error(`图片 ${file.name} 大小超过10MB,请选择更小的文件`);
return null;
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
message.error(`文件 ${file.name} 不是有效的图片格式`);
return null;
}
try {
const result = await uploadOss(file);
return result.url || result.path;
} catch (error) {
console.error('图片上传失败:', error);
message.error(`图片 ${file.name} 上传失败`);
return null;
}
});
const results = await Promise.all(uploadPromises);
const successUrls = results.filter((url) => url !== null) as string[];
if (successUrls.length > 0) {
callback(successUrls);
message.success(`成功上传 ${successUrls.length} 张图片`);
}
} catch (error) {
console.error('批量上传失败:', error);
message.error('图片上传失败,请重试');
}
};
// 📝 Markdown编辑器图片选择器
const openMarkdownImageSelector = () => {
fileSelectCallback.value = (url: string) => {
// 在当前光标位置插入Markdown图片语法
const imageMarkdown = `![图片](${url})`;
insertMarkdownText(imageMarkdown);
};
showFileSelector.value = true;
};
// 📝 Markdown编辑器视频选择器
const openMarkdownVideoSelector = () => {
videoSelectCallback.value = (url: string) => {
// 在当前光标位置插入Markdown视频语法(使用HTML标签)
const videoMarkdown = `<video controls style="max-width: 100%; height: auto;">
<source src="${url}" type="video/mp4">
您的浏览器不支持视频播放。
</video>`;
insertMarkdownText(videoMarkdown);
};
showVideoSelector.value = true;
};
// 📝 在Markdown编辑器中插入文本
const insertMarkdownText = (text: string) => {
// 简单的文本插入,在内容末尾添加
if (content.value) {
content.value += '\n\n' + text;
} else {
content.value = text;
}
};
const config = ref({
height: 420,
paste_data_images: true,
automatic_uploads: true,
// 自定义工具栏,移除默认的image和media按钮,添加自定义按钮 (视频、上传视频按钮 custom_video_selector quick_video_upload)(首行缩进
toolbar: [
'fullscreen preview code codesample emoticons auto_format custom_image_selector quick_upload',
'undo redo | forecolor backcolor',
'bold italic underline strikethrough',
'alignleft aligncenter alignright alignjustify',
'outdent indent | numlist bullist',
'formatselect fontselect fontsizeselect',
'link charmap anchor pagebreak | ltr rtl'
].join(' | '),
// 图片上传处理器 - 支持拖拽和粘贴上传
images_upload_handler: (blobInfo, success, error) => {
const file = blobInfo.blob();
// 检查文件大小(限制为10MB
if (file.size > 10 * 1024 * 1024) {
error('图片大小不能超过10MB');
return;
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
error('只能上传图片文件');
return;
}
// 显示上传进度提示
const loadingMsg = message.loading('图片上传中...', 0);
uploadOss(file)
.then((res) => {
loadingMsg();
success(res.url || res.path);
message.success('图片上传成功');
})
.catch((msg) => {
loadingMsg();
error(msg || '图片上传失败');
message.error('图片上传失败:' + msg);
});
},
// 图片工具栏
image_toolbar:
'alignleft aligncenter alignright | rotateleft rotateright | imageoptions',
// 图片标题
image_title: true,
// 图片描述
image_description: true,
// 图片尺寸
image_dimensions: true,
// 图片类别
image_class_list: [
{ title: '无样式', value: '' },
{ title: '响应式图片', value: 'img-responsive' },
{ title: '圆角图片', value: 'img-rounded' },
{ title: '圆形图片', value: 'img-circle' }
],
// 自定义按钮设置
setup: (editor: any) => {
// 添加自定义图片选择按钮
editor.ui.registry.addButton('custom_image_selector', {
text: '图片',
icon: 'image',
tooltip: '插入图片(从文件库选择或上传)',
onAction: () => {
// 打开文件库选择弹窗
fileSelectCallback.value = (url: string) => {
editor.insertContent(
`<img src="${url}" alt="图片" style="max-width: 100%;" />`
);
};
showFileSelector.value = true;
}
});
// 添加快速上传按钮
editor.ui.registry.addButton('quick_upload', {
text: '上传',
icon: 'upload',
tooltip: '快速上传图片',
onAction: () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e: any) => {
const file = e.target.files[0];
if (file) {
// 检查文件大小
if (file.size > 10 * 1024 * 1024) {
message.error('图片大小不能超过10MB');
return;
}
const loadingMsg = message.loading('图片上传中...', 0);
uploadOss(file)
.then((res) => {
loadingMsg();
const imageUrl = res.url || res.path;
editor.insertContent(
`<img src="${imageUrl}" alt="${file.name}" style="max-width: 100%;" />`
);
message.success('图片上传成功');
})
.catch((msg) => {
loadingMsg();
message.error('图片上传失败:' + msg);
});
}
};
input.click();
}
});
// 添加自定义视频选择按钮
editor.ui.registry.addButton('custom_video_selector', {
text: '视频',
icon: 'embed',
tooltip: '插入视频(从视频库选择)',
onAction: () => {
// 打开视频库选择弹窗
videoSelectCallback.value = (url: string) => {
// 插入视频标签,使用HTML5 video元素
editor.insertContent(`
<video controls style="max-width: 100%; height: auto;">
<source src="${url}" type="video/mp4">
您的浏览器不支持视频播放。
</video>
`);
};
showVideoSelector.value = true;
}
});
// 添加快速视频上传按钮
editor.ui.registry.addButton('quick_video_upload', {
text: '上传视频',
icon: 'upload',
tooltip: '快速上传视频',
onAction: () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';
input.onchange = (e: any) => {
const file = e.target.files[0];
if (file) {
// 检查文件大小(限制为100MB)
if (file.size > 100 * 1024 * 1024) {
message.error('视频大小不能超过100MB');
return;
}
const loadingMsg = message.loading('视频上传中...', 0);
uploadOss(file)
.then((res) => {
loadingMsg();
const videoUrl = res.path || res.downloadUrl;
editor.insertContent(`
<video controls style="max-width: 100%; height: auto;">
<source src="${videoUrl}" type="video/mp4">
您的浏览器不支持视频播放。
</video>
`);
message.success('视频上传成功');
})
.catch((msg) => {
loadingMsg();
message.error('视频上传失败:' + msg);
});
}
};
input.click();
}
});
// 添加一键排版按钮
editor.ui.registry.addButton('auto_format', {
text: '排版',
icon: 'template',
tooltip: '智能优化文章格式和排版',
onAction: () => {
// 直接在这里处理排版,因为此时编辑器肯定已经初始化完成
handleAutoFormat(editor);
}
});
// 添加段落首行缩进切换按钮
editor.ui.registry.addButton('toggle_indent', {
icon: 'indent',
tooltip: '切换段落首行缩进(适合中文排版)',
onAction: () => {
toggleParagraphIndent(editor);
}
});
}
});
// 从文件库选择图片的回调 // 从文件库选择图片的回调
const onFileSelected = (data: FileRecord) => { const onFileSelected = (data: FileRecord) => {
if (fileSelectCallback.value) { if (fileSelectCallback.value) {
@@ -1093,11 +611,31 @@
showVideoSelector.value = false; showVideoSelector.value = false;
}; };
// 从图片库选择图片:插入到 WangEditor 当前光标处
const openImageSelector = () => {
fileSelectCallback.value = (url: string) => {
editorRef.value?.insertHtml(
`<img src="${url}" alt="图片" style="max-width:100%;"/>`
);
};
showFileSelector.value = true;
};
// 从视频库选择视频:插入到 WangEditor 当前光标处
const openVideoSelector = () => {
videoSelectCallback.value = (url: string) => {
editorRef.value?.insertHtml(
`<video controls style="max-width:100%;height:auto;"><source src="${url}" type="video/mp4"></video>`
);
};
showVideoSelector.value = true;
};
// 🎨 智能一键排版 - 人性化设计 // 🎨 智能一键排版 - 人性化设计
const handleAutoFormat = (editor: any) => { const handleAutoFormat = () => {
try { try {
// 1. 检查内容 // 1. 检查内容
const content = editor.getContent(); const content = content.value;
if ( if (
!content || !content ||
content.trim() === '' || content.trim() === '' ||
@@ -1121,7 +659,7 @@
setTimeout(() => { setTimeout(() => {
try { try {
const optimizedContent = smartFormatContent(content); const optimizedContent = smartFormatContent(content);
editor.setContent(optimizedContent); content.value = optimizedContent;
loadingMsg(); loadingMsg();
@@ -1316,9 +854,9 @@
}; };
// 🔄 段落首行缩进切换功能 // 🔄 段落首行缩进切换功能
const toggleParagraphIndent = (editor: any) => { const toggleParagraphIndent = () => {
try { try {
const content = editor.getContent(); const content = content.value;
if ( if (
!content || !content ||
@@ -1351,7 +889,7 @@
actionText = '已添加段落首行缩进'; actionText = '已添加段落首行缩进';
} }
editor.setContent(newContent); content.value = newContent;
message.success({ message.success({
content: `📐 ${actionText}`, content: `📐 ${actionText}`,
@@ -1449,7 +987,7 @@
} }
const formData = { const formData = {
...form, ...form,
editor: editor.value || 1, editor: 1,
status: setting.setting?.articleReview ? 1 : 0, status: setting.setting?.articleReview ? 1 : 0,
content: content.value content: content.value
}; };
@@ -1480,12 +1018,6 @@
watch( watch(
() => props.visible, () => props.visible,
(visible) => { (visible) => {
if (localStorage.getItem('Editor')) {
editor.value = Number(localStorage.getItem('Editor'));
} else {
editor.value = 1;
}
if (visible) { if (visible) {
images.value = []; images.value = [];
category.value = []; category.value = [];
@@ -1499,7 +1031,7 @@
// 文章详情 // 文章详情
assignObject(form, data); assignObject(form, data);
if (data.content) { if (data.content) {
content.value = data.content; content.value = toEditorContent(data.content);
} }
if (!data.source) { if (!data.source) {
form.source = undefined; form.source = undefined;
@@ -1509,9 +1041,6 @@
} else { } else {
form.tags = undefined; form.tags = undefined;
} }
if (data.editor) {
editor.value = data.editor;
}
if (data.files) { if (data.files) {
const arr = JSON.parse(data.files); const arr = JSON.parse(data.files);
arr.map((url: string) => { arr.map((url: string) => {
@@ -1561,26 +1090,6 @@
border-radius: 6px; border-radius: 6px;
} }
// 编辑器内图片样式
:deep(.mce-content-body) {
img {
max-width: 100%;
height: auto;
&.img-responsive {
width: 100%;
height: auto;
}
&.img-rounded {
border-radius: 8px;
}
&.img-circle {
border-radius: 50%;
}
}
}
} }
// 文件选择提示 // 文件选择提示
@@ -20,13 +20,31 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' } styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
" "
> >
<a-form-item label="配送地区" name="regions"> <a-form-item label="配送模式" name="regionMode">
<RegionsSelect <a-radio-group v-model:value="form.regionMode" @change="onModeChange">
v-model:value="regions" <a-radio :value="0">全国</a-radio>
type="provinceCity" <a-radio :value="1">指定配送</a-radio>
:show-search="true" <a-radio :value="2">指定不发货</a-radio>
placeholder="请选择配送省份/城市(不选则默认全国)" </a-radio-group>
/> </a-form-item>
<a-form-item label="配送地区">
<template v-if="form.regionMode === 0">
<span class="rms-tip">全国发货无需选择地区</span>
</template>
<template v-else>
<RegionMultiSelect
:value="cityIds"
:placeholder="form.regionMode === 2 ? '选择不发货地区' : '选择配送地区'"
@update:value="onCityIdsChange"
/>
<div class="rms-tip">
{{
form.regionMode === 2
? '选中地区将不配送,用户下单会被拦截'
: '不选则默认全国(建议切换到"全国"模式)'
}}
</div>
</template>
</a-form-item> </a-form-item>
<a-form-item :label="templateType ? '首件数量' : '首重重量'" name="firstNum"> <a-form-item :label="templateType ? '首件数量' : '首重重量'" name="firstNum">
<a-input-number <a-input-number
@@ -94,7 +112,7 @@
import { import {
ShopExpressTemplateDetail ShopExpressTemplateDetail
} from '@/api/shop/shopExpressTemplateDetail/model'; } from '@/api/shop/shopExpressTemplateDetail/model';
import RegionsSelect from '@/components/RegionsSelect/index.vue'; import RegionMultiSelect from '@/components/RegionMultiSelect/index.vue';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form'; import { FormInstance } from 'ant-design-vue/es/form';
@@ -128,8 +146,8 @@
const maxable = ref(true); const maxable = ref(true);
// 表单实例 // 表单实例
const formRef = ref<FormInstance | null>(null); const formRef = ref<FormInstance | null>(null);
// 级联选择器选中值(字符串ID数组 // 已选城市编码数组(对应后端的 regionIds
const regions = ref<string[]>([]); const cityIds = ref<string[]>([]);
// 表单数据 // 表单数据
const form = reactive<ShopExpressTemplateDetail>({ const form = reactive<ShopExpressTemplateDetail>({
@@ -138,6 +156,8 @@
type: true, type: true,
provinceId: undefined, provinceId: undefined,
cityId: undefined, cityId: undefined,
regionMode: undefined,
regionIds: undefined,
firstNum: undefined, firstNum: undefined,
firstAmount: undefined, firstAmount: undefined,
extraAmount: undefined, extraAmount: undefined,
@@ -186,16 +206,20 @@
const { resetFields } = useForm(form, rules); const { resetFields } = useForm(form, rules);
/* 级联选择器值变化,同步到 form.provinceId / form.cityId */ /* 城市选中变化,同步到 form.regionIds(模式由 radio 决定:1=指定配送,2=指定不发货) */
watch(regions, (val) => { const onCityIdsChange = (val: string[]) => {
if (val && val.length > 0) { cityIds.value = val;
form.provinceId = Number(val[0]); form.regionIds = val.length ? val.join(',') : undefined;
form.cityId = val.length > 1 ? Number(val[1]) : undefined; };
} else {
form.provinceId = undefined; /* 切换配送模式 */
form.cityId = undefined; const onModeChange = () => {
// 切到「全国」时清空已选地区
if (form.regionMode === 0) {
cityIds.value = [];
form.regionIds = undefined;
} }
}); };
/* 保存编辑 */ /* 保存编辑 */
const save = () => { const save = () => {
@@ -205,11 +229,28 @@
formRef.value formRef.value
.validate() .validate()
.then(() => { .then(() => {
// 指定配送/不发货模式下必须选择地区
if (form.regionMode === 1 || form.regionMode === 2) {
if (!cityIds.value.length) {
message.error(
form.regionMode === 2 ? '请选择不发货地区' : '请选择配送地区'
);
return;
}
}
loading.value = true; loading.value = true;
const formData = { ...form }; const formData = { ...form };
// 确保templateId和type从父级继承 // 确保templateId和type从父级继承
formData.templateId = props.templateId; formData.templateId = props.templateId;
formData.type = props.templateType ?? true; formData.type = props.templateType ?? true;
// 配送模式:0=全国(无地区);1=指定配送;2=指定不发货;后两者 regionIds 存城市编码逗号串
if (form.regionMode === 0) {
formData.regionIds = undefined;
formData.regionMode = 0;
} else {
formData.regionIds = cityIds.value.join(',');
formData.regionMode = form.regionMode;
}
const saveOrUpdate = isUpdate.value const saveOrUpdate = isUpdate.value
? updateShopExpressTemplateDetail ? updateShopExpressTemplateDetail
: addShopExpressTemplateDetail; : addShopExpressTemplateDetail;
@@ -234,27 +275,43 @@
if (visible) { if (visible) {
if (props.data) { if (props.data) {
assignObject(form, props.data); assignObject(form, props.data);
if (form.regionMode == null) {
form.regionMode = 0;
}
isUpdate.value = true; isUpdate.value = true;
} else { } else {
isUpdate.value = false; isUpdate.value = false;
form.templateId = props.templateId; form.templateId = props.templateId;
form.type = props.templateType ?? true; form.type = props.templateType ?? true;
form.regionMode = 0;
} }
// 回显级联选择器 // 回显多选配送区域(仅 1=指定配送 / 2=指定不发货 带地区,regionIds 存城市编码)
if (form.provinceId) { if (form.regionMode === 1 || form.regionMode === 2) {
const arr = [String(form.provinceId)]; if (form.regionIds) {
if (form.cityId) { cityIds.value = form.regionIds.split(',').filter(Boolean);
arr.push(String(form.cityId)); } else if (form.cityId) {
// 兼容旧单值数据
cityIds.value = [String(form.cityId)];
} else {
cityIds.value = [];
} }
regions.value = arr;
} else { } else {
regions.value = []; cityIds.value = [];
form.regionIds = undefined;
} }
} else { } else {
resetFields(); resetFields();
regions.value = []; cityIds.value = [];
} }
}, },
{ immediate: true } { immediate: true }
); );
</script> </script>
<style scoped>
.rms-tip {
margin-top: 4px;
color: var(--ant-color-text-secondary, #999);
font-size: 12px;
}
</style>
@@ -51,7 +51,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { createVNode, ref, computed, onMounted } from 'vue'; import { createVNode, ref, computed, onMounted, h } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { message, Modal } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'; import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
@@ -92,6 +92,8 @@
// 地区ID→名称映射 // 地区ID→名称映射
const regionMap = ref<Record<string, string>>({}); const regionMap = ref<Record<string, string>>({});
// 城市ID→省份ID映射(用于把城市码聚合显示为省名)
const cityToProvince = ref<Record<string, string>>({});
// 表格选中数据 // 表格选中数据
const selection = ref<ShopExpressTemplateDetail[]>([]); const selection = ref<ShopExpressTemplateDetail[]>([]);
@@ -128,6 +130,25 @@
align: 'center', align: 'center',
width: 150, width: 150,
customRender: ({ record }) => { customRender: ({ record }) => {
const mode = record.regionMode;
const ids = (record.regionIds || '')
.split(',')
.filter(Boolean);
if (mode === 2) {
// 指定不发货(黑名单)
if (!ids.length) {
return h('span', { style: { color: '#bbb' } }, '不发货(未配置地区)');
}
const text = '不发货:' + aggregateProvinces(ids).join('、');
return renderRegionText(text, 26);
}
if (mode === 1) {
// 指定配送(白名单)
if (!ids.length) return '默认全国';
const text = aggregateProvinces(ids).join('、');
return renderRegionText(text, 22);
}
// 兼容旧数据 / 全国
const province = record.provinceId const province = record.provinceId
? regionMap.value[String(record.provinceId)] ? regionMap.value[String(record.provinceId)]
: ''; : '';
@@ -266,19 +287,44 @@
}; };
}; };
/* 把城市编码数组聚合为去重的省份名称数组(兼容直接存省份码的场景) */
const aggregateProvinces = (ids: string[]) => {
const set = new Set<string>();
ids.forEach((id) => {
const prov = cityToProvince.value[id];
if (prov) {
set.add(regionMap.value[prov] || prov);
} else {
set.add(regionMap.value[id] || id);
}
});
return [...set];
};
/* 过长截断并支持 tooltip 悬浮查看全部 */
const renderRegionText = (text: string, max = 22) => {
if (text.length > max) {
return h('span', { title: text }, text.slice(0, max) + '…');
}
return text;
};
/* 加载地区数据,建立ID→名称映射 */ /* 加载地区数据,建立ID→名称映射 */
onMounted(() => { onMounted(() => {
getRegionsData().then((data) => { getRegionsData().then((data) => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
const c2p: Record<string, string> = {};
data.forEach((province) => { data.forEach((province) => {
map[province.value] = province.label; map[province.value] = province.label;
if (province.children) { if (province.children) {
province.children.forEach((city) => { province.children.forEach((city) => {
map[city.value] = city.label; map[city.value] = city.label;
c2p[city.value] = province.value;
}); });
} }
}); });
regionMap.value = map; regionMap.value = map;
cityToProvince.value = c2p;
}); });
}); });
</script> </script>
+27 -21
View File
@@ -52,15 +52,17 @@
<a-tree-select <a-tree-select
allow-clear allow-clear
multiple
:max-tag-count="2"
:tree-data="navigationList" :tree-data="navigationList"
tree-default-expand-all tree-default-expand-all
style="width: 180px" style="width: 200px"
:listHeight="700" :listHeight="700"
placeholder="请选择分类" placeholder="请选择分类(可多选)"
:value="where.categoryId || undefined" :value="where.categoryIds && where.categoryIds.length ? where.categoryIds : undefined"
:dropdown-style="{ overflow: 'auto' }" :dropdown-style="{ overflow: 'auto' }"
@update:value="(value?: number) => (where.categoryId = value)" @update:value="(value?: number[]) => (where.categoryIds = value || [])"
@change="onCategoryId" @change="onCategoryIds"
/> />
<a-input-search <a-input-search
allow-clear allow-clear
@@ -119,7 +121,7 @@
ArrowDownOutlined, ArrowDownOutlined,
DownOutlined DownOutlined
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { ref, watch } from 'vue'; import { ref, watch, onMounted } from 'vue';
import { getCount } from '@/api/shop/shopGoods'; import { getCount } from '@/api/shop/shopGoods';
import type { GoodsCount, ShopGoodsParam } from '@/api/shop/shopGoods/model'; import type { GoodsCount, ShopGoodsParam } from '@/api/shop/shopGoods/model';
import useSearch from '@/utils/use-search'; import useSearch from '@/utils/use-search';
@@ -149,6 +151,7 @@
status: undefined, status: undefined,
stock: undefined, stock: undefined,
categoryId: undefined, categoryId: undefined,
categoryIds: [],
merchantId: undefined, merchantId: undefined,
keywords: '' keywords: ''
}); });
@@ -164,7 +167,7 @@
(e: 'imageImport'): void; (e: 'imageImport'): void;
(e: 'detailImport'): void; (e: 'detailImport'): void;
(e: 'convertContent'): void; (e: 'convertContent'): void;
(e: 'export'): void; (e: 'export', where?: ShopGoodsParam): void;
}>(); }>();
// 新增 // 新增
@@ -204,9 +207,9 @@
emit('convertContent'); emit('convertContent');
}; };
// 导出 // 导出(携带当前筛选条件 where
const handleExport = () => { const handleExport = () => {
emit('export'); emit('export', where);
}; };
const handleSearch = (e) => { const handleSearch = (e) => {
@@ -238,24 +241,27 @@
reload(); reload();
}; };
// 按分类查询 // 按分类查询(方案B:支持多选,主分类取首个)
const onCategoryId = (id: number) => { const onCategoryIds = (ids: number[]) => {
where.categoryId = id; where.categoryIds = ids || [];
where.categoryId = ids && ids.length ? ids[0] : undefined;
emit('search', where); emit('search', where);
}; };
// 首次进入:设置商户条件并触发统计 + 列表加载。
// 用 onMounted 替代 watch 的 immediate,避免与 ele-pro-table 的自动加载(initLoad)重复发请求。
onMounted(() => {
const id = props.merchantId;
where.merchantId = Number(id) > 0 ? id : undefined;
reload();
});
watch( watch(
() => props.merchantId, () => props.merchantId,
(id) => { (id) => {
if (Number(id) > 0) { where.merchantId = Number(id) > 0 ? id : undefined;
where.merchantId = id; reload();
reload(); }
} else {
where.merchantId = undefined;
reload();
}
},
{ immediate: true }
); );
</script> </script>
@@ -32,18 +32,31 @@
v-model:value="form.name" v-model:value="form.name"
/> />
</a-form-item> </a-form-item>
<a-form-item label="商品分类" name="categoryId"> <a-form-item label="商品分类" name="categoryIds">
<a-tree-select <a-tree-select
allow-clear allow-clear
multiple
:max-tag-count="3"
:tree-data="navigationList" :tree-data="navigationList"
tree-default-expand-all tree-default-expand-all
style="width: 320px" style="width: 320px"
placeholder="请选择分类" placeholder="请选择分类(可多选)"
:value="form.categoryId || undefined" :value="form.categoryIds && form.categoryIds.length ? form.categoryIds : undefined"
:listHeight="700" :listHeight="700"
:dropdown-style="{ overflow: 'auto' }" :dropdown-style="{ overflow: 'auto' }"
@update:value="(value?: number) => (form.categoryId = value)" @update:value="(value?: number[]) => (form.categoryIds = value || [])"
@change="onCategoryId" @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>
<a-form-item label="商品编码" name="code"> <a-form-item label="商品编码" name="code">
@@ -72,10 +85,10 @@
v-model:value="form.unitName" v-model:value="form.unitName"
/> />
</a-form-item> </a-form-item>
<a-form-item label="商品价格" name="price"> <a-form-item label="商城价" name="price">
<a-space> <a-space>
<a-input-number <a-input-number
:placeholder="`商品价格`" :placeholder="`商城价`"
style="width: 240px" style="width: 240px"
:min="0.01" :min="0.01"
v-model:value="form.price" v-model:value="form.price"
@@ -106,10 +119,11 @@
v-model:value="form.buyingPrice" v-model:value="form.buyingPrice"
/> />
</a-form-item> </a-form-item>
<a-form-item label="运费模板" v-if="!merchantId"> <a-form-item :label="form.canExpress === 1 ? '运费模板' : '运费模板'" v-if="!merchantId" :required="form.canExpress === 1">
<a-select <a-select
v-model:value="form.expressTemplateId" v-model:value="form.expressTemplateId"
style="width: 240px" style="width: 240px"
:placeholder="form.canExpress === 1 ? '请选择运费模板' : '请选择运费模板'"
> >
<a-select-option <a-select-option
v-for="(item, index) in expressTemplateList" v-for="(item, index) in expressTemplateList"
@@ -211,18 +225,12 @@
</a-form-item> </a-form-item>
</a-tab-pane> </a-tab-pane>
<a-tab-pane tab="商品详情" key="content"> <a-tab-pane tab="商品详情" key="content">
<!-- Markdown 编辑器 --> <RichTextEditor
<MdEditor v-if="active === 'content'"
v-model="content" v-model="content"
:disabled="disabled" :height="520"
height="650px" placeholder="请输入商品详情,支持图文混排..."
placeholder="支持 Markdown 语法,可直接粘贴或拖拽图片..."
:toolbars="markdownToolbars"
:onUploadImg="onMarkdownUploadImg"
/> />
<div class="file-selector-tip">
💡 提示支持 Markdown 语法可直接拖拽上传图片也可以使用工具栏按钮从文件库选择图片
</div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane tab="商品规格" key="spec"> <a-tab-pane tab="商品规格" key="spec">
<a-form-item label="规格类型" name="specs"> <a-form-item label="规格类型" name="specs">
@@ -308,7 +316,7 @@
:precision="2" :precision="2"
:min="0" :min="0"
style="width: 110px" style="width: 110px"
placeholder="商品价格" placeholder="商城价"
/> />
<a-input-number <a-input-number
v-model:value="batchSalePrice" v-model:value="batchSalePrice"
@@ -355,7 +363,7 @@
:data="record.images || []" :data="record.images || []"
:index="index" :index="index"
@done="chooseSkuImage" @done="chooseSkuImage"
@del="onDeleteSkuItem" @del="() => onDeleteSkuItem(index)"
/> />
</template> </template>
<template v-if="column.key === 'price'"> <template v-if="column.key === 'price'">
@@ -473,7 +481,7 @@
</a-form-item> </a-form-item>
<a-form-item label="配送方式" name="deliveryMode"> <a-form-item label="配送方式" name="deliveryMode">
<a-radio-group v-model:value="form.deliveryMode"> <a-radio-group v-model:value="form.deliveryMode">
<a-radio :value="0">上门</a-radio> <a-radio :value="0">快递配</a-radio>
<a-radio :value="1">限自提</a-radio> <a-radio :value="1">限自提</a-radio>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
@@ -673,23 +681,6 @@
</a-tabs> </a-tabs>
</a-form> </a-form>
</a-drawer> </a-drawer>
<!-- 文件库选择弹窗 -->
<SelectData
v-model:visible="showFileSelector"
title="选择图片"
type="image"
class="file-selector-modal"
@done="onFileSelected"
/>
<!-- 视频库选择弹窗 -->
<SelectData
v-model:visible="showVideoSelector"
title="选择视频"
type="video"
class="file-selector-modal"
@done="onVideoSelected"
/>
</template> </template>
@@ -702,7 +693,7 @@ import {
SettingOutlined, SettingOutlined,
UploadOutlined UploadOutlined
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import {ref, reactive, watch, nextTick} from 'vue'; import {ref, reactive, watch} from 'vue';
import {useRouter} from 'vue-router'; import {useRouter} from 'vue-router';
import {Form, message} from 'ant-design-vue'; import {Form, message} from 'ant-design-vue';
import {assignObject, messageLoading, uuid} from 'ele-admin-pro'; import {assignObject, messageLoading, uuid} from 'ele-admin-pro';
@@ -712,12 +703,11 @@ import {useThemeStore} from '@/store/modules/theme';
import {storeToRefs} from 'pinia'; import {storeToRefs} from 'pinia';
import {ItemType} from 'ele-admin-pro/es/ele-image-upload/types'; import {ItemType} from 'ele-admin-pro/es/ele-image-upload/types';
import {FormInstance, RuleObject} from 'ant-design-vue/es/form'; import {FormInstance, RuleObject} from 'ant-design-vue/es/form';
import {FileRecord} from '@/api/system/file/model';
import {ShopMerchant} from '@/api/shop/shopMerchant/model'; import {ShopMerchant} from '@/api/shop/shopMerchant/model';
import {uploadFile, uploadOss} from '@/api/system/file'; import {uploadFile, uploadOss} from '@/api/system/file';
import { MdEditor } from 'md-editor-v3'; import {FileRecord} from '@/api/system/file/model';
import 'md-editor-v3/lib/style.css'; import RichTextEditor from '@/components/RichTextEditor.vue';
import 'md-editor-v3/lib/preview.css'; import markdownit from 'markdown-it';
import {ShopSpec} from '@/api/shop/shopSpec/model'; import {ShopSpec} from '@/api/shop/shopSpec/model';
import {ShopGoodsSku} from '@/api/shop/shopGoodsSku/model'; import {ShopGoodsSku} from '@/api/shop/shopGoodsSku/model';
import {ShopGoodsSpec} from '@/api/shop/shopGoodsSpec/model'; import {ShopGoodsSpec} from '@/api/shop/shopGoodsSpec/model';
@@ -730,7 +720,7 @@ import {ShopExpressTemplate} from '@/api/shop/shopExpressTemplate/model';
import {listShopExpressTemplate} from '@/api/shop/shopExpressTemplate'; import {listShopExpressTemplate} from '@/api/shop/shopExpressTemplate';
import {listShopCommissionRole} from '@/api/shop/shopCommissionRole'; import {listShopCommissionRole} from '@/api/shop/shopCommissionRole';
import {listShopGoodsRoleCommission} from '@/api/shop/shopGoodsRoleCommission'; import {listShopGoodsRoleCommission} from '@/api/shop/shopGoodsRoleCommission';
import SelectData from "@/components/SelectFile/components/select-data.vue"; import {listHomeSections} from '@/api/shop/shopZone';
// 是否是修改 // 是否是修改
const isUpdate = ref(false); const isUpdate = ref(false);
@@ -761,7 +751,21 @@ const maxable = ref(true);
const formRef = ref<FormInstance | null>(null); const formRef = ref<FormInstance | null>(null);
const images = ref<ItemType[]>([]); const images = ref<ItemType[]>([]);
const content = ref(''); const content = ref('');
const disabled = ref(false);
// Markdown -> HTML 转换(编辑回显旧数据时,把 Markdown 内容转成富文本可识别的 HTML)
const md = markdownit();
/**
* 把后端 content 转为富文本编辑器可用的 HTML:
* - 已是 HTML(含标签)则原样返回,避免二次转义;
* - 否则按 Markdown 渲染为 HTML。
*/
const toEditorContent = (raw?: string): string => {
if (!raw) return '';
const s = raw.trim();
if (/<[a-z][\s\S]*>/i.test(s)) return raw;
return md.render(s);
};
// 当前选项卡 // 当前选项卡
const active = ref('base'); const active = ref('base');
@@ -867,6 +871,9 @@ const skuList = ref<ShopGoodsSku[]>([]);
const files = ref<ItemType[]>([]); const files = ref<ItemType[]>([]);
const goodsSpec = ref<ShopGoodsSpec>(); const goodsSpec = ref<ShopGoodsSpec>();
const category = ref<string[]>([]); const category = ref<string[]>([]);
// 所属专区(多选)
const sectionIdList = ref<number[]>([]);
const zoneOptions = ref<{ label: string; value: number }[]>([]);
// 批量设置 // 批量设置
const batchPrice = ref<number | undefined>(undefined); const batchPrice = ref<number | undefined>(undefined);
@@ -895,17 +902,29 @@ const columns = [
align: 'center' align: 'center'
}, },
{ {
title: '商品价格', title: '商城价',
dataIndex: 'price', dataIndex: 'price',
key: 'price', key: 'price',
align: 'center' align: 'center'
}, },
{
title: '划线价',
dataIndex: 'salePrice',
key: 'salePrice',
align: 'center'
},
{ {
title: '会员价', title: '会员价',
dataIndex: 'buyingPrice', dataIndex: 'buyingPrice',
key: 'buyingPrice', key: 'buyingPrice',
align: 'center' align: 'center'
}, },
{
title: '库存',
dataIndex: 'stock',
key: 'stock',
align: 'center'
},
{ {
title: '商品编号', title: '商品编号',
dataIndex: 'skuNo', dataIndex: 'skuNo',
@@ -926,12 +945,13 @@ const addEnsureTag = (item = '') => {
ensureTagItem.value = ''; ensureTagItem.value = '';
}; };
// 选择栏目 // 选择栏目(方案B:支持多选,主分类取列表首个,向后兼容旧字段)
const onCategoryId = (id: number) => { const onCategoryIds = (ids: number[]) => {
form.categoryId = id; form.categoryIds = ids || [];
form.categoryId = ids && ids.length ? ids[0] : undefined;
// 💾 在新增模式下,用户手动选择栏目时也保存到本地存储 // 💾 在新增模式下,用户手动选择栏目时也保存到本地存储
if (!isUpdate.value && id) { if (!isUpdate.value && ids && ids.length) {
saveLastCategory(id); saveLastCategory(ids[0]);
} }
}; };
@@ -956,34 +976,6 @@ const onDeleteEnsureTag = (tag: string) => {
ensureTag.value.splice(index, 1); ensureTag.value.splice(index, 1);
}; };
// 从文件库选择图片的回调
const onFileSelected = (data: FileRecord) => {
if (fileSelectCallback.value) {
// 使用文件的完整URL,确保有值
const imageUrl = data.url || data.path || '';
if (imageUrl) {
fileSelectCallback.value(imageUrl);
message.success('图片插入成功');
}
fileSelectCallback.value = null;
}
showFileSelector.value = false;
};
// 从视频库选择视频的回调
const onVideoSelected = (data: FileRecord) => {
if (videoSelectCallback.value) {
// 使用文件的完整URL,确保有值
const videoUrl = data.path || data.downloadUrl || '';
if (videoUrl) {
videoSelectCallback.value(videoUrl);
message.success('视频插入成功');
}
videoSelectCallback.value = null;
}
showVideoSelector.value = false;
};
// 🎨 智能一键排版 - 人性化设计 // 🎨 智能一键排版 - 人性化设计
const handleAutoFormat = (editor: any) => { const handleAutoFormat = (editor: any) => {
try { try {
@@ -1230,6 +1222,7 @@ const form = reactive<ShopGoods>({
canExpress: 1, canExpress: 1,
unitName: '', unitName: '',
categoryId: undefined, categoryId: undefined,
categoryIds: [],
parentName: undefined, parentName: undefined,
categoryName: undefined, categoryName: undefined,
specs: 0, specs: 0,
@@ -1337,7 +1330,7 @@ const rules = reactive({
price: [ price: [
{ {
required: true, required: true,
message: '请填写商品价格', message: '请填写商城价',
type: 'number', type: 'number',
trigger: 'blur' trigger: 'blur'
} }
@@ -1475,14 +1468,18 @@ const chooseSupplier = (item: ShopMerchant) => {
}; };
const chooseGoodsCategory = (item: ShopGoodsCategory, value: any) => { 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.parentName = value[0].label;
form.categoryName = value[1].label; form.categoryName = value[1].label;
}; };
const chooseTakeawayCategory = (item: ShopGoodsCategory, value: any) => { const chooseTakeawayCategory = (item: ShopGoodsCategory, value: any) => {
const cid = item[0] as number;
form.categoryParent = '店铺分类'; form.categoryParent = '店铺分类';
form.categoryChildren = value[0].label; form.categoryChildren = value[0].label;
form.categoryId = item[0]; form.categoryId = cid;
form.categoryIds = [cid];
}; };
const chooseImage = (data: FileRecord) => { const chooseImage = (data: FileRecord) => {
@@ -1505,7 +1502,8 @@ const chooseSkuImage = (data: FileRecord) => {
}; };
const onDeleteSkuItem = (index: number) => { const onDeleteSkuItem = (index: number) => {
images.value.splice(index, 1); skuList.value[index].images = [];
skuList.value[index].image = '';
}; };
const videos = ref<ItemType[]>([]); const videos = ref<ItemType[]>([]);
@@ -1726,88 +1724,6 @@ const batchApply = () => {
}; };
const editorRef = ref(null);
// 文件库选择弹窗状态
const showFileSelector = ref(false);
const fileSelectCallback = ref<((url: string) => void) | null>(null);
// 视频库选择弹窗状态
const showVideoSelector = ref(false);
const videoSelectCallback = ref<((url: string) => void) | null>(null);
// 编辑器可见性控制
// 删除了 TinyMCE 相关代码,改用 MdEditor
const editorVisible = ref(false);
// 当 Tab 切换到商品详情时,启用编辑器
watch(
() => active.value,
(key) => {
if (key === 'content') {
// 切换到商品详情 Tab 时,等待 DOM 渲染完成后启用编辑器
nextTick(() => {
setTimeout(() => {
editorVisible.value = true;
console.log('[shopGoodsEdit] Switched to content tab, editorVisible = true');
}, 100);
});
}
},
{ immediate: true }
);
const markdownToolbars = [
'bold',
'underline',
'italic',
'-',
'title',
'strikeThrough',
'quote',
'unorderedList',
'orderedList',
'task',
'-',
'codeRow',
'code',
'link',
'image',
'table',
'-',
'revoke',
'next',
'=',
'pageFullscreen',
'fullscreen',
'preview',
'previewOnly'
];
// Markdown 图片上传
const onMarkdownUploadImg = async (
files: File[],
callback: (urls: string[]) => void
) => {
try {
const uploadPromises = files.map(async (file) => {
if (file.size > 10 * 1024 * 1024) {
message.error(`图片 ${file.name} 大小超过10MB`);
return null;
}
const res = await uploadOss(file);
return res.url || res.path;
});
const results = await Promise.all(uploadPromises);
callback(results.filter(Boolean) as string[]);
} catch (err: any) {
message.error(err.message || '图片上传失败');
}
};
// TinyMCE config 已移除,改用 MdEditor
const config = ref({});
const {resetFields} = useForm(form, rules); const {resetFields} = useForm(form, rules);
const COMMISSION_PERCENT_FIELDS = [ const COMMISSION_PERCENT_FIELDS = [
@@ -1846,6 +1762,11 @@ const save = () => {
.validate() .validate()
.then(() => { .then(() => {
loading.value = true; loading.value = true;
// 快递配送时运费模板必选(后端也有此校验,前端提前拦截给出友好提示)
if (form.canExpress === 1 && !form.expressTemplateId && !merchantId) {
loading.value = false;
return message.error('请选择运费模板');
}
if (form.priceGift && !form.priceGiftNum) if (form.priceGift && !form.priceGiftNum)
return message.error('请输入会员赠品数量'); return message.error('请输入会员赠品数量');
if (ensureTag.value.length) form.ensureTag = ensureTag.value.join(); if (ensureTag.value.length) form.ensureTag = ensureTag.value.join();
@@ -1890,8 +1811,17 @@ const save = () => {
if (form.specs === 1 && goodsSpec.value && spec.value.length) { if (form.specs === 1 && goodsSpec.value && spec.value.length) {
goodsSpec.value.specValue = JSON.stringify(spec.value); goodsSpec.value.specValue = JSON.stringify(spec.value);
} }
// 方案B:主分类兜底取多分类第一个,保证旧字段/旧逻辑兼容
if (form.categoryIds && form.categoryIds.length && !form.categoryId) {
form.categoryId = form.categoryIds[0];
}
const formData: any = { const formData: any = {
...form, ...form,
// 所属专区:数字数组 -> 逗号分隔字符串
sectionIds: sectionIdList.value.length
? sectionIdList.value.join(',')
: '',
content: content.value, content: content.value,
category: JSON.stringify(category.value), category: JSON.stringify(category.value),
files: JSON.stringify(files.value), files: JSON.stringify(files.value),
@@ -2055,8 +1985,20 @@ watch(
async (visible) => { async (visible) => {
if (visible) { if (visible) {
await getExpressTemplateList(); await getExpressTemplateList();
// 加载所属专区选项
listHomeSections({})
.then((list) => {
zoneOptions.value = (list || []).map((z) => ({
label: z.title || '',
value: z.sectionId as number
}));
})
.catch(() => {
zoneOptions.value = [];
});
images.value = []; images.value = [];
sectionIdList.value = [];
category.value = []; category.value = [];
files.value = []; files.value = [];
videos.value = []; videos.value = [];
@@ -2065,6 +2007,13 @@ watch(
ensureTagItem.value = ''; ensureTagItem.value = '';
if (props.data) { if (props.data) {
assignObject(form, 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) { if (form.commissionType === undefined || form.commissionType === null) {
form.commissionType = 10; form.commissionType = 10;
} }
@@ -2145,22 +2094,28 @@ watch(
category.value = JSON.parse(props.data.category); category.value = JSON.parse(props.data.category);
} }
if (props.data.content) { if (props.data.content) {
content.value = props.data.content; content.value = toEditorContent(props.data.content);
} }
isUpdate.value = true; isUpdate.value = true;
if (form.ensureTag) { if (form.ensureTag) {
ensureTag.value = form.ensureTag.split(','); ensureTag.value = form.ensureTag.split(',');
} }
// 🎯 优先级设置栏目: // 🎯 优先级设置栏目(方案B:支持多分类回填)
// 1. 如果传入了 categoryId(从栏目页面点击添加),使用传入的 // 1. 如果传入了 categoryIds,使用多分类
// 2. 否则使用上次保存的栏目 // 2. 否则如果传入了 categoryId,兼容单分类
if (props.data.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.categoryId = props.data?.categoryId;
form.categoryIds = [props.data.categoryId];
} else { } else {
const lastCategory = getLastCategory(); const lastCategory = getLastCategory();
if (lastCategory) { if (lastCategory) {
form.categoryId = lastCategory; form.categoryId = lastCategory;
form.categoryIds = [lastCategory];
} }
} }
await getRoleList(); await getRoleList();
@@ -2181,17 +2136,4 @@ watch(
</script> </script>
<style scoped> <style scoped>
/* 修复 MdEditor 工具栏图标过大 */
:deep(.md-editor-toolbar-wrapper .md-editor-icon) {
font-size: 16px !important;
width: 16px !important;
height: 16px !important;
}
:deep(.md-editor-toolbar-wrapper svg) {
width: 16px !important;
height: 16px !important;
}
:deep(.md-editor-toolbar-item) {
font-size: 16px !important;
}
</style> </style>
+84 -28
View File
@@ -10,6 +10,7 @@
:columns="columns" :columns="columns"
:datasource="datasource" :datasource="datasource"
:customRow="customRow" :customRow="customRow"
:init-load="false"
:scroll="{ x: 1800 }" :scroll="{ x: 1800 }"
size="small" size="small"
v-model:selection="selection" v-model:selection="selection"
@@ -36,12 +37,14 @@
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'"> <template v-if="column.key === 'name'">
<a-space class="flex items-center cursor-pointer"> <a-space class="flex items-center cursor-pointer">
<a-image <img
:src="getCompressedImageUrl(record.image)"
v-if="record.image" v-if="record.image"
:preview="false" :src="getCompressedImageUrl(record.image)"
:width="50" class="goods-thumb"
:height="50" loading="lazy"
alt=""
width="50"
height="50"
/> />
<div class="flex flex-col justify-center leading-tight"> <div class="flex flex-col justify-center leading-tight">
<span class="text-gray-700 font-bold">{{ record.name }}</span> <span class="text-gray-700 font-bold">{{ record.name }}</span>
@@ -55,16 +58,36 @@
</template> </template>
<template v-if="column.key === 'priceGroup'"> <template v-if="column.key === 'priceGroup'">
<div class="flex flex-col leading-tight"> <div class="flex flex-col leading-tight">
<span class="text-gray-800 font-bold">{{ record.price }}</span> <span class="text-gray-800 font-bold"
<span class="text-gray-400 text-xs" >{{ record.price
>市场价{{ }}<span
record.salePrice ? `${record.salePrice}` : '-' v-if="record.priceMax != null && record.priceMax !== record.price"
}}</span > ~ {{ record.priceMax }}</span
></span
> >
<span class="text-gray-400 text-xs" <span class="text-gray-400 text-xs"
>会员{{ >市场<template v-if="record.salePrice"
record.dealerPrice ? `${record.dealerPrice}` : '-' >{{ record.salePrice
}}</span }}<span
v-if="
record.salePriceMax != null &&
record.salePriceMax !== record.salePrice
"
> ~ ¥{{ record.salePriceMax }}</span
></template
><template v-else>-</template></span
>
<span class="text-gray-400 text-xs"
>会员价:<template v-if="record.dealerPrice"
>¥{{ record.dealerPrice
}}<span
v-if="
record.dealerPriceMax != null &&
record.dealerPriceMax !== record.dealerPrice
"
> ~ ¥{{ record.dealerPriceMax }}</span
></template
><template v-else>-</template></span
> >
</div> </div>
</template> </template>
@@ -154,7 +177,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { createVNode, ref } from 'vue'; import { createVNode, ref, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import { import {
ExclamationCircleOutlined, ExclamationCircleOutlined,
@@ -188,6 +211,7 @@
import { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model'; import { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model';
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory'; import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory';
import { utils, writeFile } from 'xlsx'; import { utils, writeFile } from 'xlsx';
import { ensureFullUrl } from '@/utils/image';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
// 表格实例 // 表格实例
@@ -210,7 +234,7 @@
// 是否显示批量移动弹窗 // 是否显示批量移动弹窗
const showMove = ref(false); const showMove = ref(false);
// 加载状态 // 加载状态
const loading = ref(true); const loading = ref(false);
// 表格数据源 // 表格数据源
const datasource: DatasourceFunction = ({ const datasource: DatasourceFunction = ({
@@ -246,6 +270,15 @@
key: 'name', key: 'name',
width: 300 width: 300
}, },
{
title: '分类',
dataIndex: 'categoryName',
key: 'categoryName',
align: 'center',
width: 160,
ellipsis: true,
customRender: ({ text }) => text || '--'
},
{ {
title: '价格', title: '价格',
key: 'priceGroup', key: 'priceGroup',
@@ -386,8 +419,19 @@
}); });
}; };
/* 导出商品列表 */ /* 价格区间文本:多规格且区间不相等时显示 ¥min ~ ¥max,否则显示单值 */
const handleExport = async () => { const fmtPrice = (min?: string | number, max?: string | number) => {
if (min == null || min === '') {
return max != null && max !== '' ? `¥${max}` : '';
}
const base = `¥${min}`;
return max != null && max !== '' && `${max}` !== `${min}`
? `${base} ~ ¥${max}`
: base;
};
/* 导出商品列表(可携带当前筛选条件) */
const handleExport = async (where?: ShopGoodsParam) => {
if (loading.value) { if (loading.value) {
return; return;
} }
@@ -405,11 +449,12 @@
'库存', '库存',
'状态', '状态',
'推荐', '推荐',
'创建时间' '创建时间',
'封面图'
] ]
]; ];
const list = await listShopGoods({}); const list = await listShopGoods(where || {});
if (!list || list.length === 0) { if (!list || list.length === 0) {
message.warning('没有数据可以导出'); message.warning('没有数据可以导出');
loading.value = false; loading.value = false;
@@ -421,14 +466,15 @@
array.push([ array.push([
`${goods.goodsId || ''}`, `${goods.goodsId || ''}`,
`${goods.name || ''}`, `${goods.name || ''}`,
`${goods.price || 0}`, fmtPrice(goods.price, goods.priceMax),
`${goods.salePrice ? `${goods.salePrice}` : ''}`, fmtPrice(goods.salePrice, goods.salePriceMax),
`${goods.dealerPrice ? `${goods.dealerPrice}` : ''}`, fmtPrice(goods.dealerPrice, goods.dealerPriceMax),
`${goods.sales || 0}`, `${goods.sales || 0}`,
`${goods.stock || 0}`, `${goods.stock || 0}`,
statusMap[goods.status || 0] || '', statusMap[goods.status || 0] || '',
goods.recommend === 1 ? '是' : '否', goods.recommend === 1 ? '是' : '否',
`${goods.createTime || ''}` `${goods.createTime || ''}`,
ensureFullUrl(`${goods.image || ''}`)
]); ]);
}); });
@@ -450,7 +496,8 @@
{ wch: 10 }, { wch: 10 },
{ wch: 12 }, { wch: 12 },
{ wch: 8 }, { wch: 8 },
{ wch: 20 } { wch: 20 },
{ wch: 50 }
]; ];
message.destroy(); message.destroy();
@@ -511,9 +558,8 @@
}); });
}; };
/* 查询 */ /* 查询:分类数据延迟到列表首屏渲染完成后再加载,不阻塞首屏 */
const query = () => { const query = () => {
loading.value = true;
// 加载分类数据 // 加载分类数据
if (!navigationList.value) { if (!navigationList.value) {
listShopGoodsCategory({}).then((res) => { listShopGoodsCategory({}).then((res) => {
@@ -623,7 +669,7 @@
} }
}; };
}; };
query(); onMounted(query);
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -632,4 +678,14 @@
}; };
</script> </script>
<style lang="less" scoped></style> <style lang="less" scoped>
.goods-thumb {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.06);
flex-shrink: 0;
display: block;
}
</style>
+1 -1
View File
@@ -119,7 +119,7 @@
ellipsis: true ellipsis: true
}, },
{ {
title: '商品价格', title: '商城价',
dataIndex: 'price', dataIndex: 'price',
key: 'price', key: 'price',
align: 'center', align: 'center',
@@ -90,6 +90,14 @@
style="width: 100%" style="width: 100%"
/> />
</a-form-item> </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-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status"> <a-radio-group v-model:value="form.status">
<a-radio :value="0">正常</a-radio> <a-radio :value="0">正常</a-radio>
@@ -286,6 +294,7 @@
image: undefined, image: undefined,
adImage: undefined, adImage: undefined,
parentId: undefined, parentId: undefined,
path: undefined,
sortNumber: 100, sortNumber: 100,
comments: undefined, comments: undefined,
status: 0 status: 0
+2 -2
View File
@@ -38,7 +38,7 @@
<img <img
v-if="record.adImage" v-if="record.adImage"
:src="getCompressedImageUrl(record.adImage, { width: 144, quality: 85 })" :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> <span v-else>-</span>
</template> </template>
@@ -176,7 +176,7 @@
const depth = depthMap.value.get(record.categoryId || 0) || 0; const depth = depthMap.value.get(record.categoryId || 0) || 0;
if (depth === 0) return ''; if (depth === 0) return '';
const W = 20; const W = 20;
const H = 40; const H = 20;
const mid = H / 2; const mid = H / 2;
// 回溯祖先链:chain[0]=顶级 ... chain[depth]=当前节点 // 回溯祖先链:chain[0]=顶级 ... chain[depth]=当前节点
const chain: number[] = []; const chain: number[] = [];
@@ -45,10 +45,10 @@
@del="onDeleteItem" @del="onDeleteItem"
/> />
</a-form-item> </a-form-item>
<a-form-item label="商品价格" name="price"> <a-form-item label="商城价" name="price">
<a-input <a-input
allow-clear allow-clear
placeholder="请输入商品价格" placeholder="请输入商城价"
v-model:value="form.price" v-model:value="form.price"
/> />
</a-form-item> </a-form-item>
+1 -1
View File
@@ -137,7 +137,7 @@
align: 'center' align: 'center'
}, },
{ {
title: '商品价格', title: '商城价',
dataIndex: 'price', dataIndex: 'price',
key: 'price', key: 'price',
align: 'center' align: 'center'
@@ -20,12 +20,12 @@
<a-form-item label="未付款自动关闭" name="orderAutoCloseMins"> <a-form-item label="未付款自动关闭" name="orderAutoCloseMins">
<a-input-number <a-input-number
v-model:value="form.orderAutoCloseMins" v-model:value="form.orderAutoCloseMins"
:min="1" :min="0"
:max="10080" :max="10080"
placeholder="请输入分钟数" placeholder="0 表示不自动取消"
style="width: 200px" style="width: 200px"
/> />
<span style="margin-left: 8px; color: #888">分钟</span> <span style="margin-left: 8px; color: #888">分钟0 表示不自动取消</span>
</a-form-item> </a-form-item>
<a-form-item label="自动好评天数" name="orderAutoCommentDays"> <a-form-item label="自动好评天数" name="orderAutoCommentDays">
<a-input-number <a-input-number
@@ -54,6 +54,15 @@
un-checked-children="" un-checked-children=""
/> />
</a-form-item> </a-form-item>
<a-form-item label="销量累加方式" name="salesAccumulateType">
<a-radio-group v-model:value="form.salesAccumulateType">
<a-radio :value="10">支付成功后累加默认</a-radio>
<a-radio :value="20">下单成功后累加</a-radio>
</a-radio-group>
<div style="color: #888; font-size: 12px; margin-top: 4px">
支付累加用户付款后才计入销量下单累加生成订单即计入销量取消订单会回滚
</div>
</a-form-item>
<a-form-item label="保存"> <a-form-item label="保存">
<a-button type="primary" :loading="loading" @click="save">保存</a-button> <a-button type="primary" :loading="loading" @click="save">保存</a-button>
</a-form-item> </a-form-item>
@@ -84,7 +93,8 @@ const { form, assignFields } = useFormData<ShopSetting>({
orderAutoCloseMins: 30, orderAutoCloseMins: 30,
orderAutoCommentDays: 15, orderAutoCommentDays: 15,
orderFreightFreeAmount: 0, orderFreightFreeAmount: 0,
orderRefundEnabled: true orderRefundEnabled: true,
salesAccumulateType: 10
}); });
const rules = reactive({ const rules = reactive({
@@ -99,7 +109,7 @@ const rules = reactive({
orderAutoCloseMins: [ orderAutoCloseMins: [
{ {
required: true, required: true,
message: '请输入未付款自动关闭分钟数', message: '请输入未付款自动关闭分钟数0 表示不自动取消)',
type: 'number', type: 'number',
trigger: 'blur' trigger: 'blur'
} }
@@ -135,6 +145,9 @@ const load = async () => {
if (values.orderFreightFreeAmount !== undefined) { if (values.orderFreightFreeAmount !== undefined) {
form.orderFreightFreeAmount = Number(values.orderFreightFreeAmount); form.orderFreightFreeAmount = Number(values.orderFreightFreeAmount);
} }
if (values.salesAccumulateType !== undefined) {
form.salesAccumulateType = Number(values.salesAccumulateType);
}
} }
} catch (e: any) { } catch (e: any) {
// 未配置时忽略 // 未配置时忽略
@@ -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>
+249
View File
@@ -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.valuev-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,71 @@
<!-- 搜索表单 -->
<template>
<a-space style="flex-wrap: wrap">
<a-button type="text" @click="openUrl(`/website/field`)"
>字段扩展
</a-button>
<a-button type="text" @click="openUrl('/website/dict')">字典管理 </a-button>
<a-button type="text" @click="openUrl('/website/domain')"
>域名管理
</a-button>
<a-button type="text" @click="openUrl('/website/model')"
>模型管理
</a-button>
<a-button type="text" @click="openUrl('/website/form')">表单管理 </a-button>
<a-button type="text" @click="openUrl('/website/lang')">国际化 </a-button>
<a-button type="text" @click="openUrl('/website/setting')"
>网站设置
</a-button>
<a-button type="text" class="ele-btn-icon" @click="clearSiteInfoCache">
清除缓存
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { watch, nextTick } from 'vue';
import { CmsWebsite } from '@/api/cms/cmsWebsite/model';
import { openUrl } from '@/utils/common';
import { message } from 'ant-design-vue';
import { removeSiteInfoCache } from '@/api/cms/cmsWebsite';
const props = withDefaults(
defineProps<{
// 选中的角色
selection?: [];
website?: CmsWebsite;
count?: 0;
}>(),
{}
);
const emit = defineEmits<{
(e: 'add'): void;
}>();
const add = () => {
emit('add');
};
// 清除缓存
const clearSiteInfoCache = () => {
removeSiteInfoCache(
'SiteInfo:' + localStorage.getItem('TenantId') + '*'
).then((msg) => {
if (msg) {
message.success(msg);
}
});
};
nextTick(() => {
if (localStorage.getItem('NotActive')) {
// IsActive.value = false
}
});
watch(
() => props.selection,
() => {}
);
</script>
@@ -0,0 +1,408 @@
<!-- 编辑弹窗 -->
<template>
<ele-modal
:width="800"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑小程序' : '创建小程序'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
:confirm-loading="loading"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 4, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="Logo" name="avatar">
<SelectFile
:placeholder="`请选择图片`"
:limit="1"
:data="images"
@done="chooseImage"
@del="onDeleteItem"
/>
</a-form-item>
<a-form-item label="账号类型" name="type">
{{ form.type }}
</a-form-item>
<a-form-item label="小程序名称" name="websiteName">
<a-input
allow-clear
placeholder="请输入小程序名称"
v-model:value="form.websiteName"
/>
</a-form-item>
<a-form-item label="网站域名" name="domain" v-if="form.type == 10">
<a-input v-model:value="form.domain" placeholder="huawei.com">
<template #addonBefore>
<a-select v-model:value="form.prefix" style="width: 90px">
<a-select-option value="http://">http://</a-select-option>
<a-select-option value="https://">https://</a-select-option>
</a-select>
</template>
</a-input>
</a-form-item>
<a-form-item label="AppId" name="websiteCode" v-if="form.type == 20">
<a-input
allow-clear
placeholder="请输入AppId"
v-model:value="form.websiteCode"
/>
</a-form-item>
<a-form-item label="小程序描述" name="comments">
<a-textarea
:rows="4"
:maxlength="200"
placeholder="请输入小程序描述"
v-model:value="form.comments"
/>
</a-form-item>
<a-form-item label="SEO关键词" name="keywords">
<a-textarea
:rows="4"
:maxlength="200"
placeholder="请输入SEO关键词"
v-model:value="form.keywords"
/>
</a-form-item>
<!-- <a-form-item label="全局样式" name="style">-->
<!-- <a-textarea-->
<!-- :rows="4"-->
<!-- :maxlength="200"-->
<!-- placeholder="全局样式"-->
<!-- v-model:value="form.style"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- <a-form-item label="小程序类型" name="websiteType">-->
<!-- <a-select-->
<!-- :options="websiteType"-->
<!-- :value="form.websiteType"-->
<!-- placeholder="请选择主体类型"-->
<!-- @change="onCmsWebsiteType"-->
<!-- />-->
<!-- </a-form-item>-->
<!-- <a-form-item label="当前版本" name="version">-->
<!-- <a-tag color="red" v-if="form.version === 10">标准版</a-tag>-->
<!-- <a-tag color="green" v-if="form.version === 20">专业版</a-tag>-->
<!-- <a-tag color="cyan" v-if="form.version === 30">永久授权</a-tag>-->
<!-- </a-form-item>-->
<a-form-item label="状态" name="running">
<a-radio-group
v-model:value="form.running"
:disabled="form.running == 4 || form.running == 5"
>
<a-radio :value="1">运行中</a-radio>
<a-radio :value="2">维护中</a-radio>
<a-radio :value="3">已关闭</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="form.running == 2" label="维护说明" name="statusText">
<a-textarea
:rows="4"
:maxlength="200"
placeholder="状态说明"
v-model:value="form.statusText"
/>
</a-form-item>
<!-- <a-divider style="margin-bottom: 24px" />-->
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { addCmsWebsite, updateCmsWebsite } from '@/api/cms/cmsWebsite';
import { CmsWebsite } from '@/api/cms/cmsWebsite/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { FormInstance, type Rule } from 'ant-design-vue/es/form';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { FileRecord } from '@/api/system/file/model';
import { checkExistence } from '@/api/cms/cmsDomain';
import { updateCmsDomain } from '@/api/cms/cmsDomain';
import { updateTenant } from '@/api/system/tenant';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: CmsWebsite | null;
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
const images = ref<ItemType[]>([]);
const websiteQrcode = ref<ItemType[]>([]);
const oldDomain = ref();
const files = ref<ItemType[]>([]);
// 用户信息
const form = reactive<CmsWebsite>({
websiteId: undefined,
websiteLogo: undefined,
websiteName: undefined,
websiteCode: undefined,
type: 20,
files: undefined,
keywords: '',
prefix: '',
domain: '',
adminUrl: '',
style: '',
icpNo: undefined,
email: undefined,
version: undefined,
websiteType: '',
running: 1,
expirationTime: undefined,
sortNumber: undefined,
comments: undefined,
status: undefined,
statusText: undefined
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
// comments: [
// {
// required: true,
// type: 'string',
// message: '请填写小程序描述',
// trigger: 'blur'
// }
// ],
keywords: [
{
required: true,
type: 'string',
message: '请填写SEO关键词',
trigger: 'blur'
}
],
running: [
{
required: true,
type: 'number',
message: '请选择小程序状态',
trigger: 'change'
}
],
domain: [
{
required: true,
type: 'string',
message: '请填写小程序域名',
trigger: 'blur'
}
],
websiteCode: [
{
required: true,
type: 'string',
message: '请填写小程序码',
trigger: 'blur'
}
],
// websiteCode: [
// {
// required: true,
// type: 'string',
// message: '该域名已被使用',
// validator: (_rule: Rule, value: string) => {
// return new Promise<void>((resolve, reject) => {
// if (!value) {
// return reject('请输入二级域名');
// }
// checkExistence('domain', `${value}.wsdns.cn`)
// .then(() => {
// if (value === oldDomain.value) {
// return resolve();
// }
// reject('已存在');
// })
// .catch(() => {
// resolve();
// });
// });
// },
// trigger: 'blur'
// }
// ],
adminUrl: [
{
required: true,
type: 'string',
message: '请填写小程序后台管理地址',
trigger: 'blur'
}
],
icpNo: [
{
required: true,
type: 'string',
message: '请填写ICP备案号',
trigger: 'blur'
}
],
appSecret: [
{
required: true,
type: 'string',
message: '请填写小程序秘钥',
trigger: 'blur'
}
],
websiteName: [
{
required: true,
type: 'string',
message: '请填写小程序信息名称',
trigger: 'blur'
}
]
});
const chooseImage = (data: FileRecord) => {
images.value.push({
uid: data.id,
url: data.path,
status: 'done'
});
form.websiteLogo = data.downloadUrl;
};
const onDeleteItem = (index: number) => {
images.value.splice(index, 1);
form.websiteLogo = '';
};
const chooseFile = (data: FileRecord) => {
form.websiteCode = data.url;
files.value.push({
uid: data.id,
url: data.url,
status: 'done'
});
};
const onDeleteFile = (index: number) => {
files.value.splice(index, 1);
};
// const onWebsiteType = (text: string) => {
// form.websiteType = text;
// };
const { resetFields } = useForm(form, rules);
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateCmsWebsite : addCmsWebsite;
if (!isUpdate.value) {
updateVisible(false);
message.loading('创建过程中请勿刷新页面!', 0);
}
const formData = {
...form,
type: 20,
adminUrl: `mp.websoft.top`,
files: JSON.stringify(files.value)
};
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
updateVisible(false);
updateCmsDomain({
websiteId: form.websiteId,
domain: `${localStorage.getItem('TenantId')}.shoplnk.cn`
});
updateTenant({
tenantName: `${form.websiteName}`
}).then(() => {});
localStorage.setItem('Domain', `${form.websiteCode}.shoplnk.cn`);
localStorage.setItem('WebsiteId', `${form.websiteId}`);
localStorage.setItem('WebsiteName', `${form.websiteName}`);
message.destroy();
message.success(msg);
// window.location.reload();
emit('done');
})
.catch((e) => {
loading.value = false;
message.destroy();
message.error(e.message);
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
images.value = [];
files.value = [];
websiteQrcode.value = [];
if (props.data?.websiteId) {
assignObject(form, props.data);
if (props.data.websiteLogo) {
images.value.push({
uid: uuid(),
url: props.data.websiteLogo,
status: 'done'
});
}
if (props.data.files) {
files.value = JSON.parse(props.data.files);
}
if (props.data.websiteCode) {
oldDomain.value = props.data.websiteCode;
}
isUpdate.value = true;
} else {
isUpdate.value = false;
}
} else {
resetFields();
}
},
{ immediate: true }
);
</script>
File diff suppressed because it is too large Load Diff
@@ -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>
+604
View File
@@ -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>
+8
View File
@@ -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;
+10
View File
@@ -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;