feat(special-zone): 实现专区商品管理功能

- 新增添加商品到专区接口 addSectionGoods
- 新增从专区移除商品接口 removeSectionGoods
- 商品抽屉宽度调整并改造为支持搜索添加和移除商品
- 增加商品搜索输入框及搜索按钮,支持关键词搜索商品
- 搜索结果列表添加单条商品添加按钮和批量添加确认按钮
- 已关联商品列表新增移除商品操作按钮及确认弹窗
- 调整商品列表图片大小及表格滚动高度,提高界面整洁度
- 专区商品数据加载添加搜索结果过滤逻辑,避免重复添加
- 用户白名单弹窗移除 isStaff 限制,允许所有用户搜索
- 修复白名单用户选择框无法显示缺失勾选列宽度问题
- 采用 computed 管理多选配置及已选用户状态,提升响应性
This commit is contained in:
2026-08-17 01:14:16 +08:00
parent b5c67988a6
commit 4a7b217c2b
6 changed files with 224 additions and 24 deletions
+28
View File
@@ -141,6 +141,34 @@ export async function listSectionGoods(
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>
</ele-pro-table>
<div class="mt-2 ele-text-secondary">
已选 {{ rowSelection.selectedRowKeys.length }}
已选 {{ selectedRowKeys.length }}
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import {
ColumnItem,
DatasourceFunction
@@ -106,14 +106,17 @@
}
]);
// 多选配置
const rowSelection = reactive({
type: 'checkbox' as const,
selectedRowKeys: [] as number[],
// 已选用户ID
const selectedRowKeys = ref<number[]>([]);
// 多选配置(与 shopCoupon 一致:computed + columnWidth
const rowSelection = computed(() => ({
columnWidth: 48,
selectedRowKeys: selectedRowKeys.value,
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 }) => {
@@ -121,7 +124,6 @@
where.keywords = searchText.value;
}
return pageUsers({
isStaff: true,
keywords: searchText.value,
...where,
...orders,
@@ -137,7 +139,7 @@
/* 确认保存 */
const confirm = () => {
emit('confirm', [...rowSelection.selectedRowKeys]);
emit('confirm', [...selectedRowKeys.value]);
updateVisible(false);
};
@@ -146,7 +148,7 @@
() => props.visible,
(visible) => {
if (visible) {
rowSelection.selectedRowKeys = props.selectedUserIds
selectedRowKeys.value = props.selectedUserIds
? [...props.selectedUserIds]
: [];
if (tableRef.value) {
+128 -11
View File
@@ -95,14 +95,52 @@
<!-- 专区商品抽屉 -->
<a-drawer
:width="800"
:width="860"
:visible="showGoods"
title="专区商品"
:maskClosable="false"
@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
row-key="goodsId"
:dataSource="goodsList"
@@ -116,20 +154,26 @@
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'">
<img
v-if="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>
</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>
</template>
</a-spin>
</a-drawer>
</div>
</template>
@@ -153,8 +197,11 @@
updateHomeSection,
listSectionUsers,
setSectionUsers,
listSectionGoods
listSectionGoods,
addSectionGoods,
removeSectionGoods
} from '@/api/shop/shopZone';
import { pageShopGoods } from '@/api/shop/shopGoods';
import type {
HomeSection,
HomeSectionParam
@@ -179,6 +226,10 @@
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 }) => {
@@ -265,10 +316,11 @@
dataIndex: 'image',
key: 'image',
align: 'center',
width: 90
width: 80
},
{ title: '商城价', dataIndex: 'price', key: 'price', align: 'center', width: 100 },
{ title: '库存', dataIndex: 'stock', key: 'stock', align: 'center', width: 90 }
{ 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[];
/* 搜索条件 */
@@ -350,6 +402,8 @@
const openGoods = (row: HomeSection) => {
currentSectionId.value = row.sectionId || 0;
goodsPage.value = 1;
goodsSearchText.value = '';
candidateGoods.value = [];
showGoods.value = true;
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) => {
return {