feat(special-zone): 实现专区商品管理功能
- 新增添加商品到专区接口 addSectionGoods - 新增从专区移除商品接口 removeSectionGoods - 商品抽屉宽度调整并改造为支持搜索添加和移除商品 - 增加商品搜索输入框及搜索按钮,支持关键词搜索商品 - 搜索结果列表添加单条商品添加按钮和批量添加确认按钮 - 已关联商品列表新增移除商品操作按钮及确认弹窗 - 调整商品列表图片大小及表格滚动高度,提高界面整洁度 - 专区商品数据加载添加搜索结果过滤逻辑,避免重复添加 - 用户白名单弹窗移除 isStaff 限制,允许所有用户搜索 - 修复白名单用户选择框无法显示缺失勾选列宽度问题 - 采用 computed 管理多选配置及已选用户状态,提升响应性
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
VITE_APP_NAME=后台管理系统
|
VITE_APP_NAME=后台管理系统
|
||||||
#VITE_API_URL=http://127.0.0.1:9200/api
|
VITE_API_URL=http://127.0.0.1:9200/api
|
||||||
VITE_API_URL=https://shop-api.websoft.top/api
|
#VITE_API_URL=https://shop-api.websoft.top/api
|
||||||
VITE_CMS_API_URL=https://cms-api.websoft.top/api
|
VITE_CMS_API_URL=https://cms-api.websoft.top/api
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-08-15 工作日志
|
||||||
|
|
||||||
|
## 专区功能(ShopHomeSection)接口报错修复
|
||||||
|
|
||||||
|
- 现象:访问专区列表接口报 `NoSuchPropertyException: ShopHomeSectionParam.deleted`。
|
||||||
|
- 根因:模板生成的 `ShopHomeSectionParam`(guilixu-java `src/main/java/com/gxwebsoft/shop/param/ShopHomeSectionParam.java`)**漏了 `deleted` 字段**;同项目 Mapper XML(`ShopHomeSectionMapper.xml`)按模板带 `<if test="param.deleted == null"> AND a.deleted = 0</if>` 逻辑删除过滤,OGNL 求值时属性不存在即抛异常。
|
||||||
|
- 对比:`ShopGoodsParam` 有 `deleted` 字段(@QueryField EQ + @Schema),为标准写法;`ShopHomeSection` 实体本身有 `@TableLogic private Integer deleted;`,表启用逻辑删除。
|
||||||
|
- 修复:给 `ShopHomeSectionParam` 末尾补 `private Integer deleted;`(带 @QueryField + @Schema),与模板一致。
|
||||||
|
- 验证:重新编译部署 guilixu-java(端口 9200 本地实例)后接口恢复正常。
|
||||||
|
- ⚠️ 同类预警:其他新生成的 Param 若同样漏 `deleted`,会报一模一样的错,补字段即可。
|
||||||
|
|
||||||
|
## 专区功能菜单未显示(待办)
|
||||||
|
|
||||||
|
- 前端页面 `src/views/special/zone/index.vue` 与 API 层 `src/api/shop/shopZone/` 已完成。
|
||||||
|
- 但因 guilixu-admin 为动态路由(菜单由后端接口拉取),后端菜单表尚未插入「专区管理」记录,故左侧导航看不到入口。
|
||||||
|
- 待处理:在后端菜单表(sys_menu 类)插入菜单记录(component=special/zone,tenant_id=10606),刷新/重登后生效。
|
||||||
|
|
||||||
|
## 专区功能交互入口约定(答疑结论)
|
||||||
|
|
||||||
|
- 白名单用户:专区列表每行「白名单」按钮 → 弹窗勾选用户 → 确定(PUT /{id}/users 覆盖式保存到 shop_home_section_user 表)。前端 UserSelectModal 链路完整。
|
||||||
|
- 商品关联:专区页「商品」按钮只是**查看**该专区下商品(GET /{id}/goods,后端用 FIND_IN_SET(section_id, section_ids) 查询);**往专区加商品不在此页操作**,要去「商品管理 → 编辑商品 → 所属专区(多选)」勾选专区并保存,写入 goods.section_ids。
|
||||||
|
- 后端接口齐全:users/goods 查询与设置、check-permission 权限校验均已实现。
|
||||||
|
- ⚠️ 待确认:白名单弹窗当前按 isStaff=true 查后台员工用户(@/api/system/user 的 pageUsers);若受限专区面向小程序买家,需调整用户来源。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 2026-08-16 工作日志
|
||||||
|
|
||||||
|
## 专区白名单用户弹窗修复
|
||||||
|
|
||||||
|
### 1. 去掉 isStaff 限制
|
||||||
|
- 文件:`src/views/special/zone/components/UserSelectModal.vue`
|
||||||
|
- 改动:`pageUsers` 调用移除 `isStaff: true` 参数,使白名单搜索覆盖全部用户(不再只查后台员工)。
|
||||||
|
|
||||||
|
### 2. 修复勾选框不显示
|
||||||
|
- 现象:弹窗表格数据正常但无 checkbox 勾选列,用户无法选择。
|
||||||
|
- 根因:`rowSelection` 用 `reactive()` 包整个对象的方式在 `ele-pro-table` 中不生效;且缺少 `columnWidth` 导致勾选列无宽度不渲染。
|
||||||
|
- 对比:项目内 `shopCoupon/index.vue` 用 `computed(() => ({ columnWidth, selectedRowKeys, onChange }))` 模式可正常工作;字典页用 `{ columnWidth: 32 }` 最简写法也可。
|
||||||
|
- 修复:
|
||||||
|
- `reactive` → `computed` 返回 rowSelection 对象(与 shopCoupon 一致)
|
||||||
|
- 补 `columnWidth: 48`
|
||||||
|
- `selectedRowKeys` 独立为 `ref<number[]>`,通过 computed 注入 rowSelection
|
||||||
|
- 所有引用点同步更新(模板计数、confirm、watch 初始化)
|
||||||
|
|
||||||
|
## 专区商品抽屉改造为可编辑
|
||||||
|
|
||||||
|
- 需求:专区「商品」按钮打开的抽屉从只读列表升级为支持添加/移除商品。
|
||||||
|
- 后端(guilixu-java `ShopHomeSectionController.java`)新增两个接口:
|
||||||
|
- `POST /{id}/goods` — 添加商品到专区(接收 goodsId[],将 section_id 追加到每个 goods 的 section_ids 逗号串中,用 LinkedHashSet 去重)
|
||||||
|
- `DELETE /{id}/goods` — 从专区移除商品(接收 goodsId[],从 section_ids 中移除该 section_id,空则置 null)
|
||||||
|
- 前端 API(`src/api/shop/shopZone/index.ts`)新增 `addSectionGoods`、`removeSectionGoods`。
|
||||||
|
- 前端 UI(`src/views/special/zone/index.vue`)商品抽屉改造:
|
||||||
|
- 顶部:搜索框 + 「搜索」按钮 + 「全部添加(N)」确认按钮
|
||||||
|
- 搜索结果区:虚线框展示候选商品列表,每行有「+添加」单条操作(自动过滤已在专区内的商品)
|
||||||
|
- 已关联商品表格:每行加「移除」按钮(二次确认后调用 remove 接口)
|
||||||
|
- 数据源:搜索商品复用已有 `pageShopGoods` API(@/api/shop/shopGoods)
|
||||||
@@ -141,6 +141,34 @@ export async function listSectionGoods(
|
|||||||
return Promise.reject(new Error(res.data.message));
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验专区访问权限
|
* 校验专区访问权限
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</ele-pro-table>
|
</ele-pro-table>
|
||||||
<div class="mt-2 ele-text-secondary">
|
<div class="mt-2 ele-text-secondary">
|
||||||
已选 {{ rowSelection.selectedRowKeys.length }} 人
|
已选 {{ selectedRowKeys.length }} 人
|
||||||
</div>
|
</div>
|
||||||
</ele-modal>
|
</ele-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
ColumnItem,
|
ColumnItem,
|
||||||
DatasourceFunction
|
DatasourceFunction
|
||||||
@@ -106,14 +106,17 @@
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 多选配置
|
// 已选用户ID
|
||||||
const rowSelection = reactive({
|
const selectedRowKeys = ref<number[]>([]);
|
||||||
type: 'checkbox' as const,
|
|
||||||
selectedRowKeys: [] as number[],
|
// 多选配置(与 shopCoupon 一致:computed + columnWidth)
|
||||||
|
const rowSelection = computed(() => ({
|
||||||
|
columnWidth: 48,
|
||||||
|
selectedRowKeys: selectedRowKeys.value,
|
||||||
onChange: (keys: (string | number)[]) => {
|
onChange: (keys: (string | number)[]) => {
|
||||||
rowSelection.selectedRowKeys = keys.map((k) => Number(k));
|
selectedRowKeys.value = keys.map((k) => Number(k));
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
|
|
||||||
// 表格数据源
|
// 表格数据源
|
||||||
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
||||||
@@ -121,7 +124,6 @@
|
|||||||
where.keywords = searchText.value;
|
where.keywords = searchText.value;
|
||||||
}
|
}
|
||||||
return pageUsers({
|
return pageUsers({
|
||||||
isStaff: true,
|
|
||||||
keywords: searchText.value,
|
keywords: searchText.value,
|
||||||
...where,
|
...where,
|
||||||
...orders,
|
...orders,
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
|
|
||||||
/* 确认保存 */
|
/* 确认保存 */
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
emit('confirm', [...rowSelection.selectedRowKeys]);
|
emit('confirm', [...selectedRowKeys.value]);
|
||||||
updateVisible(false);
|
updateVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@
|
|||||||
() => props.visible,
|
() => props.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
rowSelection.selectedRowKeys = props.selectedUserIds
|
selectedRowKeys.value = props.selectedUserIds
|
||||||
? [...props.selectedUserIds]
|
? [...props.selectedUserIds]
|
||||||
: [];
|
: [];
|
||||||
if (tableRef.value) {
|
if (tableRef.value) {
|
||||||
|
|||||||
@@ -95,14 +95,52 @@
|
|||||||
|
|
||||||
<!-- 专区商品抽屉 -->
|
<!-- 专区商品抽屉 -->
|
||||||
<a-drawer
|
<a-drawer
|
||||||
:width="800"
|
:width="860"
|
||||||
:visible="showGoods"
|
:visible="showGoods"
|
||||||
title="专区商品"
|
title="专区商品"
|
||||||
:maskClosable="false"
|
:maskClosable="false"
|
||||||
@update:visible="(v: boolean) => (showGoods = v)"
|
@update:visible="(v: boolean) => (showGoods = v)"
|
||||||
>
|
>
|
||||||
<a-empty v-if="goodsLoading" description="加载中..." />
|
<!-- 添加商品区域 -->
|
||||||
<template v-else>
|
<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
|
<a-table
|
||||||
row-key="goodsId"
|
row-key="goodsId"
|
||||||
:dataSource="goodsList"
|
:dataSource="goodsList"
|
||||||
@@ -116,20 +154,26 @@
|
|||||||
loadGoods(currentSectionId);
|
loadGoods(currentSectionId);
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
:scroll="{ y: 480 }"
|
:scroll="{ y: 380 }"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, text }">
|
<template #bodyCell="{ column, text, record }">
|
||||||
<template v-if="column.key === 'image'">
|
<template v-if="column.key === 'image'">
|
||||||
<img
|
<img
|
||||||
v-if="text"
|
v-if="text"
|
||||||
:src="text"
|
:src="text"
|
||||||
style="width: 48px; height: 48px; object-fit: cover; border-radius: 4px;"
|
style="width: 40px; height: 40px; object-fit: cover; border-radius: 4px;"
|
||||||
/>
|
/>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</template>
|
</a-spin>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,8 +197,11 @@
|
|||||||
updateHomeSection,
|
updateHomeSection,
|
||||||
listSectionUsers,
|
listSectionUsers,
|
||||||
setSectionUsers,
|
setSectionUsers,
|
||||||
listSectionGoods
|
listSectionGoods,
|
||||||
|
addSectionGoods,
|
||||||
|
removeSectionGoods
|
||||||
} from '@/api/shop/shopZone';
|
} from '@/api/shop/shopZone';
|
||||||
|
import { pageShopGoods } from '@/api/shop/shopGoods';
|
||||||
import type {
|
import type {
|
||||||
HomeSection,
|
HomeSection,
|
||||||
HomeSectionParam
|
HomeSectionParam
|
||||||
@@ -179,6 +226,10 @@
|
|||||||
const goodsList = ref<any[]>([]);
|
const goodsList = ref<any[]>([]);
|
||||||
const goodsTotal = ref(0);
|
const goodsTotal = ref(0);
|
||||||
const goodsPage = ref(1);
|
const goodsPage = ref(1);
|
||||||
|
// 商品搜索/添加
|
||||||
|
const goodsSearchText = ref('');
|
||||||
|
const goodsSearchLoading = ref(false);
|
||||||
|
const candidateGoods = ref<any[]>([]);
|
||||||
|
|
||||||
// 表格数据源
|
// 表格数据源
|
||||||
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
|
||||||
@@ -265,10 +316,11 @@
|
|||||||
dataIndex: 'image',
|
dataIndex: 'image',
|
||||||
key: 'image',
|
key: 'image',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 90
|
width: 80
|
||||||
},
|
},
|
||||||
{ title: '商城价', dataIndex: 'price', key: 'price', align: 'center', width: 100 },
|
{ title: '商城价', dataIndex: 'price', key: 'price', align: 'center', width: 90 },
|
||||||
{ title: '库存', dataIndex: 'stock', key: 'stock', align: 'center', width: 90 }
|
{ title: '库存', dataIndex: 'stock', key: 'stock', align: 'center', width: 80 },
|
||||||
|
{ title: '操作', key: 'action', align: 'center', width: 80 }
|
||||||
] as any[];
|
] as any[];
|
||||||
|
|
||||||
/* 搜索条件 */
|
/* 搜索条件 */
|
||||||
@@ -350,6 +402,8 @@
|
|||||||
const openGoods = (row: HomeSection) => {
|
const openGoods = (row: HomeSection) => {
|
||||||
currentSectionId.value = row.sectionId || 0;
|
currentSectionId.value = row.sectionId || 0;
|
||||||
goodsPage.value = 1;
|
goodsPage.value = 1;
|
||||||
|
goodsSearchText.value = '';
|
||||||
|
candidateGoods.value = [];
|
||||||
showGoods.value = true;
|
showGoods.value = true;
|
||||||
loadGoods(row.sectionId || 0);
|
loadGoods(row.sectionId || 0);
|
||||||
};
|
};
|
||||||
@@ -369,6 +423,69 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* 搜索可添加的商品 */
|
||||||
|
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) => {
|
const customRow = (record: HomeSection) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user