Compare commits
6 Commits
main
...
ae2ea4d841
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2ea4d841 | |||
| aae0c03dd8 | |||
| 3bc856fc95 | |||
| c553a71ea2 | |||
| a284a83b86 | |||
| bf60ba1d04 |
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist.zip
|
||||||
|
.verify-build
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 2026-07-29
|
||||||
|
|
||||||
|
## websopy-java:cms 模块数据源路由修复(重要跨项目)
|
||||||
|
- 报错:`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 事务不在一个事务里,写一致性需后续评估。
|
||||||
@@ -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 改 0,placeholder/文案/校验提示改为「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 / 取消逻辑 / 库存回退 / 优惠券退还 / 销量回滚 全部未动。
|
||||||
@@ -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 个请求:pageShopGoods(datasource)、getCount(search.vue reload)、listShopGoodsCategory(index.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 整体**;仅当前模块。
|
||||||
|
|
||||||
|
## 后端 /data(getCount)实现细节(影响合并决策)
|
||||||
|
- `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 等共享业务在两端都有活代码,改动需两端同步。
|
||||||
@@ -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://shop-api.websoft.top`(= guilixu-java)**,不是 mp-java。前端 shopExpressTemplateDetail API 用默认 `@/utils/request`,baseURL = `MODULES_API_URL` = `localStorage ApiUrl || VITE_API_URL`;该部署的 ApiUrl 配的就是 shop-api.websoft.top/api,即 **guilixu-java 才是生效后端**。
|
||||||
|
- **结论反转:guilixu-java 这套实现正确且生效,不是死代码,后端已完整,无需改 mp-java。** mp-java 与此功能无关(本部署 商城不走 mp-java)。
|
||||||
|
- 后端 `region/regions-data.json` 用 v/l/c 精简格式(与 RegionCodeResolver 读取一致),无字段名 bug。
|
||||||
|
- 后端完成度:entity/param/SQL/DeliveryRegionChecker/RegionCodeResolver/下单接入(OrderBusinessService 第 87 行调用 validateDeliveryRegionIfNeeded)全齐。
|
||||||
|
- **剩余工作 = 纯前端(guilixu-admin)**:① model 加 regionMode/regionIds;② 新建双栏多选地区组件(替换单路径级联 `RegionsSelect`,支持"全国/指定配送/指定不发货"模式切换+多选多省);③ 编辑表单加模式切换+多选;④ 列表回显模式与地区汇总。截图里的 Transfer Modal 前端尚不存在。
|
||||||
|
- 教训:判断"改了哪个后端才生效"要以**运行时的 ApiUrl(localStorage/localStorage 配置的 ApiUrl,本部署=guilixu-java)**为准,不能默认 `MODULES_API_URL`=mp-java。guilixu-admin 的 商城走 guilixu-java。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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。
|
||||||
|
|
||||||
|
### 待办 / 风险
|
||||||
|
- **后端需同步改造**:表加 `province_ids` / `city_ids`(varchar 逗号分隔),接口 add/update 接收这两个字段。前端已按此约定提交,后端未改则数据落不了库。
|
||||||
|
- antd 为 v4,`var(--ant-color-border)` 在暗色下可能回退浅灰(边框略浅,不影响功能);如要完美暗色适配可后续改 less 变量。
|
||||||
|
- 构建验证(vite build)已触发,待结果确认无编译错误。
|
||||||
@@ -27,11 +27,26 @@
|
|||||||
### 双后端 / MODULES_API_URL 模式(2026-07-25 分析结论)
|
### 双后端 / MODULES_API_URL 模式(2026-07-25 分析结论)
|
||||||
- **mp-java**(`/Users/gxwebsoft/JAVA/mp-java`)= 独立的「modules 服务」,用 `modules` 库;cms(235类)、shop 等业务模块都在它这里,持续高频开发。
|
- **mp-java**(`/Users/gxwebsoft/JAVA/mp-java`)= 独立的「modules 服务」,用 `modules` 库;cms(235类)、shop 等业务模块都在它这里,持续高频开发。
|
||||||
- **guilixu-java**(`/Users/gxwebsoft/JAVA/guilixu-java`)= 桂礼序主后端,用 `db_guilixu` 库;是 mp-java 的「子集分叉」。
|
- **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-admin 前端已预设双基地址**:`SERVER_API_URL`(主后端) + `MODULES_API_URL`(localStorage ApiUrl / VITE_API_URL)。
|
||||||
- **结论:guilixu-java 里的 cms 包(152类)是 mp-java cms 的旧分叉子集、且前端并不调它(前端调 modules 服务),属重复/死代码,应删除而非复制或扩展。**
|
- ⚠️ **运行时实际指向以 `localStorage ApiUrl` 为准**(2026-08-06 用户纠正):**guilixu-admin 的「商城」接口实际调 `https://shop-api.websoft.top`(= guilixu-java)**,并非 mp-java。**判断"改哪个后端才生效"不能默认 MODULES_API_URL=mp-java,必须查 ApiUrl 运行时配置。** cms 接口另走 `VITE_CMS_API_URL`=cms-api.websoft.top(mp-java)。
|
||||||
|
- **结论(仅限 cms 模块):guilixu-java 里的 cms 包(152类)是 mp-java cms 的旧分叉子集、且前端并不调它(前端调 modules 服务),属重复/死代码,应删除而非复制或扩展。**
|
||||||
- 复制「整个 cms 到 guilixu-java」不可行:mp-java 完整 cms 的 CmsApp/CmsWebsiteService 依赖 `project` 模块(guilixu-java 没有),会编译失败。
|
- 复制「整个 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`),各自独立部署、独立库。
|
- 注意:mp-java 与 guilixu-java 是独立 git 仓库(`git.websoft.top/gxwebsoft/mp-java.git` 与 `guilixu-java.git`),各自独立部署、独立库。
|
||||||
|
|
||||||
|
### ⚠️ 双后端改动同步规则(2026-08-05 修正)
|
||||||
|
- **cms 模块**改动:仅 mp-java(guilixu-java 的 cms 是死代码)。
|
||||||
|
- **shop 等共享业务模块**(如 `ShopGoodsController` / Service / Mapper / task):**mp-java 与 guilixu-java 都有活代码且结构一致(同 `com.gxwebsoft.shop` 包)**,用户确认改动要**两端同步**(例:2026-08-05 shopGoods `/data` 统计优化,两端各改 6 处)。
|
||||||
|
- 改动前先 grep 两端确认文件是否存在,避免只改一处导致另一端行为不一致。
|
||||||
|
|
||||||
|
### ⚠️ 运费模板不发货地区需求(2026-08-06 结论,已按用户纠正修正)
|
||||||
|
- 需求:运费模板支持「不发货地区(黑名单)」,ShopExpressTemplateDetail 加 regionMode/regionIds 字段 + DeliveryRegionChecker 下单校验。
|
||||||
|
- **后端(guilixu-java)已完整实现**:entity/param/迁移SQL/DeliveryRegionChecker/RegionCodeResolver/OrderBusinessService 接入(第 87 行调用 validateDeliveryRegionIfNeeded)均就位。
|
||||||
|
- **关键事实(用户 2026-08-06 18:09 纠正):guilixu-admin 的「商城」接口实际调 `https://shop-api.websoft.top`(= guilixu-java)**。前端 shopExpressTemplateDetail 用默认 `@/utils/request`,baseURL = MODULES_API_URL = localStorage ApiUrl || VITE_API_URL;本部署的 ApiUrl 即 shop-api.websoft.top/api。故 guilixu-java 是生效后端,其实现正确生效,不是死代码。
|
||||||
|
- **结论:本功能(admin 配置)无需改 mp-java**(本部署 商城不走 mp-java)。
|
||||||
|
- **端到端风险(待确认)**:下单校验读 guilixu-java 的 db_guilixu。若「用户端小程序 guilixu-taro 的 下单」走 mp-java(不同库 modules),黑名单配置不被 下单看到、不发货地区不拦截,需确认 下单后端;若 下单也走 guilixu-java 则功能完整闭环。
|
||||||
|
- **剩余工作 = 纯前端(guilixu-admin)**:model 加 regionMode/regionIds + 新建双栏多选地区组件(替换单路径级联 RegionsSelect,支持"全国/指定配送/指定不发货"模式切换+多选多省)+ 编辑表单模式切换 + 列表回显模式与地区汇总。
|
||||||
|
- 接口方案文档:`docs/运费模板-不发货地区-接口方案.md`。
|
||||||
|
|
||||||
### shopGoods status 字段(上架/下架)
|
### shopGoods status 字段(上架/下架)
|
||||||
- `0` = 已上架 / 上架
|
- `0` = 已上架 / 上架
|
||||||
- `1` = 待上架 / 下架
|
- `1` = 待上架 / 下架
|
||||||
@@ -40,3 +55,11 @@
|
|||||||
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
|
- 一键上下架、批量上下架均通过 `updateShopGoods({ ...record, status })` 直接改 status 实现(不额外用 isShow 字段)。
|
||||||
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
|
- 列表 tag 仍显示:0=已上架、1=待上架、2=待审核、3=审核不通过。
|
||||||
|
|
||||||
|
### 运费模板配送地区多选(2026-08-11 已实现前端)
|
||||||
|
- 需求:配送地区选择从「省+市单选级联」升级为「省+市两级多选穿梭弹窗」(仿客户截图双栏:左候选/右已选)。
|
||||||
|
- 前端组件:`src/components/RegionMultiSelect/index.vue`(树形可展开,省级"选择"=选该省全部市,市级可单独选;@update:value 城市ID数组 + @update:provinceIds 省份ID数组)。
|
||||||
|
- 数据字段:model 新增 `provinceIds` / `cityIds`(逗号分隔字符串);保留旧 `provinceId`/`cityId` 兼容(编辑回显时旧单值会自动补进 cityIds)。
|
||||||
|
- 列表多值展示:`index.vue` customRender 遍历 `cityIds` 反查 regionMap,超 22 字截断 + tooltip。
|
||||||
|
- ⚠️ 后端待改:`shop_express_template_detail` 表加 `province_ids`/`city_ids`(varchar 逗号分隔),add/update 接口接收这两个字段;前端已按此约定提交,后端未改前数据落不了库。
|
||||||
|
- 注:与"不发货地区(黑名单) regionMode/regionIds"是不同需求,勿混淆。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 运费模板「不发货地区(排除地区)」接口方案
|
||||||
|
|
||||||
|
> 目标:支持商家配置"除部分偏远地区外,全国均可配送"的场景,避免逐个添加可配送省份。
|
||||||
|
> 前端代码库:guilixu-admin(shop 模块接口走 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` 增加模式切换 + 列表回显。*
|
||||||
@@ -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;
|
||||||
|
/** 省份IDs(逗号分隔,多选配送区域) */
|
||||||
|
provinceIds?: string;
|
||||||
|
/** 城市IDs(逗号分隔,多选配送区域) */
|
||||||
|
cityIds?: string;
|
||||||
/** 首件数量/重量 */
|
/** 首件数量/重量 */
|
||||||
firstNum?: string;
|
firstNum?: string;
|
||||||
/** 首件价格 */
|
/** 首件价格 */
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface ShopGoods {
|
|||||||
position?: string;
|
position?: string;
|
||||||
// 进货价格
|
// 进货价格
|
||||||
buyingPrice?: string;
|
buyingPrice?: string;
|
||||||
// 商品价格
|
// 商城价
|
||||||
price?: string;
|
price?: string;
|
||||||
originPrice?: string;
|
originPrice?: string;
|
||||||
// 销售价格
|
// 销售价格
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface ShopGoodsBrowse {
|
|||||||
goodsName?: string;
|
goodsName?: string;
|
||||||
/** 商品封面图(关联查询) */
|
/** 商品封面图(关联查询) */
|
||||||
goodsImage?: string;
|
goodsImage?: string;
|
||||||
/** 商品价格(关联查询) */
|
/** 商城价(关联查询) */
|
||||||
price?: string;
|
price?: string;
|
||||||
/** 市场价(关联查询) */
|
/** 市场价(关联查询) */
|
||||||
salePrice?: string;
|
salePrice?: string;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface ShopGoodsSku {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
// 商品图片
|
// 商品图片
|
||||||
image?: string;
|
image?: string;
|
||||||
// 商品价格
|
// 商城价
|
||||||
price?: string;
|
price?: string;
|
||||||
// 市场价格
|
// 市场价格
|
||||||
salePrice?: string;
|
salePrice?: string;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export interface ShopSetting {
|
|||||||
payAlipayPrivateKey?: string;
|
payAlipayPrivateKey?: string;
|
||||||
payAlipayPublicKey?: string;
|
payAlipayPublicKey?: string;
|
||||||
payBalanceEnabled?: boolean | number;
|
payBalanceEnabled?: boolean | number;
|
||||||
|
|
||||||
|
salesAccumulateType?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
<!-- 省+市两级多选穿梭弹窗 -->
|
||||||
|
<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;
|
||||||
|
(e: 'update:provinceIds', 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 derivedProvinceIds = computed(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
selectedSet.value.forEach((cv) => {
|
||||||
|
const pv = cityToProvince.value[cv];
|
||||||
|
if (pv) {
|
||||||
|
set.add(pv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(set);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
emit('update:provinceIds', derivedProvinceIds.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>
|
||||||
+21
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-28
@@ -20,13 +20,13 @@
|
|||||||
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="配送地区">
|
||||||
<RegionsSelect
|
<RegionMultiSelect
|
||||||
v-model:value="regions"
|
:value="cityIds"
|
||||||
type="provinceCity"
|
@update:value="onCityIdsChange"
|
||||||
:show-search="true"
|
@update:provinceIds="onProvinceIdsChange"
|
||||||
placeholder="请选择配送省份/城市(不选则默认全国)"
|
|
||||||
/>
|
/>
|
||||||
|
<div class="rms-tip">不选则默认全国</div>
|
||||||
</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 +94,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 +128,10 @@
|
|||||||
const maxable = ref(true);
|
const maxable = ref(true);
|
||||||
// 表单实例
|
// 表单实例
|
||||||
const formRef = ref<FormInstance | null>(null);
|
const formRef = ref<FormInstance | null>(null);
|
||||||
// 级联选择器选中值(字符串ID数组)
|
// 已选城市ID数组
|
||||||
const regions = ref<string[]>([]);
|
const cityIds = ref<string[]>([]);
|
||||||
|
// 已选省份ID数组(由城市推导,随城市变化同步)
|
||||||
|
const provinceIds = ref<string[]>([]);
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = reactive<ShopExpressTemplateDetail>({
|
const form = reactive<ShopExpressTemplateDetail>({
|
||||||
@@ -138,6 +140,8 @@
|
|||||||
type: true,
|
type: true,
|
||||||
provinceId: undefined,
|
provinceId: undefined,
|
||||||
cityId: undefined,
|
cityId: undefined,
|
||||||
|
provinceIds: undefined,
|
||||||
|
cityIds: undefined,
|
||||||
firstNum: undefined,
|
firstNum: undefined,
|
||||||
firstAmount: undefined,
|
firstAmount: undefined,
|
||||||
extraAmount: undefined,
|
extraAmount: undefined,
|
||||||
@@ -186,16 +190,17 @@
|
|||||||
|
|
||||||
const { resetFields } = useForm(form, rules);
|
const { resetFields } = useForm(form, rules);
|
||||||
|
|
||||||
/* 级联选择器值变化时,同步到 form.provinceId / form.cityId */
|
/* 城市选中变化,同步到 form.cityIds */
|
||||||
watch(regions, (val) => {
|
const onCityIdsChange = (val: string[]) => {
|
||||||
if (val && val.length > 0) {
|
cityIds.value = val;
|
||||||
form.provinceId = Number(val[0]);
|
form.cityIds = val.length ? val.join(',') : undefined;
|
||||||
form.cityId = val.length > 1 ? Number(val[1]) : undefined;
|
};
|
||||||
} else {
|
|
||||||
form.provinceId = undefined;
|
/* 省份选中变化(由组件根据已选城市推导),同步到 form.provinceIds */
|
||||||
form.cityId = undefined;
|
const onProvinceIdsChange = (val: string[]) => {
|
||||||
}
|
provinceIds.value = val;
|
||||||
});
|
form.provinceIds = val.length ? val.join(',') : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/* 保存编辑 */
|
/* 保存编辑 */
|
||||||
const save = () => {
|
const save = () => {
|
||||||
@@ -210,6 +215,13 @@
|
|||||||
// 确保templateId和type从父级继承
|
// 确保templateId和type从父级继承
|
||||||
formData.templateId = props.templateId;
|
formData.templateId = props.templateId;
|
||||||
formData.type = props.templateType ?? true;
|
formData.type = props.templateType ?? true;
|
||||||
|
// 多选配送区域(逗号分隔,空则默认全国)
|
||||||
|
formData.cityIds = cityIds.value.length
|
||||||
|
? cityIds.value.join(',')
|
||||||
|
: undefined;
|
||||||
|
formData.provinceIds = provinceIds.value.length
|
||||||
|
? provinceIds.value.join(',')
|
||||||
|
: undefined;
|
||||||
const saveOrUpdate = isUpdate.value
|
const saveOrUpdate = isUpdate.value
|
||||||
? updateShopExpressTemplateDetail
|
? updateShopExpressTemplateDetail
|
||||||
: addShopExpressTemplateDetail;
|
: addShopExpressTemplateDetail;
|
||||||
@@ -240,21 +252,36 @@
|
|||||||
form.templateId = props.templateId;
|
form.templateId = props.templateId;
|
||||||
form.type = props.templateType ?? true;
|
form.type = props.templateType ?? true;
|
||||||
}
|
}
|
||||||
// 回显级联选择器
|
// 回显多选配送区域
|
||||||
if (form.provinceId) {
|
if (form.cityIds) {
|
||||||
const arr = [String(form.provinceId)];
|
cityIds.value = form.cityIds.split(',').filter(Boolean);
|
||||||
if (form.cityId) {
|
} else if (form.cityId) {
|
||||||
arr.push(String(form.cityId));
|
// 兼容旧单值数据
|
||||||
}
|
cityIds.value = [String(form.cityId)];
|
||||||
regions.value = arr;
|
|
||||||
} else {
|
} else {
|
||||||
regions.value = [];
|
cityIds.value = [];
|
||||||
|
}
|
||||||
|
if (form.provinceIds) {
|
||||||
|
provinceIds.value = form.provinceIds.split(',').filter(Boolean);
|
||||||
|
} else if (form.provinceId) {
|
||||||
|
provinceIds.value = [String(form.provinceId)];
|
||||||
|
} else {
|
||||||
|
provinceIds.value = [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resetFields();
|
resetFields();
|
||||||
regions.value = [];
|
cityIds.value = [];
|
||||||
|
provinceIds.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';
|
||||||
@@ -128,6 +128,26 @@
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
width: 150,
|
width: 150,
|
||||||
customRender: ({ record }) => {
|
customRender: ({ record }) => {
|
||||||
|
// 多选配送区域(cityIds 逗号分隔)
|
||||||
|
const cityIds = (record.cityIds || '')
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean);
|
||||||
|
if (cityIds.length) {
|
||||||
|
const names = cityIds.map(
|
||||||
|
(id: string) => regionMap.value[id] || id
|
||||||
|
);
|
||||||
|
const text = names.join('、');
|
||||||
|
// 过长截断并支持 tooltip 悬浮查看全部
|
||||||
|
if (text.length > 22) {
|
||||||
|
return h(
|
||||||
|
'span',
|
||||||
|
{ title: text },
|
||||||
|
text.slice(0, 22) + '…'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
// 兼容旧单值数据
|
||||||
const province = record.provinceId
|
const province = record.provinceId
|
||||||
? regionMap.value[String(record.provinceId)]
|
? regionMap.value[String(record.provinceId)]
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
@@ -119,7 +119,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';
|
||||||
@@ -244,18 +244,20 @@
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,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"
|
||||||
@@ -308,7 +308,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"
|
||||||
@@ -895,7 +895,7 @@ const columns = [
|
|||||||
align: 'center'
|
align: 'center'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '商品价格',
|
title: '商城价',
|
||||||
dataIndex: 'price',
|
dataIndex: 'price',
|
||||||
key: 'price',
|
key: 'price',
|
||||||
align: 'center'
|
align: 'center'
|
||||||
@@ -1337,7 +1337,7 @@ const rules = reactive({
|
|||||||
price: [
|
price: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: '请填写商品价格',
|
message: '请填写商城价',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
trigger: 'blur'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -154,7 +157,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,
|
||||||
@@ -511,9 +514,8 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* 查询 */
|
/* 查询:分类数据延迟到列表首屏渲染完成后再加载,不阻塞首屏 */
|
||||||
const query = () => {
|
const query = () => {
|
||||||
loading.value = true;
|
|
||||||
// 加载分类数据
|
// 加载分类数据
|
||||||
if (!navigationList.value) {
|
if (!navigationList.value) {
|
||||||
listShopGoodsCategory({}).then((res) => {
|
listShopGoodsCategory({}).then((res) => {
|
||||||
@@ -623,7 +625,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
query();
|
onMounted(query);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -632,4 +634,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>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
ellipsis: true
|
ellipsis: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '商品价格',
|
title: '商城价',
|
||||||
dataIndex: 'price',
|
dataIndex: 'price',
|
||||||
key: 'price',
|
key: 'price',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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
Reference in New Issue
Block a user