6 Commits

Author SHA1 Message Date
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
18 changed files with 1419 additions and 30 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
VITE_APP_NAME=后台管理系统
#VITE_API_URL=http://127.0.0.1:9200/api
VITE_API_URL=https://shop-api.websoft.top/api
VITE_API_URL=http://127.0.0.1:9200/api
#VITE_API_URL=https://shop-api.websoft.top/api
VITE_CMS_API_URL=https://cms-api.websoft.top/api
+10
View File
@@ -13,3 +13,13 @@
- 改 `src/views/shop/shopExpressTemplateDetail/index.vue`:「配送地区」列按 region_mode 显示「不发货:xxx / 配送:xxx / 默认全国」,城市码聚合为省名显示。
- 类型检查(vue-tsc)确认本次改动无新错误(唯一相关报错是预存的 `$router` 用法,原文件即有,未引入)。
- 结论:黑名单现可经后台 UI 配置;SQL 直插仍作为备选(二者后台存城市码、SQL 存省份码,后端比对为编码集合 contains,都生效、互不冲突)。
- 已生成插偏远八省黑名单完整 SQL:`offline-remote-areas-blacklist-10606.sql`(template_id=11, region_mode=2, region_ids=新疆650000/西藏540000/青海630000/宁夏640000/内蒙古150000/海南460000/黑龙江230000/甘肃620000, status=0, tenant_id=10606),含前置查重 + 后置验证语句。
## 微信支付报错 APPID_MCHID_NOT_MATCH(tenant 10606 下单失败)
- 现象:用户下单报 `创建微信支付订单失败:Wrong HttpStatusCode[400] ... APPID_MCHID_NOT_MATCH`。服务商模式 JSAPI,请求体 sp_appid=`wx541db955e7a62709`/sp_mchid=`1720296183`、sub_appid=`wxcad054ee427aaca1`/sub_mchid=`1744894035`。
- 根因:传给微信的 `sub_appid` 与 `sub_mchid` 在微信侧不匹配(该子商户应用ID未绑定到该子商户号)。代码无 bug。
- 4 个值来源:`gxwebsoft_core.sys_payment`(tenant 10606 微信JSAPI记录)字段 `app_id`/`mch_id`/`sub_app_id`/`sub_mch_id`;拼装在 `ShopOrderServiceImpl.java:809-912`(服务商模式)。
- 已验证 `sub_app_id=wxcad054ee427aaca1` 就是 `guilixu-taro` 用户端小程序真实 appid(project.config.json),故 sub_appid 是对的、与买家 openid 一致。
- 结论:问题在 `sub_app_id↔sub_mchid` 绑定被破坏——最可能(1)商户平台子商户 `1744894035` 对 appid 的授权被移除/失效;(2)`sys_payment.sub_mch_id` 被误改。
- 修复:微信商户平台(服务商 `1720296183` 服务商平台→特约商户管理→开发配置)为子商户 `1744894035` 绑定 appid `wxcad054ee427aaca1`;并 `SELECT` 核对 `sys_payment` 的 sub_mch_id 仍为 `1744894035`。无需改代码,新订单号直接读最新配置。
+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/zone,tenant_id=10606),刷新/重登后生效。
## 专区功能交互入口约定(答疑结论)
- 白名单用户:专区列表每行「白名单」按钮 → 弹窗勾选用户 → 确定(PUT /{id}/users 覆盖式保存到 shop_home_section_user 表)。前端 UserSelectModal 链路完整。
- 商品关联:专区页「商品」按钮只是**查看**该专区下商品(GET /{id}/goods,后端用 FIND_IN_SET(section_id, section_ids) 查询);**往专区加商品不在此页操作**,要去「商品管理 → 编辑商品 → 所属专区(多选)」勾选专区并保存,写入 goods.section_ids。
- 后端接口齐全:users/goods 查询与设置、check-permission 权限校验均已实现。
- ⚠️ 待确认:白名单弹窗当前按 isStaff=true 查后台员工用户(@/api/system/user 的 pageUsers);若受限专区面向小程序买家,需调整用户来源。
+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 @@
-- ============================================================
-- 偏远地区不发货黑名单(租户 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;
+7 -1
View File
@@ -31,8 +31,10 @@ export interface ShopGoods {
canExpress?: number;
// 商品分类
category?: string;
// 商品分类ID
// 商品分类ID(主分类,向后兼容)
categoryId?: number;
// 商品分类ID列表(方案B:可归属多个分类)
categoryIds?: number[];
parentName?: string;
categoryName?: string;
// 一级分类
@@ -149,6 +151,8 @@ export interface ShopGoods {
activityType?: number;
// 配送方式:0送上门 1限自提
deliveryMode?: number;
// 所属专区(逗号分隔的 sectionId 字符串,对应 shop_goods.section_ids 列)
sectionIds?: string;
}
export interface BathSet {
@@ -174,6 +178,8 @@ export interface ShopGoodsCollectParam {
export interface ShopGoodsParam extends PageParam {
parentId?: number;
categoryId?: number;
// 方案B:按多个分类查询,与单值 categoryId 二选一,多值时优先
categoryIds?: number[];
goodsId?: number;
status?: number;
goodsName?: string;
+184
View File
@@ -0,0 +1,184 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type {
HomeSection,
HomeSectionParam,
SectionPermission,
SectionGoods,
SectionUser
} from './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));
}
/**
* 查询专区白名单用户
*/
export async function listSectionUsers(id: number) {
const res = await request.get<ApiResult<SectionUser[]>>(
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 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));
}
+88
View File
@@ -0,0 +1,88 @@
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;
// 排序号(数字越小越靠前)
sortNumber?: number;
// 状态 0正常 1禁用
status?: number;
// 生效开始时间
startTime?: string;
// 生效结束时间
endTime?: string;
// 用户ID
userId?: number;
// 是否删除 0否 1是
deleted?: number;
// 创建时间
createTime?: string;
// 修改时间
updateTime?: string;
}
/**
* 首页专区搜索条件
*/
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;
}
+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>>(
SERVER_API_URL + '/system/user/recommend',
form
+12 -8
View File
@@ -52,15 +52,17 @@
<a-tree-select
allow-clear
multiple
:max-tag-count="2"
:tree-data="navigationList"
tree-default-expand-all
style="width: 180px"
style="width: 200px"
:listHeight="700"
placeholder="请选择分类"
:value="where.categoryId || undefined"
placeholder="请选择分类(可多选)"
:value="where.categoryIds && where.categoryIds.length ? where.categoryIds : undefined"
:dropdown-style="{ overflow: 'auto' }"
@update:value="(value?: number) => (where.categoryId = value)"
@change="onCategoryId"
@update:value="(value?: number[]) => (where.categoryIds = value || [])"
@change="onCategoryIds"
/>
<a-input-search
allow-clear
@@ -149,6 +151,7 @@
status: undefined,
stock: undefined,
categoryId: undefined,
categoryIds: [],
merchantId: undefined,
keywords: ''
});
@@ -238,9 +241,10 @@
reload();
};
// 按分类查询
const onCategoryId = (id: number) => {
where.categoryId = id;
// 按分类查询(方案B:支持多选,主分类取首个)
const onCategoryIds = (ids: number[]) => {
where.categoryIds = ids || [];
where.categoryId = ids && ids.length ? ids[0] : undefined;
emit('search', where);
};
@@ -32,18 +32,31 @@
v-model:value="form.name"
/>
</a-form-item>
<a-form-item label="商品分类" name="categoryId">
<a-form-item label="商品分类" name="categoryIds">
<a-tree-select
allow-clear
multiple
:max-tag-count="3"
:tree-data="navigationList"
tree-default-expand-all
style="width: 320px"
placeholder="请选择分类"
:value="form.categoryId || undefined"
placeholder="请选择分类(可多选)"
:value="form.categoryIds && form.categoryIds.length ? form.categoryIds : undefined"
:listHeight="700"
:dropdown-style="{ overflow: 'auto' }"
@update:value="(value?: number) => (form.categoryId = value)"
@change="onCategoryId"
@update:value="(value?: number[]) => (form.categoryIds = value || [])"
@change="onCategoryIds"
/>
</a-form-item>
<a-form-item label="所属专区" name="sectionIds">
<a-select
v-model:value="sectionIdList"
mode="multiple"
:max-tag-count="3"
:options="zoneOptions"
style="width: 320px"
placeholder="请选择所属专区(可多选)"
allow-clear
/>
</a-form-item>
<a-form-item label="商品编码" name="code">
@@ -707,6 +720,7 @@ import {ShopExpressTemplate} from '@/api/shop/shopExpressTemplate/model';
import {listShopExpressTemplate} from '@/api/shop/shopExpressTemplate';
import {listShopCommissionRole} from '@/api/shop/shopCommissionRole';
import {listShopGoodsRoleCommission} from '@/api/shop/shopGoodsRoleCommission';
import {listHomeSections} from '@/api/shop/shopZone';
// 是否是修改
const isUpdate = ref(false);
@@ -857,6 +871,9 @@ const skuList = ref<ShopGoodsSku[]>([]);
const files = ref<ItemType[]>([]);
const goodsSpec = ref<ShopGoodsSpec>();
const category = ref<string[]>([]);
// 所属专区(多选)
const sectionIdList = ref<number[]>([]);
const zoneOptions = ref<{ label: string; value: number }[]>([]);
// 批量设置
const batchPrice = ref<number | undefined>(undefined);
@@ -928,12 +945,13 @@ const addEnsureTag = (item = '') => {
ensureTagItem.value = '';
};
// 选择栏目
const onCategoryId = (id: number) => {
form.categoryId = id;
// 选择栏目(方案B:支持多选,主分类取列表首个,向后兼容旧字段)
const onCategoryIds = (ids: number[]) => {
form.categoryIds = ids || [];
form.categoryId = ids && ids.length ? ids[0] : undefined;
// 💾 在新增模式下,用户手动选择栏目时也保存到本地存储
if (!isUpdate.value && id) {
saveLastCategory(id);
if (!isUpdate.value && ids && ids.length) {
saveLastCategory(ids[0]);
}
};
@@ -1204,6 +1222,7 @@ const form = reactive<ShopGoods>({
canExpress: 1,
unitName: '',
categoryId: undefined,
categoryIds: [],
parentName: undefined,
categoryName: undefined,
specs: 0,
@@ -1449,14 +1468,18 @@ const chooseSupplier = (item: ShopMerchant) => {
};
const chooseGoodsCategory = (item: ShopGoodsCategory, value: any) => {
form.categoryId = value[1].value;
const cid = value[1].value as number;
form.categoryId = cid;
form.categoryIds = [cid];
form.parentName = value[0].label;
form.categoryName = value[1].label;
};
const chooseTakeawayCategory = (item: ShopGoodsCategory, value: any) => {
const cid = item[0] as number;
form.categoryParent = '店铺分类';
form.categoryChildren = value[0].label;
form.categoryId = item[0];
form.categoryId = cid;
form.categoryIds = [cid];
};
const chooseImage = (data: FileRecord) => {
@@ -1788,8 +1811,17 @@ const save = () => {
if (form.specs === 1 && goodsSpec.value && spec.value.length) {
goodsSpec.value.specValue = JSON.stringify(spec.value);
}
// 方案B:主分类兜底取多分类第一个,保证旧字段/旧逻辑兼容
if (form.categoryIds && form.categoryIds.length && !form.categoryId) {
form.categoryId = form.categoryIds[0];
}
const formData: any = {
...form,
// 所属专区:数字数组 -> 逗号分隔字符串
sectionIds: sectionIdList.value.length
? sectionIdList.value.join(',')
: '',
content: content.value,
category: JSON.stringify(category.value),
files: JSON.stringify(files.value),
@@ -1953,8 +1985,20 @@ watch(
async (visible) => {
if (visible) {
await getExpressTemplateList();
// 加载所属专区选项
listHomeSections({})
.then((list) => {
zoneOptions.value = (list || []).map((z) => ({
label: z.title || '',
value: z.sectionId as number
}));
})
.catch(() => {
zoneOptions.value = [];
});
images.value = [];
sectionIdList.value = [];
category.value = [];
files.value = [];
videos.value = [];
@@ -1963,6 +2007,13 @@ watch(
ensureTagItem.value = '';
if (props.data) {
assignObject(form, props.data);
// 所属专区:逗号分隔字符串 -> 数字数组
sectionIdList.value = form.sectionIds
? form.sectionIds
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => !Number.isNaN(n))
: [];
if (form.commissionType === undefined || form.commissionType === null) {
form.commissionType = 10;
}
@@ -2050,15 +2101,21 @@ watch(
if (form.ensureTag) {
ensureTag.value = form.ensureTag.split(',');
}
// 🎯 优先级设置栏目:
// 1. 如果传入了 categoryId(从栏目页面点击添加),使用传入的
// 2. 否则使用上次保存的栏目
if (props.data.categoryId) {
// 🎯 优先级设置栏目(方案B:支持多分类回填):
// 1. 如果传入了 categoryIds,使用多分类
// 2. 否则如果传入了 categoryId,兼容单分类
// 3. 否则使用上次保存的栏目
if (props.data.categoryIds && props.data.categoryIds.length) {
form.categoryIds = props.data.categoryIds;
form.categoryId = props.data.categoryId ?? props.data.categoryIds[0];
} else if (props.data.categoryId) {
form.categoryId = props.data?.categoryId;
form.categoryIds = [props.data.categoryId];
} else {
const lastCategory = getLastCategory();
if (lastCategory) {
form.categoryId = lastCategory;
form.categoryIds = [lastCategory];
}
}
await getRoleList();
+9
View File
@@ -269,6 +269,15 @@
key: 'name',
width: 300
},
{
title: '分类',
dataIndex: 'categoryName',
key: 'categoryName',
align: 'center',
width: 160,
ellipsis: true,
customRender: ({ text }) => text || '--'
},
{
title: '价格',
key: 'priceGroup',
@@ -90,6 +90,14 @@
style="width: 100%"
/>
</a-form-item>
<a-form-item label="链接地址" name="path">
<a-input
allow-clear
placeholder="可选,如:/pages/goods/list?categoryId=1"
v-model:value="form.path"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="0">正常</a-radio>
@@ -286,6 +294,7 @@
image: undefined,
adImage: undefined,
parentId: undefined,
path: undefined,
sortNumber: 100,
comments: undefined,
status: 0
+2 -2
View File
@@ -38,7 +38,7 @@
<img
v-if="record.adImage"
:src="getCompressedImageUrl(record.adImage, { width: 144, quality: 85 })"
style="width: 72px; height: 40px; object-fit: cover; border-radius: 4px;"
style="max-width: 56px; height: 28px; object-fit: cover; border-radius: 4px;"
/>
<span v-else>-</span>
</template>
@@ -176,7 +176,7 @@
const depth = depthMap.value.get(record.categoryId || 0) || 0;
if (depth === 0) return '';
const W = 20;
const H = 40;
const H = 20;
const mid = H / 2;
// 回溯祖先链:chain[0]=顶级 ... chain[depth]=当前节点
const chain: number[] = [];
@@ -0,0 +1,163 @@
<!-- 白名单用户多选弹窗 -->
<template>
<ele-modal
:width="820"
:visible="visible"
:maskClosable="false"
title="设置专区白名单用户"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="confirm"
>
<ele-pro-table
ref="tableRef"
row-key="userId"
:datasource="datasource"
:columns="columns"
:row-selection="rowSelection"
:pagination="true"
:page-size="10"
>
<template #toolbar>
<a-space>
<a-input-search
allow-clear
v-model:value="searchText"
placeholder="姓名/手机号/昵称"
style="width: 220px"
@search="reload"
@pressEnter="reload"
/>
</a-space>
</template>
</ele-pro-table>
<div class="mt-2 ele-text-secondary">
已选 {{ selectedRowKeys.length }} 人
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import {
ColumnItem,
DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types';
import { pageUsers } from '@/api/system/user';
import { EleProTable } from 'ele-admin-pro';
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 专区ID
sectionId?: number;
// 已选用户ID
selectedUserIds?: number[];
}>();
const emit = defineEmits<{
(e: 'confirm', userIds: number[]): void;
(e: 'update:visible', visible: boolean): void;
}>();
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 搜索内容
const searchText = ref<string>('');
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'userId',
key: 'userId',
align: 'center',
width: 80
},
{
title: '姓名',
dataIndex: 'realName',
key: 'realName',
align: 'center'
},
{
title: '手机号码',
dataIndex: 'mobile',
key: 'mobile',
align: 'center'
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
align: 'center'
},
{
title: '所属部门',
dataIndex: 'organizationName',
key: 'organizationName',
align: 'center'
}
]);
// 已选用户ID
const selectedRowKeys = ref<number[]>([]);
// 多选配置(与 shopCoupon 一致:computed + columnWidth)
const rowSelection = computed(() => ({
columnWidth: 48,
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: (string | number)[]) => {
selectedRowKeys.value = keys.map((k) => Number(k));
}
}));
// 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
if (searchText.value) {
where.keywords = searchText.value;
}
return pageUsers({
keywords: searchText.value,
...where,
...orders,
page,
limit
});
};
/* 搜索 */
const reload = () => {
tableRef?.value?.reload({ page: 1 });
};
/* 确认保存 */
const confirm = () => {
emit('confirm', [...selectedRowKeys.value]);
updateVisible(false);
};
// 打开弹窗时初始化已选
watch(
() => props.visible,
(visible) => {
if (visible) {
selectedRowKeys.value = props.selectedUserIds
? [...props.selectedUserIds]
: [];
if (tableRef.value) {
// 等待表格挂载后刷新
setTimeout(() => reload(), 0);
}
}
},
{ immediate: true }
);
</script>
<style lang="less"></style>
@@ -0,0 +1,251 @@
<!-- 首页专区编辑弹窗 -->
<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="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,
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>
+497
View File
@@ -0,0 +1,497 @@
<!-- 首页专区管理 -->
<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: 96, 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-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"
:selectedUserIds="currentUserIds"
@confirm="onSaveUsers"
/>
<!-- 专区商品抽屉 -->
<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="{
total: goodsTotal,
current: goodsPage,
pageSize: 10,
onChange: (page: number) => {
goodsPage = page;
loadGoods(currentSectionId);
}
}"
:scroll="{ y: 380 }"
size="small"
>
<template #bodyCell="{ column, text, record }">
<template v-if="column.key === 'image'">
<img
v-if="text"
:src="text"
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>
</div>
</template>
<script lang="ts" setup>
import { ref } 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 { getCompressedImageUrl } from '@/utils/image';
import {
pageHomeSections,
removeHomeSection,
updateHomeSection,
listSectionUsers,
setSectionUsers,
listSectionGoods,
addSectionGoods,
removeSectionGoods
} 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 currentUserIds = ref<number[]>([]);
// 商品抽屉
const showGoods = ref(false);
const goodsLoading = ref(false);
const goodsList = ref<any[]>([]);
const goodsTotal = ref(0);
const goodsPage = ref(1);
// 商品搜索/添加
const goodsSearchText = ref('');
const goodsSearchLoading = ref(false);
const candidateGoods = ref<any[]>([]);
// 表格数据源
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: 'createTime',
key: 'createTime',
align: 'center',
width: 120,
sorter: true,
customRender: ({ text }: any) => toDateString(text, 'yyyy-MM-dd')
},
{
title: '操作',
key: 'action',
width: 220,
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;
listSectionUsers(row.sectionId || 0)
.then((list) => {
currentUserIds.value = (list || []).map((u) => u.userId || 0).filter(Boolean);
showUser.value = true;
})
.catch((e) => {
message.error(e.message);
});
};
/* 保存白名单 */
const onSaveUsers = (userIds: number[]) => {
setSectionUsers(currentSectionId.value, userIds)
.then((msg) => {
message.success(msg || '白名单已保存');
showUser.value = false;
})
.catch((e) => {
message.error(e.message);
});
};
/* 打开商品抽屉 */
const openGoods = (row: HomeSection) => {
currentSectionId.value = row.sectionId || 0;
goodsPage.value = 1;
goodsSearchText.value = '';
candidateGoods.value = [];
showGoods.value = true;
loadGoods(row.sectionId || 0);
};
const loadGoods = (sectionId: number) => {
goodsLoading.value = true;
listSectionGoods(sectionId, { page: goodsPage.value, limit: 10 })
.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>