feat(project): sync all pending tenant ui changes

This commit is contained in:
2026-02-20 16:50:20 +08:00
parent 937c9b4334
commit 788491ad3d
34 changed files with 7527 additions and 95 deletions

View File

@@ -1,7 +1,7 @@
/**
* 文件职责:商品管理模块 API 与 DTO 定义。
* 1. 维护商品列表、分类、详情、批量操作契约。
* 2. 提供商品查询、保存、状态变更、沽清与批量动作接口。
* 2. 提供分类、规格、加料、标签、时段供应等扩展模块接口。
*/
import type { PaginatedResult } from '#/api/store';
@@ -16,7 +16,32 @@ export type ProductKind = 'combo' | 'single';
/** 沽清模式。 */
export type ProductSoldoutMode = 'permanent' | 'timed' | 'today';
/** 分类信息。 */
/** 分类展示渠道。 */
export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm';
/** 通用启停状态。 */
export type ProductSwitchStatus = 'disabled' | 'enabled';
/** 商品选择器项。 */
export interface ProductPickerItemDto {
categoryId: string;
categoryName: string;
id: string;
name: string;
price: number;
spuCode: string;
status: ProductStatus;
}
/** 商品选择器查询参数。 */
export interface ProductPickerQuery {
categoryId?: string;
keyword?: string;
limit?: number;
storeId: string;
}
/** 分类信息(列表页侧栏)。 */
export interface ProductCategoryDto {
id: string;
name: string;
@@ -24,6 +49,72 @@ export interface ProductCategoryDto {
sort: number;
}
/** 分类管理项。 */
export interface ProductCategoryManageDto extends ProductCategoryDto {
channels: ProductCategoryChannel[];
description: string;
icon: string;
status: ProductSwitchStatus;
}
/** 分类管理查询参数。 */
export interface ProductCategoryManageQuery {
keyword?: string;
status?: ProductSwitchStatus;
storeId: string;
}
/** 保存分类参数。 */
export interface SaveProductCategoryDto {
channels: ProductCategoryChannel[];
description: string;
icon: string;
id?: string;
name: string;
sort: number;
status: ProductSwitchStatus;
storeId: string;
}
/** 删除分类参数。 */
export interface DeleteProductCategoryDto {
categoryId: string;
storeId: string;
}
/** 分类状态变更参数。 */
export interface ChangeProductCategoryStatusDto {
categoryId: string;
status: ProductSwitchStatus;
storeId: string;
}
/** 分类排序项。 */
export interface ProductCategorySortItemDto {
categoryId: string;
sort: number;
}
/** 分类排序参数。 */
export interface SortProductCategoryDto {
items: ProductCategorySortItemDto[];
storeId: string;
}
/** 分类绑定商品参数。 */
export interface BindCategoryProductsDto {
categoryId: string;
productIds: string[];
storeId: string;
}
/** 分类解绑商品参数。 */
export interface UnbindCategoryProductDto {
categoryId: string;
productId: string;
storeId: string;
}
/** 商品列表项。 */
export interface ProductListItemDto {
categoryId: string;
@@ -141,13 +232,346 @@ export interface BatchProductActionResultDto {
totalCount: number;
}
/** 获取商品分类。 */
/** 规格值。 */
export interface ProductSpecValueDto {
extraPrice: number;
id: string;
name: string;
sort: number;
}
/** 规格配置。 */
export interface ProductSpecDto {
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
values: ProductSpecValueDto[];
}
/** 规格查询参数。 */
export interface ProductSpecQuery {
keyword?: string;
status?: ProductSwitchStatus;
storeId: string;
}
/** 保存规格参数。 */
export interface SaveProductSpecDto {
description: string;
id?: string;
name: string;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
storeId: string;
values: Array<{
extraPrice: number;
id?: string;
name: string;
sort: number;
}>;
}
/** 删除规格参数。 */
export interface DeleteProductSpecDto {
specId: string;
storeId: string;
}
/** 规格状态变更参数。 */
export interface ChangeProductSpecStatusDto {
specId: string;
status: ProductSwitchStatus;
storeId: string;
}
/** 加料项。 */
export interface ProductAddonItemDto {
id: string;
name: string;
price: number;
sort: number;
status: ProductSwitchStatus;
}
/** 加料组。 */
export interface ProductAddonGroupDto {
description: string;
id: string;
items: ProductAddonItemDto[];
maxSelect: number;
minSelect: number;
name: string;
productCount: number;
productIds: string[];
required: boolean;
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
/** 加料组查询参数。 */
export interface ProductAddonQuery {
keyword?: string;
status?: ProductSwitchStatus;
storeId: string;
}
/** 保存加料组参数。 */
export interface SaveProductAddonGroupDto {
description: string;
id?: string;
items: Array<{
id?: string;
name: string;
price: number;
sort: number;
status: ProductSwitchStatus;
}>;
maxSelect: number;
minSelect: number;
name: string;
productIds: string[];
required: boolean;
sort: number;
status: ProductSwitchStatus;
storeId: string;
}
/** 删除加料组参数。 */
export interface DeleteProductAddonGroupDto {
groupId: string;
storeId: string;
}
/** 加料组状态变更参数。 */
export interface ChangeProductAddonGroupStatusDto {
groupId: string;
status: ProductSwitchStatus;
storeId: string;
}
/** 商品标签。 */
export interface ProductLabelDto {
color: string;
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
/** 标签查询参数。 */
export interface ProductLabelQuery {
keyword?: string;
status?: ProductSwitchStatus;
storeId: string;
}
/** 保存标签参数。 */
export interface SaveProductLabelDto {
color: string;
description: string;
id?: string;
name: string;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
storeId: string;
}
/** 删除标签参数。 */
export interface DeleteProductLabelDto {
labelId: string;
storeId: string;
}
/** 标签状态变更参数。 */
export interface ChangeProductLabelStatusDto {
labelId: string;
status: ProductSwitchStatus;
storeId: string;
}
/** 时段条目。 */
export interface ProductScheduleSlotDto {
endTime: string;
id: string;
startTime: string;
weekDays: number[];
}
/** 时段模板。 */
export interface ProductScheduleDto {
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
slots: ProductScheduleSlotDto[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
/** 时段查询参数。 */
export interface ProductScheduleQuery {
keyword?: string;
status?: ProductSwitchStatus;
storeId: string;
}
/** 保存时段参数。 */
export interface SaveProductScheduleDto {
description: string;
id?: string;
name: string;
productIds: string[];
slots: Array<{
endTime: string;
id?: string;
startTime: string;
weekDays: number[];
}>;
sort: number;
status: ProductSwitchStatus;
storeId: string;
}
/** 删除时段参数。 */
export interface DeleteProductScheduleDto {
scheduleId: string;
storeId: string;
}
/** 时段状态变更参数。 */
export interface ChangeProductScheduleStatusDto {
scheduleId: string;
status: ProductSwitchStatus;
storeId: string;
}
/** 批量范围。 */
export interface ProductBatchScopeDto {
categoryId?: string;
productIds?: string[];
type: 'all' | 'category' | 'selected';
}
/** 批量调价参数。 */
export interface ProductBatchAdjustPriceDto {
amount: number;
mode: 'decrease' | 'increase' | 'set';
scope: ProductBatchScopeDto;
storeId: string;
}
/** 批量移动分类参数。 */
export interface ProductBatchMoveCategoryDto {
scope: ProductBatchScopeDto;
storeId: string;
targetCategoryId: string;
}
/** 批量上下架参数。 */
export interface ProductBatchSaleSwitchDto {
action: 'off' | 'on';
scope: ProductBatchScopeDto;
storeId: string;
}
/** 批量同步门店参数。 */
export interface ProductBatchSyncStoreDto {
productIds: string[];
sourceStoreId: string;
targetStoreIds: string[];
}
/** 批量工具通用结果。 */
export interface ProductBatchToolResultDto {
failedCount: number;
successCount: number;
totalCount: number;
}
/** 导入导出请求参数。 */
export interface ProductBatchImportExportDto {
scope: ProductBatchScopeDto;
storeId: string;
}
/** 导入导出回执。 */
export interface ProductBatchImportExportResultDto {
exportedCount?: number;
failedCount: number;
fileName: string;
skippedCount?: number;
successCount: number;
totalCount: number;
}
/** 获取商品分类(侧栏口径)。 */
export async function getProductCategoryListApi(storeId: string) {
return requestClient.get<ProductCategoryDto[]>('/product/category/list', {
params: { storeId },
});
}
/** 获取分类管理列表。 */
export async function getProductCategoryManageListApi(
params: ProductCategoryManageQuery,
) {
return requestClient.get<ProductCategoryManageDto[]>(
'/product/category/manage/list',
{
params,
},
);
}
/** 保存分类。 */
export async function saveProductCategoryApi(data: SaveProductCategoryDto) {
return requestClient.post<ProductCategoryManageDto>(
'/product/category/manage/save',
data,
);
}
/** 删除分类。 */
export async function deleteProductCategoryApi(data: DeleteProductCategoryDto) {
return requestClient.post('/product/category/manage/delete', data);
}
/** 修改分类状态。 */
export async function changeProductCategoryStatusApi(
data: ChangeProductCategoryStatusDto,
) {
return requestClient.post('/product/category/manage/status', data);
}
/** 批量排序分类。 */
export async function sortProductCategoryApi(data: SortProductCategoryDto) {
return requestClient.post('/product/category/manage/sort', data);
}
/** 将商品绑定到分类。 */
export async function bindCategoryProductsApi(data: BindCategoryProductsDto) {
return requestClient.post('/product/category/manage/products/bind', data);
}
/** 从分类解绑单个商品。 */
export async function unbindCategoryProductApi(data: UnbindCategoryProductDto) {
return requestClient.post('/product/category/manage/products/unbind', data);
}
/** 获取商品列表。 */
export async function getProductListApi(params: ProductListQuery) {
return requestClient.get<PaginatedResult<ProductListItemDto>>(
@@ -185,10 +609,175 @@ export async function soldoutProductApi(data: SoldoutProductDto) {
return requestClient.post('/product/soldout', data);
}
/** 批量商品操作。 */
/** 批量商品操作(列表页)。 */
export async function batchProductActionApi(data: BatchProductActionDto) {
return requestClient.post<BatchProductActionResultDto>(
'/product/batch',
data,
);
}
/** 获取规格列表。 */
export async function getProductSpecListApi(params: ProductSpecQuery) {
return requestClient.get<ProductSpecDto[]>('/product/spec/list', {
params,
});
}
/** 保存规格。 */
export async function saveProductSpecApi(data: SaveProductSpecDto) {
return requestClient.post<ProductSpecDto>('/product/spec/save', data);
}
/** 删除规格。 */
export async function deleteProductSpecApi(data: DeleteProductSpecDto) {
return requestClient.post('/product/spec/delete', data);
}
/** 修改规格状态。 */
export async function changeProductSpecStatusApi(
data: ChangeProductSpecStatusDto,
) {
return requestClient.post('/product/spec/status', data);
}
/** 获取加料组列表。 */
export async function getProductAddonGroupListApi(params: ProductAddonQuery) {
return requestClient.get<ProductAddonGroupDto[]>(
'/product/addon/group/list',
{
params,
},
);
}
/** 保存加料组。 */
export async function saveProductAddonGroupApi(data: SaveProductAddonGroupDto) {
return requestClient.post<ProductAddonGroupDto>(
'/product/addon/group/save',
data,
);
}
/** 删除加料组。 */
export async function deleteProductAddonGroupApi(
data: DeleteProductAddonGroupDto,
) {
return requestClient.post('/product/addon/group/delete', data);
}
/** 修改加料组状态。 */
export async function changeProductAddonGroupStatusApi(
data: ChangeProductAddonGroupStatusDto,
) {
return requestClient.post('/product/addon/group/status', data);
}
/** 获取标签列表。 */
export async function getProductLabelListApi(params: ProductLabelQuery) {
return requestClient.get<ProductLabelDto[]>('/product/label/list', {
params,
});
}
/** 保存标签。 */
export async function saveProductLabelApi(data: SaveProductLabelDto) {
return requestClient.post<ProductLabelDto>('/product/label/save', data);
}
/** 删除标签。 */
export async function deleteProductLabelApi(data: DeleteProductLabelDto) {
return requestClient.post('/product/label/delete', data);
}
/** 修改标签状态。 */
export async function changeProductLabelStatusApi(
data: ChangeProductLabelStatusDto,
) {
return requestClient.post('/product/label/status', data);
}
/** 获取时段列表。 */
export async function getProductScheduleListApi(params: ProductScheduleQuery) {
return requestClient.get<ProductScheduleDto[]>('/product/schedule/list', {
params,
});
}
/** 保存时段。 */
export async function saveProductScheduleApi(data: SaveProductScheduleDto) {
return requestClient.post<ProductScheduleDto>('/product/schedule/save', data);
}
/** 删除时段。 */
export async function deleteProductScheduleApi(data: DeleteProductScheduleDto) {
return requestClient.post('/product/schedule/delete', data);
}
/** 修改时段状态。 */
export async function changeProductScheduleStatusApi(
data: ChangeProductScheduleStatusDto,
) {
return requestClient.post('/product/schedule/status', data);
}
/** 批量调价。 */
export async function batchAdjustProductPriceApi(
data: ProductBatchAdjustPriceDto,
) {
return requestClient.post<ProductBatchToolResultDto>(
'/product/batch/price-adjust',
data,
);
}
/** 批量移动分类。 */
export async function batchMoveProductCategoryApi(
data: ProductBatchMoveCategoryDto,
) {
return requestClient.post<ProductBatchToolResultDto>(
'/product/batch/move-category',
data,
);
}
/** 批量上下架。 */
export async function batchSwitchProductSaleApi(
data: ProductBatchSaleSwitchDto,
) {
return requestClient.post<ProductBatchToolResultDto>(
'/product/batch/sale-switch',
data,
);
}
/** 批量同步门店。 */
export async function batchSyncProductStoreApi(data: ProductBatchSyncStoreDto) {
return requestClient.post<ProductBatchToolResultDto>(
'/product/batch/store-sync',
data,
);
}
/** 批量导入。 */
export async function batchImportProductApi(data: ProductBatchImportExportDto) {
return requestClient.post<ProductBatchImportExportResultDto>(
'/product/batch/import',
data,
);
}
/** 批量导出。 */
export async function batchExportProductApi(data: ProductBatchImportExportDto) {
return requestClient.post<ProductBatchImportExportResultDto>(
'/product/batch/export',
data,
);
}
/** 商品选择器列表。 */
export async function searchProductPickerApi(params: ProductPickerQuery) {
return requestClient.get<ProductPickerItemDto[]>('/product/picker/list', {
params,
});
}

View File

@@ -1,5 +1,6 @@
// Mock 数据入口,仅在开发环境下使用
// 门店模块已切换真实 TenantApi此处仅保留其他业务的 mock。
import './product';
import './product-extensions';
console.warn('[Mock] 非门店模块 Mock 数据已启用');

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,60 @@ const routes: RouteRecordRaw[] = [
title: '商品列表',
},
},
{
name: 'ProductCategory',
path: '/product/category',
component: () => import('#/views/product/category/index.vue'),
meta: {
icon: 'lucide:folders',
title: '分类管理',
},
},
{
name: 'ProductSpecs',
path: '/product/specs',
component: () => import('#/views/product/specs/index.vue'),
meta: {
icon: 'lucide:sliders-horizontal',
title: '规格做法',
},
},
{
name: 'ProductAddons',
path: '/product/addons',
component: () => import('#/views/product/addons/index.vue'),
meta: {
icon: 'lucide:plus-square',
title: '加料管理',
},
},
{
name: 'ProductLabels',
path: '/product/labels',
component: () => import('#/views/product/labels/index.vue'),
meta: {
icon: 'lucide:tags',
title: '商品标签',
},
},
{
name: 'ProductSchedule',
path: '/product/schedule',
component: () => import('#/views/product/schedule/index.vue'),
meta: {
icon: 'lucide:calendar-clock',
title: '时段供应',
},
},
{
name: 'ProductBatchTools',
path: '/product/batch',
component: () => import('#/views/product/batch/index.vue'),
meta: {
icon: 'lucide:wand-sparkles',
title: '批量工具',
},
},
{
name: 'ProductDetail',
path: '/product/detail',

View File

@@ -0,0 +1,564 @@
<script setup lang="ts">
/**
* 文件职责:加料管理页面。
* 1. 管理加料组、加料项配置。
* 2. 管理加料组与商品关联。
*/
import type { ProductSwitchStatus } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Divider,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import {
changeProductAddonGroupStatusApi,
deleteProductAddonGroupApi,
getProductAddonGroupListApi,
saveProductAddonGroupApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
type StatusFilter = '' | ProductSwitchStatus;
interface AddonItemForm {
id: string;
name: string;
price: number;
sort: number;
status: ProductSwitchStatus;
}
interface AddonGroupRow {
description: string;
id: string;
items: AddonItemForm[];
maxSelect: number;
minSelect: number;
name: string;
productCount: number;
productIds: string[];
required: boolean;
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<AddonGroupRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingGroupId = ref('');
const form = reactive({
name: '',
description: '',
required: false,
minSelect: 0,
maxSelect: 1,
sort: 1,
status: 'enabled' as ProductSwitchStatus,
productIds: [] as string[],
items: [] as AddonItemForm[],
});
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingGroupId.value ? '编辑加料组' : '新增加料组',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载加料组列表。 */
async function loadAddonGroups() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductAddonGroupListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as AddonGroupRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载加料组失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 重置表单。 */
function resetForm() {
editingGroupId.value = '';
form.name = '';
form.description = '';
form.required = false;
form.minSelect = 0;
form.maxSelect = 1;
form.sort = rows.value.length + 1;
form.status = 'enabled';
form.productIds = [];
form.items = [
{
id: '',
name: '',
price: 0,
sort: 1,
status: 'enabled',
},
];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: AddonGroupRow) {
editingGroupId.value = row.id;
form.name = row.name;
form.description = row.description;
form.required = row.required;
form.minSelect = row.minSelect;
form.maxSelect = row.maxSelect;
form.sort = row.sort;
form.status = row.status;
form.productIds = [...row.productIds];
form.items = row.items.map((item) => ({ ...item }));
if (form.items.length === 0) {
form.items = [{ id: '', name: '', price: 0, sort: 1, status: 'enabled' }];
}
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 添加加料项。 */
function addAddonItem() {
form.items.push({
id: '',
name: '',
price: 0,
sort: form.items.length + 1,
status: 'enabled',
});
}
/** 删除加料项。 */
function removeAddonItem(index: number) {
if (form.items.length <= 1) {
message.warning('至少保留一个加料项');
return;
}
form.items.splice(index, 1);
form.items.forEach((item, idx) => {
item.sort = idx + 1;
});
}
/** 保存加料组。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入加料组名称');
return;
}
if (form.items.some((item) => !item.name.trim())) {
message.warning('加料项名称不能为空');
return;
}
if (form.maxSelect < form.minSelect) {
message.warning('最大可选数量不能小于最小可选数量');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductAddonGroupApi({
storeId: selectedStoreId.value,
id: editingGroupId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
required: form.required,
minSelect: form.minSelect,
maxSelect: form.maxSelect,
sort: form.sort,
status: form.status,
productIds: [...form.productIds],
items: form.items.map((item) => ({
id: item.id || undefined,
name: item.name.trim(),
price: item.price,
sort: item.sort,
status: item.status,
})),
});
message.success(editingGroupId.value ? '加料组已更新' : '加料组已创建');
isDrawerOpen.value = false;
await loadAddonGroups();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除加料组。 */
function removeAddonGroup(row: AddonGroupRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除加料组「${row.name}」吗?`,
async onOk() {
await deleteProductAddonGroupApi({
storeId: selectedStoreId.value,
groupId: row.id,
});
message.success('加料组已删除');
await loadAddonGroups();
},
});
}
/** 切换加料组状态。 */
async function toggleAddonStatus(row: AddonGroupRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductAddonGroupStatusApi({
storeId: selectedStoreId.value,
groupId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 重置筛选。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadAddonGroups();
}
watch(selectedStoreId, loadAddonGroups);
onMounted(loadStores);
</script>
<template>
<Page title="加料管理" content-class="space-y-4 page-product-addons">
<Card :bordered="false">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索加料组名称"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 140px"
/>
<Button type="primary" @click="loadAddonGroups">查询</Button>
<Button @click="resetFilters">重置</Button>
<Button type="primary" @click="openCreateDrawer">新增加料组</Button>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column
title="加料组名称"
data-index="name"
key="name"
:width="180"
/>
<Table.Column title="加料项" key="items">
<template #default="{ record }">
<Space wrap size="small">
<Tag v-for="item in record.items" :key="item.id || item.name">
{{ item.name }}+{{ item.price }}
</Tag>
</Space>
</template>
</Table.Column>
<Table.Column title="选择规则" key="rule" :width="160">
<template #default="{ record }">
{{ record.required ? '必选' : '非必选' }}{{ record.minSelect }}~{{
record.maxSelect
}}
</template>
</Table.Column>
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="(checked) => toggleAddonStatus(record, checked === true)"
/>
</template>
</Table.Column>
<Table.Column
title="更新时间"
data-index="updatedAt"
key="updatedAt"
:width="170"
/>
<Table.Column title="操作" key="action" :width="170">
<template #default="{ record }">
<Space size="small">
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
<Button danger size="small" @click="removeAddonGroup(record)">
删除
</Button>
</Space>
</template>
</Table.Column>
</Table>
</Card>
<Drawer
:open="isDrawerOpen"
:title="drawerTitle"
width="620"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="加料组名称" required>
<Input v-model:value="form.name" :maxlength="30" show-count />
</Form.Item>
<Form.Item label="描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Form.Item label="是否必选">
<Switch v-model:checked="form.required" />
</Form.Item>
<Space style="display: flex; width: 100%">
<Form.Item label="最少可选" style="flex: 1">
<InputNumber
v-model:value="form.minSelect"
:min="0"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="最多可选" style="flex: 1">
<InputNumber
v-model:value="form.maxSelect"
:min="1"
style="width: 100%"
/>
</Form.Item>
</Space>
<Divider orientation="left">加料项</Divider>
<div
v-for="(item, index) in form.items"
:key="index"
class="addon-item-row"
>
<Input v-model:value="item.name" placeholder="加料项名称,如:珍珠" />
<InputNumber
v-model:value="item.price"
:min="0"
:step="0.5"
style="width: 120px"
placeholder="加价"
/>
<Select
v-model:value="item.status"
style="width: 110px"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
<Button danger @click="removeAddonItem(index)">删除</Button>
</div>
<Button @click="addAddonItem">新增加料项</Button>
<Divider />
<Space style="display: flex; width: 100%">
<Form.Item label="排序" style="flex: 1">
<InputNumber
v-model:value="form.sort"
:min="1"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Space>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:加料管理页面样式。 */
.page-product-addons {
.addon-item-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
</style>

View File

@@ -0,0 +1,522 @@
<script setup lang="ts">
/**
* 文件职责:商品批量工具页面。
* 1. 提供批量调价、批量上下架、批量移动分类能力。
* 2. 提供批量同步门店、导入导出能力Mock 回执)。
*/
import type { ProductBatchScopeDto } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Alert,
Button,
Card,
Empty,
InputNumber,
message,
Select,
Space,
Tabs,
} from 'ant-design-vue';
import {
batchAdjustProductPriceApi,
batchExportProductApi,
batchImportProductApi,
batchMoveProductCategoryApi,
batchSwitchProductSaleApi,
batchSyncProductStoreApi,
getProductCategoryManageListApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
type ScopeType = 'all' | 'category' | 'selected';
interface CategoryOption {
label: string;
value: string;
}
interface ProductOption {
label: string;
value: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const isCategoryLoading = ref(false);
const categoryOptions = ref<CategoryOption[]>([]);
const productOptions = ref<ProductOption[]>([]);
const isProductLoading = ref(false);
const scopeType = ref<ScopeType>('all');
const scopeCategoryId = ref('');
const scopeProductIds = ref<string[]>([]);
const priceMode = ref<'decrease' | 'increase' | 'set'>('increase');
const priceAmount = ref(1);
const saleAction = ref<'off' | 'on'>('off');
const moveTargetCategoryId = ref('');
const syncTargetStoreIds = ref<string[]>([]);
const syncProductIds = ref<string[]>([]);
const activeTab = ref('price');
const isSubmitting = ref(false);
const latestResultText = ref('');
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const targetStoreOptions = computed(() =>
stores.value
.filter((item) => item.id !== selectedStoreId.value)
.map((item) => ({
label: item.name,
value: item.id,
})),
);
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载分类与商品选择器数据。 */
async function reloadScopeData() {
if (!selectedStoreId.value) {
categoryOptions.value = [];
productOptions.value = [];
return;
}
isCategoryLoading.value = true;
isProductLoading.value = true;
try {
const [categories, products] = await Promise.all([
getProductCategoryManageListApi({
storeId: selectedStoreId.value,
}),
searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
}),
]);
categoryOptions.value = categories.map((item) => ({
label: item.name,
value: item.id,
}));
productOptions.value = products.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
if (
moveTargetCategoryId.value &&
!categoryOptions.value.some(
(item) => item.value === moveTargetCategoryId.value,
)
) {
moveTargetCategoryId.value = '';
}
} catch (error) {
console.error(error);
categoryOptions.value = [];
productOptions.value = [];
message.error('加载批量工具数据失败');
} finally {
isCategoryLoading.value = false;
isProductLoading.value = false;
}
}
/** 构建批量范围。 */
function buildScope(): ProductBatchScopeDto {
if (scopeType.value === 'category') {
return {
type: 'category',
categoryId: scopeCategoryId.value || undefined,
};
}
if (scopeType.value === 'selected') {
return {
type: 'selected',
productIds: [...scopeProductIds.value],
};
}
return { type: 'all' };
}
/** 校验范围入参是否可用。 */
function validateScope() {
if (scopeType.value === 'category' && !scopeCategoryId.value) {
message.warning('请选择范围分类');
return false;
}
if (scopeType.value === 'selected' && scopeProductIds.value.length === 0) {
message.warning('请选择至少一个商品');
return false;
}
return true;
}
/** 批量调价。 */
async function submitPriceAdjust() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
if (priceAmount.value < 0) {
message.warning('调价金额不能小于 0');
return;
}
isSubmitting.value = true;
try {
const result = await batchAdjustProductPriceApi({
storeId: selectedStoreId.value,
scope: buildScope(),
mode: priceMode.value,
amount: priceAmount.value,
});
latestResultText.value = `调价完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量调价成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 批量上下架。 */
async function submitSaleSwitch() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
isSubmitting.value = true;
try {
const result = await batchSwitchProductSaleApi({
storeId: selectedStoreId.value,
scope: buildScope(),
action: saleAction.value,
});
latestResultText.value = `上下架完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量上下架成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 批量移动分类。 */
async function submitMoveCategory() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
if (!moveTargetCategoryId.value) {
message.warning('请选择目标分类');
return;
}
isSubmitting.value = true;
try {
const result = await batchMoveProductCategoryApi({
storeId: selectedStoreId.value,
scope: buildScope(),
targetCategoryId: moveTargetCategoryId.value,
});
latestResultText.value = `移动分类完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量移动分类成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 批量同步门店。 */
async function submitSyncStore() {
if (!selectedStoreId.value) return;
if (syncTargetStoreIds.value.length === 0) {
message.warning('请选择目标门店');
return;
}
if (syncProductIds.value.length === 0) {
message.warning('请选择要同步的商品');
return;
}
isSubmitting.value = true;
try {
const result = await batchSyncProductStoreApi({
sourceStoreId: selectedStoreId.value,
targetStoreIds: [...syncTargetStoreIds.value],
productIds: [...syncProductIds.value],
});
latestResultText.value = `同步完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量同步成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 批量导入。 */
async function submitImport() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
isSubmitting.value = true;
try {
const result = await batchImportProductApi({
storeId: selectedStoreId.value,
scope: buildScope(),
});
latestResultText.value = `导入完成:文件 ${result.fileName},成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量导入成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 批量导出。 */
async function submitExport() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
isSubmitting.value = true;
try {
const result = await batchExportProductApi({
storeId: selectedStoreId.value,
scope: buildScope(),
});
latestResultText.value = `导出完成:文件 ${result.fileName},导出 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量导出成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
watch(selectedStoreId, () => {
scopeCategoryId.value = '';
scopeProductIds.value = [];
moveTargetCategoryId.value = '';
syncTargetStoreIds.value = [];
syncProductIds.value = [];
reloadScopeData();
});
onMounted(loadStores);
</script>
<template>
<Page title="批量工具" content-class="space-y-4 page-product-batch-tools">
<Card :bordered="false">
<Space direction="vertical" style="width: 100%">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
/>
<Select
v-model:value="scopeType"
style="width: 160px"
:options="[
{ label: '全部商品', value: 'all' },
{ label: '按分类', value: 'category' },
{ label: '手动选择', value: 'selected' },
]"
/>
<Select
v-if="scopeType === 'category'"
v-model:value="scopeCategoryId"
:options="categoryOptions"
:loading="isCategoryLoading"
style="width: 200px"
placeholder="选择分类"
/>
<Select
v-if="scopeType === 'selected'"
v-model:value="scopeProductIds"
mode="multiple"
:options="productOptions"
:loading="isProductLoading"
style="min-width: 360px"
placeholder="选择商品"
/>
</Space>
<Alert
type="info"
show-icon
message="先设定批量范围,再选择具体工具执行。所有结果均为 Mock 回执。"
/>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<template v-else>
<Card :bordered="false">
<Tabs v-model:active-key="activeTab">
<Tabs.TabPane key="price" tab="批量调价">
<Space wrap>
<Select
v-model:value="priceMode"
style="width: 160px"
:options="[
{ label: '统一设价', value: 'set' },
{ label: '统一涨价', value: 'increase' },
{ label: '统一降价', value: 'decrease' },
]"
/>
<InputNumber v-model:value="priceAmount" :min="0" :step="0.5" />
<Button
type="primary"
:loading="isSubmitting"
@click="submitPriceAdjust"
>
执行调价
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="sale" tab="批量上下架">
<Space wrap>
<Select
v-model:value="saleAction"
style="width: 160px"
:options="[
{ label: '批量上架', value: 'on' },
{ label: '批量下架', value: 'off' },
]"
/>
<Button
type="primary"
:loading="isSubmitting"
@click="submitSaleSwitch"
>
执行上下架
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="category" tab="批量移动分类">
<Space wrap>
<Select
v-model:value="moveTargetCategoryId"
:options="categoryOptions"
:loading="isCategoryLoading"
style="width: 220px"
placeholder="目标分类"
/>
<Button
type="primary"
:loading="isSubmitting"
@click="submitMoveCategory"
>
执行移动
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="sync" tab="同步到其他门店">
<Space direction="vertical" style="width: 100%">
<Space wrap>
<Select
v-model:value="syncTargetStoreIds"
mode="multiple"
:options="targetStoreOptions"
style="min-width: 300px"
placeholder="选择目标门店"
/>
</Space>
<Select
v-model:value="syncProductIds"
mode="multiple"
:options="productOptions"
:loading="isProductLoading"
placeholder="选择需要同步的商品"
/>
<Button
type="primary"
:loading="isSubmitting"
@click="submitSyncStore"
>
执行同步
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="excel" tab="导入导出">
<Space wrap>
<Button :loading="isSubmitting" @click="submitImport">
导入商品
</Button>
<Button
type="primary"
:loading="isSubmitting"
@click="submitExport"
>
导出商品
</Button>
</Space>
</Tabs.TabPane>
</Tabs>
</Card>
<Card v-if="latestResultText" :bordered="false">
<Alert type="success" show-icon :message="latestResultText" />
</Card>
</template>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:批量工具页面样式。 */
.page-product-batch-tools {
:deep(.ant-tabs-nav) {
margin-bottom: 18px;
}
}
</style>

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import type { ProductPreviewItem } from '../types';
/**
* 文件职责:分类详情与商品预览面板。
*/
import type { ProductCategoryManageDto } from '#/api/product';
import {
CATEGORY_CHANNEL_META,
CATEGORY_CHANNEL_ORDER,
formatPrice,
getProductStatusClass,
getProductStatusText,
} from '../types';
interface Props {
categoryProducts: ProductPreviewItem[];
isCategoryProductsLoading: boolean;
selectedCategory: ProductCategoryManageDto | undefined;
}
interface Emits {
(event: 'edit'): void;
(event: 'openPicker'): void;
(event: 'remove'): void;
(event: 'unbind', item: ProductPreviewItem): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<div class="pcat-right">
<template v-if="props.selectedCategory">
<div class="pcat-info-card">
<div class="pcat-info-hd">
<div>
<div class="pcat-info-title">{{ props.selectedCategory.name }}</div>
<div class="pcat-info-desc">
{{ props.selectedCategory.description || '暂无分类描述' }}
</div>
</div>
<div class="pcat-info-actions">
<button type="button" class="pcat-btn" @click="emit('edit')">
编辑
</button>
<button
type="button"
class="pcat-btn pcat-btn-danger"
@click="emit('remove')"
>
删除
</button>
</div>
</div>
<div class="pcat-attr-grid">
<div class="pcat-attr-item">
<span class="pcat-attr-label">商品数量</span>
<span class="pcat-attr-value">
{{ props.selectedCategory.productCount }}
</span>
</div>
<div class="pcat-attr-item">
<span class="pcat-attr-label">排序权重</span>
<span class="pcat-attr-value">{{
props.selectedCategory.sort
}}</span>
</div>
<div class="pcat-attr-item">
<span class="pcat-attr-label">启用状态</span>
<span class="pcat-attr-value">
<span
class="pcat-status-tag"
:class="
props.selectedCategory.status === 'enabled'
? 'enabled'
: 'disabled'
"
>
{{
props.selectedCategory.status === 'enabled'
? '已启用'
: '已停用'
}}
</span>
</span>
</div>
<div class="pcat-attr-item">
<span class="pcat-attr-label">分类图标</span>
<span class="pcat-attr-value icon-value">
{{ props.selectedCategory.icon || '未设置' }}
</span>
</div>
</div>
</div>
<div class="pcat-channel-card">
<div class="pcat-channel-hd">渠道可见性</div>
<div class="pcat-channel-list">
<div
v-for="channel in CATEGORY_CHANNEL_ORDER"
:key="`vis-${channel}`"
class="pcat-channel-item"
>
<div
class="pcat-ch-icon"
:class="CATEGORY_CHANNEL_META[channel].cardClass"
>
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
</div>
<div class="pcat-ch-info">
<div class="pcat-ch-name">
{{ CATEGORY_CHANNEL_META[channel].label }}
</div>
<div class="pcat-ch-sub">
{{ CATEGORY_CHANNEL_META[channel].subText }}
</div>
</div>
<div
class="pcat-ch-status"
:class="
props.selectedCategory.channels.includes(channel) ? 'on' : 'off'
"
>
<span class="pcat-ch-status-dot"></span>
{{
props.selectedCategory.channels.includes(channel)
? '已开启'
: '未开启'
}}
</div>
</div>
</div>
</div>
<div class="pcat-prod-card">
<div class="pcat-prod-hd">
<div class="pcat-prod-title">
分类商品
<span>{{ props.categoryProducts.length }} 个商品</span>
</div>
<button
type="button"
class="pcat-btn pcat-btn-sm"
@click="emit('openPicker')"
>
添加商品到此分类
</button>
</div>
<table class="pcat-prod-table">
<thead>
<tr>
<th>商品</th>
<th>价格</th>
<th>月销</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="props.isCategoryProductsLoading">
<td colspan="5" class="pcat-table-empty">商品加载中...</td>
</tr>
<tr v-else-if="props.categoryProducts.length === 0">
<td colspan="5" class="pcat-table-empty">该分类暂无商品</td>
</tr>
<tr v-for="item in props.categoryProducts" v-else :key="item.id">
<td>
<div class="pcat-prod-name">
<div class="pcat-prod-thumb"></div>
<div class="pcat-prod-info">
<div class="name">{{ item.name }}</div>
<div class="spu">{{ item.spuCode }}</div>
</div>
</div>
</td>
<td class="pcat-prod-price">{{ formatPrice(item.price) }}</td>
<td>{{ item.monthlySales }}</td>
<td>
<span
class="pcat-status-tag"
:class="getProductStatusClass(item.status)"
>
{{ getProductStatusText(item.status) }}
</span>
</td>
<td>
<button
type="button"
class="pcat-link-btn"
@click="emit('unbind', item)"
>
移出
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-else class="pcat-empty-card">暂无分类数据</div>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* 文件职责:分类商品选择弹窗。
*/
import type { ProductPickerItemDto } from '#/api/product';
import { Input, Modal } from 'ant-design-vue';
import { formatPrice } from '../types';
interface Props {
keyword: string;
loading: boolean;
open: boolean;
products: ProductPickerItemDto[];
selectedIds: string[];
submitting: boolean;
}
interface Emits {
(event: 'close'): void;
(event: 'search'): void;
(event: 'setKeyword', value: string): void;
(event: 'submit'): void;
(event: 'toggleProduct', id: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function setKeyword(value: string) {
emit('setKeyword', value);
}
</script>
<template>
<Modal
:open="props.open"
title="添加商品到分类"
ok-text="确认添加"
cancel-text="取消"
:confirm-loading="props.submitting"
:ok-button-props="{ disabled: props.selectedIds.length === 0 }"
@ok="emit('submit')"
@cancel="emit('close')"
>
<div class="pcat-picker-search">
<Input
:value="props.keyword"
placeholder="搜索商品名称/SPU"
allow-clear
@update:value="setKeyword"
@press-enter="emit('search')"
/>
<button
type="button"
class="pcat-btn pcat-btn-sm"
:disabled="props.loading"
@click="emit('search')"
>
搜索
</button>
</div>
<div class="pcat-picker-list">
<div v-if="props.loading" class="pcat-picker-empty">加载中...</div>
<div v-else-if="props.products.length === 0" class="pcat-picker-empty">
暂无可选商品
</div>
<label
v-for="item in props.products"
v-else
:key="`picker-${item.id}`"
class="pcat-picker-item"
>
<input
:checked="props.selectedIds.includes(item.id)"
type="checkbox"
@change="emit('toggleProduct', item.id)"
/>
<span class="name">{{ item.name }}</span>
<span class="spu">{{ item.spuCode }}</span>
<span class="price">{{ formatPrice(item.price) }}</span>
</label>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { ChannelFilter } from '../types';
/**
* 文件职责:分类左侧列表组件。
*/
import type { ProductCategoryManageDto } from '#/api/product';
import { CATEGORY_CHANNEL_META, CATEGORY_CHANNEL_ORDER } from '../types';
interface Props {
categories: ProductCategoryManageDto[];
channelFilter: ChannelFilter;
keyword: string;
loading: boolean;
selectedCategoryId: string;
}
interface Emits {
(event: 'select', id: string): void;
(event: 'update:channelFilter', value: ChannelFilter): void;
(event: 'update:keyword', value: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function setKeyword(event: Event) {
const target = event.target as HTMLInputElement | null;
emit('update:keyword', target?.value ?? '');
}
function getDisplayChannels(channels: ProductCategoryManageDto['channels']) {
return CATEGORY_CHANNEL_ORDER.filter((channel) => channels.includes(channel));
}
</script>
<template>
<div class="pcat-left">
<div class="pcat-left-hd">
<div class="pcat-left-title">
全部分类
<span> {{ props.categories.length }} </span>
</div>
<div class="pcat-ch-filter">
<span
class="pcat-ch-pill"
:class="{ active: props.channelFilter === 'all' }"
@click="emit('update:channelFilter', 'all')"
>
全部
</span>
<span
class="pcat-ch-pill"
:class="{ active: props.channelFilter === 'wm' }"
@click="emit('update:channelFilter', 'wm')"
>
外卖
</span>
<span
class="pcat-ch-pill"
:class="{ active: props.channelFilter === 'pickup' }"
@click="emit('update:channelFilter', 'pickup')"
>
自提
</span>
<span
class="pcat-ch-pill"
:class="{ active: props.channelFilter === 'dine_in' }"
@click="emit('update:channelFilter', 'dine_in')"
>
堂食
</span>
</div>
<div class="pcat-search">
<input
:value="props.keyword"
type="text"
placeholder="搜索分类名称…"
@input="setKeyword"
/>
</div>
</div>
<div class="pcat-left-list">
<div v-if="props.loading" class="pcat-left-empty">分类加载中...</div>
<div v-else-if="props.categories.length === 0" class="pcat-left-empty">
暂无分类
</div>
<div
v-for="item in props.categories"
v-else
:key="item.id"
class="pcat-cat-item"
:class="{
active: item.id === props.selectedCategoryId,
disabled: item.status === 'disabled',
}"
@click="emit('select', item.id)"
>
<span class="pcat-drag-handle"></span>
<span class="pcat-cat-name">{{ item.name }}</span>
<span class="pcat-cat-tags">
<span
v-for="channel in getDisplayChannels(item.channels)"
:key="`${item.id}-${channel}`"
class="pcat-ch-mini"
:class="CATEGORY_CHANNEL_META[channel].filterClass"
>
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
</span>
</span>
<span class="pcat-cat-badge">{{ item.productCount }}</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
/**
* 文件职责:分类统计条展示组件。
*/
import type { CategoryStatsViewModel } from '../types';
interface Props {
stats: CategoryStatsViewModel;
}
defineProps<Props>();
</script>
<template>
<div class="pcat-stats">
<span class="pcat-stat-item">
<span>分类总数</span>
<strong>{{ stats.totalCategories }}</strong>
</span>
<span class="pcat-stat-item">
<span>已启用</span>
<strong>{{ stats.enabledCategories }}</strong>
</span>
<span class="pcat-stat-item">
<span>商品总数</span>
<strong>{{ stats.totalProducts }}</strong>
</span>
<span>
渠道覆盖
<span class="pcat-ch-stat">
<span class="pcat-ch-dot wm"></span>外卖
<strong>{{ stats.wmCount }}</strong>
</span>
<span class="pcat-ch-stat">
<span class="pcat-ch-dot zt"></span>自提
<strong>{{ stats.pickupCount }}</strong>
</span>
<span class="pcat-ch-stat">
<span class="pcat-ch-dot ts"></span>堂食
<strong>{{ stats.dineInCount }}</strong>
</span>
</span>
</div>
</template>

View File

@@ -0,0 +1,626 @@
import type {
CategoryFormModel,
CategoryStatsViewModel,
ChannelFilter,
DrawerMode,
ProductPreviewItem,
StoreOption,
} from '../types';
/**
* 文件职责:分类管理页面状态与行为聚合。
* 1. 管理门店、分类、商品预览、抽屉与弹窗状态。
* 2. 统一封装分类 CRUD 与商品绑定/解绑流程。
*/
import type {
ProductCategoryManageDto,
ProductPickerItemDto,
} from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
bindCategoryProductsApi,
deleteProductCategoryApi,
getProductCategoryManageListApi,
saveProductCategoryApi,
searchProductPickerApi,
unbindCategoryProductApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
import { CATEGORY_CHANNEL_ORDER, deriveMonthlySales } from '../types';
/** 分类管理页面组合式状态。 */
export function useProductCategoryPage() {
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const categories = ref<ProductCategoryManageDto[]>([]);
const isCategoryLoading = ref(false);
const selectedCategoryId = ref('');
const categoryKeyword = ref('');
const channelFilter = ref<ChannelFilter>('all');
const categoryProducts = ref<ProductPreviewItem[]>([]);
const isCategoryProductsLoading = ref(false);
const isDrawerOpen = ref(false);
const drawerMode = ref<DrawerMode>('create');
const isDrawerSubmitting = ref(false);
const form = reactive<CategoryFormModel>({
id: '',
name: '',
description: '',
icon: '',
channels: [...CATEGORY_CHANNEL_ORDER],
sort: 1,
status: 'enabled',
});
const isPickerOpen = ref(false);
const pickerKeyword = ref('');
const pickerProducts = ref<ProductPickerItemDto[]>([]);
const pickerSelectedIds = ref<string[]>([]);
const isPickerLoading = ref(false);
const isPickerSubmitting = ref(false);
const isCopyModalOpen = ref(false);
const isCopySubmitting = ref(false);
const copyTargetStoreIds = ref<string[]>([]);
const storeOptions = computed<StoreOption[]>(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const selectedCategory = computed(() =>
categories.value.find((item) => item.id === selectedCategoryId.value),
);
const stats = computed<CategoryStatsViewModel>(() => {
const totalCategories = categories.value.length;
const enabledCategories = categories.value.filter(
(item) => item.status === 'enabled',
).length;
const totalProducts = categories.value.reduce(
(sum, item) => sum + item.productCount,
0,
);
const wmCount = categories.value.filter((item) =>
item.channels.includes('wm'),
).length;
const pickupCount = categories.value.filter((item) =>
item.channels.includes('pickup'),
).length;
const dineInCount = categories.value.filter((item) =>
item.channels.includes('dine_in'),
).length;
return {
totalCategories,
enabledCategories,
totalProducts,
wmCount,
pickupCount,
dineInCount,
};
});
const filteredCategories = computed(() => {
const keyword = categoryKeyword.value.trim().toLowerCase();
return categories.value.filter((item) => {
if (
channelFilter.value !== 'all' &&
!item.channels.includes(channelFilter.value)
) {
return false;
}
if (!keyword) return true;
return (
item.name.toLowerCase().includes(keyword) ||
item.description.toLowerCase().includes(keyword)
);
});
});
const drawerTitle = computed(() =>
drawerMode.value === 'create' ? '添加分类' : '编辑分类',
);
const drawerSubmitText = computed(() =>
drawerMode.value === 'create' ? '确认添加' : '保存修改',
);
const selectedStoreName = computed(
() =>
stores.value.find((item) => item.id === selectedStoreId.value)?.name ??
'',
);
const copyCandidates = computed(() =>
stores.value.filter((item) => item.id !== selectedStoreId.value),
);
const isCopyAllChecked = computed(
() =>
copyCandidates.value.length > 0 &&
copyTargetStoreIds.value.length === copyCandidates.value.length,
);
const isCopyIndeterminate = computed(
() =>
copyTargetStoreIds.value.length > 0 &&
copyTargetStoreIds.value.length < copyCandidates.value.length,
);
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
async function loadCategories(preferredCategoryId = '') {
if (!selectedStoreId.value) {
categories.value = [];
selectedCategoryId.value = '';
categoryProducts.value = [];
return;
}
isCategoryLoading.value = true;
const previousCategoryId = selectedCategoryId.value;
try {
const result = await getProductCategoryManageListApi({
storeId: selectedStoreId.value,
});
categories.value = [...result].toSorted((a, b) => a.sort - b.sort);
const nextCategoryId =
(preferredCategoryId &&
categories.value.some((item) => item.id === preferredCategoryId)
? preferredCategoryId
: '') ||
(previousCategoryId &&
categories.value.some((item) => item.id === previousCategoryId)
? previousCategoryId
: '') ||
categories.value[0]?.id ||
'';
selectedCategoryId.value = nextCategoryId;
if (nextCategoryId && nextCategoryId === previousCategoryId) {
await loadCategoryProducts();
}
} catch (error) {
console.error(error);
categories.value = [];
selectedCategoryId.value = '';
categoryProducts.value = [];
message.error('加载分类失败');
} finally {
isCategoryLoading.value = false;
}
}
async function loadCategoryProducts() {
if (!selectedStoreId.value || !selectedCategoryId.value) {
categoryProducts.value = [];
return;
}
isCategoryProductsLoading.value = true;
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
categoryId: selectedCategoryId.value,
limit: 500,
});
categoryProducts.value = list.map((item) => ({
...item,
monthlySales: deriveMonthlySales(item.id),
}));
} catch (error) {
console.error(error);
categoryProducts.value = [];
message.error('加载分类商品失败');
} finally {
isCategoryProductsLoading.value = false;
}
}
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
}
function setCategoryKeyword(value: string) {
categoryKeyword.value = value;
}
function setChannelFilter(value: ChannelFilter) {
channelFilter.value = value;
}
function selectCategory(id: string) {
if (selectedCategoryId.value === id) return;
selectedCategoryId.value = id;
}
function openCopyModal() {
if (!selectedStoreId.value) {
message.warning('请先选择门店');
return;
}
copyTargetStoreIds.value = [];
isCopyModalOpen.value = true;
}
function setCopyModalOpen(value: boolean) {
isCopyModalOpen.value = value;
}
function handleCopyCheckAll(checked: boolean) {
copyTargetStoreIds.value = checked
? copyCandidates.value.map((item) => item.id)
: [];
}
function toggleCopyStore(storeId: string, checked: boolean) {
if (checked) {
copyTargetStoreIds.value = [
...new Set([storeId, ...copyTargetStoreIds.value]),
];
return;
}
copyTargetStoreIds.value = copyTargetStoreIds.value.filter(
(item) => item !== storeId,
);
}
async function handleCopySubmit() {
if (copyTargetStoreIds.value.length === 0) {
message.warning('请至少选择一个目标门店');
return;
}
isCopySubmitting.value = true;
try {
message.info('复制到其他门店功能开发中');
isCopyModalOpen.value = false;
copyTargetStoreIds.value = [];
} finally {
isCopySubmitting.value = false;
}
}
function resetDrawerForm() {
form.id = '';
form.name = '';
form.description = '';
form.icon = '';
form.channels = [...CATEGORY_CHANNEL_ORDER];
form.sort = categories.value.length + 1;
form.status = 'enabled';
}
function setFormName(value: string) {
form.name = value;
}
function setFormDescription(value: string) {
form.description = value;
}
function setFormIcon(value: string) {
form.icon = value;
}
function setFormSort(value: number) {
form.sort = Number.isFinite(value) && value > 0 ? Math.floor(value) : 1;
}
function openCreateDrawer() {
drawerMode.value = 'create';
resetDrawerForm();
isDrawerOpen.value = true;
}
function openEditDrawer() {
const current = selectedCategory.value;
if (!current) {
message.warning('请先选择分类');
return;
}
drawerMode.value = 'edit';
form.id = current.id;
form.name = current.name;
form.description = current.description;
form.icon = current.icon;
form.channels = [...current.channels];
form.sort = current.sort;
form.status = current.status;
isDrawerOpen.value = true;
}
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
function toggleDrawerChannel(channel: CategoryFormModel['channels'][number]) {
if (form.channels.includes(channel)) {
form.channels = form.channels.filter((item) => item !== channel);
return;
}
form.channels = [...form.channels, channel];
}
function toggleDrawerStatus() {
form.status = form.status === 'enabled' ? 'disabled' : 'enabled';
}
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入分类名称');
return;
}
if (form.channels.length === 0) {
message.warning('请至少选择一个销售渠道');
return;
}
isDrawerSubmitting.value = true;
try {
const result = await saveProductCategoryApi({
storeId: selectedStoreId.value,
id: form.id || undefined,
name: form.name.trim(),
description: form.description.trim(),
icon: form.icon.trim() || 'lucide:folder',
channels: [...form.channels],
sort: form.sort,
status: form.status,
});
message.success(
drawerMode.value === 'create' ? '分类添加成功' : '保存成功',
);
isDrawerOpen.value = false;
await loadCategories(result.id);
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
function removeSelectedCategory() {
const current = selectedCategory.value;
if (!selectedStoreId.value || !current) return;
Modal.confirm({
title: `确认删除分类「${current.name}」吗?`,
content: '若分类下还有商品会删除失败。',
okText: '确认删除',
cancelText: '取消',
async onOk() {
await deleteProductCategoryApi({
storeId: selectedStoreId.value,
categoryId: current.id,
});
message.success('分类已删除');
await loadCategories();
},
});
}
async function openProductPicker() {
if (!selectedStoreId.value || !selectedCategory.value) return;
pickerKeyword.value = '';
pickerProducts.value = [];
pickerSelectedIds.value = [];
isPickerOpen.value = true;
await loadPickerProducts();
}
function setPickerOpen(value: boolean) {
isPickerOpen.value = value;
}
function setPickerKeyword(value: string) {
pickerKeyword.value = value;
}
async function loadPickerProducts() {
if (!selectedStoreId.value || !selectedCategory.value) {
pickerProducts.value = [];
return;
}
isPickerLoading.value = true;
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
keyword: pickerKeyword.value.trim() || undefined,
limit: 500,
});
const currentIds = new Set(categoryProducts.value.map((item) => item.id));
pickerProducts.value = list.filter((item) => !currentIds.has(item.id));
} catch (error) {
console.error(error);
pickerProducts.value = [];
message.error('加载可选商品失败');
} finally {
isPickerLoading.value = false;
}
}
function togglePickerProduct(id: string) {
if (pickerSelectedIds.value.includes(id)) {
pickerSelectedIds.value = pickerSelectedIds.value.filter(
(item) => item !== id,
);
return;
}
pickerSelectedIds.value = [...pickerSelectedIds.value, id];
}
async function submitProductPicker() {
const current = selectedCategory.value;
if (!selectedStoreId.value || !current) return;
if (pickerSelectedIds.value.length === 0) {
message.warning('请至少选择一个商品');
return;
}
isPickerSubmitting.value = true;
try {
await bindCategoryProductsApi({
storeId: selectedStoreId.value,
categoryId: current.id,
productIds: [...pickerSelectedIds.value],
});
message.success('商品已添加到分类');
isPickerOpen.value = false;
await loadCategories(current.id);
} catch (error) {
console.error(error);
} finally {
isPickerSubmitting.value = false;
}
}
function unbindProduct(item: ProductPreviewItem) {
const current = selectedCategory.value;
if (!selectedStoreId.value || !current) return;
Modal.confirm({
title: `确认将「${item.name}」移出当前分类吗?`,
content: '移出后商品会自动归入其他分类。',
okText: '确认移出',
cancelText: '取消',
async onOk() {
await unbindCategoryProductApi({
storeId: selectedStoreId.value,
categoryId: current.id,
productId: item.id,
});
message.success('商品已移出');
await loadCategories(current.id);
},
});
}
watch(selectedStoreId, () => {
categoryKeyword.value = '';
channelFilter.value = 'all';
isCopyModalOpen.value = false;
copyTargetStoreIds.value = [];
void loadCategories();
});
watch(filteredCategories, (list) => {
if (list.length === 0) {
selectedCategoryId.value = '';
categoryProducts.value = [];
return;
}
const hasSelected = list.some(
(item) => item.id === selectedCategoryId.value,
);
if (!hasSelected) {
selectedCategoryId.value = list[0]?.id ?? '';
}
});
watch(selectedCategoryId, () => {
void loadCategoryProducts();
});
onMounted(loadStores);
return {
categoryKeyword,
categoryProducts,
categories,
channelFilter,
copyCandidates,
copyTargetStoreIds,
drawerMode,
drawerSubmitText,
drawerTitle,
filteredCategories,
form,
isCategoryLoading,
isCategoryProductsLoading,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isDrawerOpen,
isDrawerSubmitting,
isPickerLoading,
isPickerOpen,
isPickerSubmitting,
isStoreLoading,
pickerKeyword,
pickerProducts,
pickerSelectedIds,
selectedCategory,
selectedCategoryId,
selectedStoreId,
selectedStoreName,
stats,
storeOptions,
handleCopyCheckAll,
handleCopySubmit,
loadPickerProducts,
openCopyModal,
openCreateDrawer,
openEditDrawer,
openProductPicker,
removeSelectedCategory,
selectCategory,
setCategoryKeyword,
setChannelFilter,
setCopyModalOpen,
setDrawerOpen,
setFormDescription,
setFormIcon,
setFormName,
setFormSort,
setPickerKeyword,
setPickerOpen,
setSelectedStoreId,
submitDrawer,
submitProductPicker,
toggleCopyStore,
toggleDrawerChannel,
toggleDrawerStatus,
togglePickerProduct,
unbindProduct,
};
}

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
/**
* 文件职责:分类管理页面主视图。
* 1. 组合分类统计、左侧列表、右侧详情、编辑抽屉与商品选择弹窗。
* 2. 复用门店维度工具栏与复制弹窗组件。
*/
import { Page } from '@vben/common-ui';
import { Card, Empty } from 'ant-design-vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import CategoryDetailPanel from './components/CategoryDetailPanel.vue';
import CategoryEditorDrawer from './components/CategoryEditorDrawer.vue';
import CategoryProductPickerModal from './components/CategoryProductPickerModal.vue';
import CategorySidebar from './components/CategorySidebar.vue';
import CategoryStatsBar from './components/CategoryStatsBar.vue';
import { useProductCategoryPage } from './composables/useProductCategoryPage';
const {
categoryKeyword,
categoryProducts,
channelFilter,
copyCandidates,
copyTargetStoreIds,
drawerSubmitText,
drawerTitle,
filteredCategories,
form,
isCategoryLoading,
isCategoryProductsLoading,
isCopyAllChecked,
isCopyIndeterminate,
isCopyModalOpen,
isCopySubmitting,
isDrawerOpen,
isDrawerSubmitting,
isPickerLoading,
isPickerOpen,
isPickerSubmitting,
isStoreLoading,
pickerKeyword,
pickerProducts,
pickerSelectedIds,
selectedCategory,
selectedCategoryId,
selectedStoreId,
selectedStoreName,
stats,
storeOptions,
handleCopyCheckAll,
handleCopySubmit,
loadPickerProducts,
openCopyModal,
openCreateDrawer,
openEditDrawer,
openProductPicker,
removeSelectedCategory,
selectCategory,
setCategoryKeyword,
setChannelFilter,
setCopyModalOpen,
setDrawerOpen,
setFormDescription,
setFormIcon,
setFormName,
setFormSort,
setPickerKeyword,
setPickerOpen,
setSelectedStoreId,
submitDrawer,
submitProductPicker,
toggleCopyStore,
toggleDrawerChannel,
toggleDrawerStatus,
togglePickerProduct,
unbindProduct,
} = useProductCategoryPage();
</script>
<template>
<Page title="分类管理" content-class="page-product-category">
<div class="pcat-page">
<StoreScopeToolbar
class="pcat-toolbar-card"
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
:copy-disabled="storeOptions.length <= 1"
copy-position="left"
@update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal"
>
<template #actions>
<button
type="button"
class="pcat-btn pcat-btn-primary pcat-btn-lg"
@click="openCreateDrawer"
>
添加分类
</button>
</template>
</StoreScopeToolbar>
<Card v-if="storeOptions.length === 0" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<template v-else>
<CategoryStatsBar :stats="stats" />
<div class="pcat-main">
<CategorySidebar
:loading="isCategoryLoading"
:categories="filteredCategories"
:selected-category-id="selectedCategoryId"
:keyword="categoryKeyword"
:channel-filter="channelFilter"
@select="selectCategory"
@update:keyword="setCategoryKeyword"
@update:channel-filter="setChannelFilter"
/>
<CategoryDetailPanel
:selected-category="selectedCategory"
:category-products="categoryProducts"
:is-category-products-loading="isCategoryProductsLoading"
@edit="openEditDrawer"
@remove="removeSelectedCategory"
@open-picker="openProductPicker"
@unbind="unbindProduct"
/>
</div>
</template>
</div>
<CategoryProductPickerModal
:open="isPickerOpen"
:loading="isPickerLoading"
:submitting="isPickerSubmitting"
:products="pickerProducts"
:keyword="pickerKeyword"
:selected-ids="pickerSelectedIds"
@close="setPickerOpen(false)"
@set-keyword="setPickerKeyword"
@search="loadPickerProducts"
@toggle-product="togglePickerProduct"
@submit="submitProductPicker"
/>
<CategoryEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
:submit-text="drawerSubmitText"
:submitting="isDrawerSubmitting"
:form="form"
@close="setDrawerOpen(false)"
@set-name="setFormName"
@set-description="setFormDescription"
@set-icon="setFormIcon"
@set-sort="setFormSort"
@toggle-channel="toggleDrawerChannel"
@toggle-status="toggleDrawerStatus"
@submit="submitDrawer"
/>
<CopyToStoresModal
:open="isCopyModalOpen"
:copy-candidates="copyCandidates"
:target-store-ids="copyTargetStoreIds"
:is-all-checked="isCopyAllChecked"
:is-indeterminate="isCopyIndeterminate"
:is-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
title="复制分类设置到其他门店"
confirm-text="确认复制"
@update:open="setCopyModalOpen"
@check-all="handleCopyCheckAll"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -0,0 +1,135 @@
.page-product-category {
--pcat-primary: #1677ff;
--pcat-shadow: 0 6px 16px rgb(15 23 42 / 6%);
--pcat-radius: 10px;
.pcat-page {
min-height: calc(100vh - 208px);
color: #1a1a2e;
}
.pcat-toolbar-card {
margin-bottom: 16px;
}
.pcat-btn {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 14px;
font-size: 13px;
color: #374151;
cursor: pointer;
user-select: none;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 8px;
transition: all 0.2s ease;
}
.pcat-btn:hover {
color: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-btn:disabled {
color: #9ca3af;
cursor: not-allowed;
opacity: 0.7;
}
.pcat-btn-primary {
color: #fff;
background: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-btn-primary:hover {
color: #fff;
filter: brightness(1.04);
}
.pcat-btn-danger {
color: #ff4d4f;
background: #fff2f0;
border-color: #ffccc7;
}
.pcat-btn-danger:hover {
color: #ff4d4f;
background: #fff1f0;
border-color: #ff4d4f;
}
.pcat-btn-sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.pcat-btn-lg {
height: 36px;
padding: 0 18px;
font-size: 15px;
font-weight: 600;
border-radius: 10px;
}
.pcat-main {
display: flex;
gap: 18px;
align-items: stretch;
}
.pcat-stats {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 10px 16px;
margin-bottom: 16px;
font-size: 13px;
color: #4b5563;
background: #fff;
border-radius: var(--pcat-radius);
box-shadow: var(--pcat-shadow);
}
.pcat-stats strong {
margin-left: 2px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-stat-item {
display: inline-flex;
gap: 4px;
align-items: center;
}
.pcat-ch-stat {
display: inline-flex;
gap: 4px;
align-items: center;
margin-left: 4px;
}
.pcat-ch-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.pcat-ch-dot.wm {
background: #1890ff;
}
.pcat-ch-dot.zt {
background: #52c41a;
}
.pcat-ch-dot.ts {
background: #fa8c16;
}
}

View File

@@ -0,0 +1,319 @@
.page-product-category {
.pcat-right {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
}
.pcat-info-card,
.pcat-channel-card,
.pcat-prod-card,
.pcat-empty-card {
background: #fff;
border-radius: var(--pcat-radius);
box-shadow: var(--pcat-shadow);
}
.pcat-info-card {
padding: 20px;
}
.pcat-info-hd {
display: flex;
gap: 16px;
justify-content: space-between;
margin-bottom: 16px;
}
.pcat-info-title {
font-size: 18px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-info-desc {
margin-top: 4px;
font-size: 13px;
color: #9ca3af;
}
.pcat-info-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
}
.pcat-attr-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding-top: 16px;
border-top: 1px solid #f3f4f6;
}
.pcat-attr-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: #f8f9fb;
border-radius: 8px;
}
.pcat-attr-label {
font-size: 11px;
font-weight: 500;
color: #9ca3af;
}
.pcat-attr-value {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-attr-value.icon-value {
font-size: 12px;
font-weight: 400;
color: #9ca3af;
}
.pcat-status-tag {
display: inline-flex;
align-items: center;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 10px;
}
.pcat-status-tag.enabled,
.pcat-status-tag.on-sale {
color: #52c41a;
background: rgb(82 196 26 / 12%);
}
.pcat-status-tag.disabled,
.pcat-status-tag.off-shelf {
color: #9ca3af;
background: rgb(148 163 184 / 16%);
}
.pcat-status-tag.sold-out {
color: #fa8c16;
background: rgb(250 140 22 / 14%);
}
.pcat-channel-card {
padding: 16px 20px;
}
.pcat-channel-hd {
padding-left: 10px;
margin-bottom: 14px;
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
border-left: 3px solid var(--pcat-primary);
}
.pcat-channel-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.pcat-channel-item {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
min-width: 160px;
padding: 10px 16px;
background: #f8f9fb;
border: 1px solid #f3f4f6;
border-radius: 8px;
}
.pcat-ch-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 13px;
font-weight: 700;
border-radius: 8px;
}
.pcat-ch-icon.wm {
color: #1890ff;
background: rgb(24 144 255 / 12%);
}
.pcat-ch-icon.zt {
color: #52c41a;
background: rgb(82 196 26 / 12%);
}
.pcat-ch-icon.ts {
color: #fa8c16;
background: rgb(250 140 22 / 12%);
}
.pcat-ch-info {
flex: 1;
}
.pcat-ch-name {
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-ch-sub {
margin-top: 2px;
font-size: 11px;
color: #9ca3af;
}
.pcat-ch-status {
display: flex;
gap: 4px;
align-items: center;
font-size: 11px;
font-weight: 600;
}
.pcat-ch-status.on {
color: #52c41a;
}
.pcat-ch-status.off {
color: #9ca3af;
}
.pcat-ch-status-dot {
width: 6px;
height: 6px;
background: currentcolor;
border-radius: 50%;
}
.pcat-prod-card {
overflow: hidden;
}
.pcat-prod-hd {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f3f4f6;
}
.pcat-prod-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-prod-title span {
font-size: 12px;
font-weight: 400;
color: #9ca3af;
}
.pcat-prod-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.pcat-prod-table th {
padding: 10px 18px;
font-size: 12px;
font-weight: 500;
color: #6b7280;
text-align: left;
background: #f8f9fb;
}
.pcat-prod-table td {
padding: 10px 18px;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.pcat-prod-table tr:last-child td {
border-bottom: none;
}
.pcat-prod-table tr:hover td {
background: rgb(22 119 255 / 4%);
}
.pcat-prod-name {
display: flex;
gap: 10px;
align-items: center;
}
.pcat-prod-thumb {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 11px;
color: #bbb;
background: #f3f4f6;
border-radius: 6px;
}
.pcat-prod-info .name {
font-weight: 500;
}
.pcat-prod-info .spu {
margin-top: 1px;
font-size: 11px;
color: #9ca3af;
}
.pcat-prod-price {
font-weight: 600;
}
.pcat-link-btn {
padding: 0;
font-size: 13px;
color: var(--pcat-primary);
cursor: pointer;
background: transparent;
border: none;
}
.pcat-link-btn:hover {
text-decoration: underline;
}
.pcat-table-empty {
padding: 30px 18px !important;
color: #9ca3af !important;
text-align: center;
}
.pcat-empty-card {
padding: 60px 24px;
color: #9ca3af;
text-align: center;
}
}

View File

@@ -0,0 +1,291 @@
.page-product-category {
.pcat-drawer-mask {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
background: rgb(15 23 42 / 28%);
opacity: 0;
transition: opacity 0.2s ease;
}
.pcat-drawer-mask.open {
pointer-events: auto;
opacity: 1;
}
.pcat-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 1001;
display: flex;
flex-direction: column;
width: min(480px, 100vw);
height: 100vh;
background: #fff;
box-shadow: -8px 0 24px rgb(15 23 42 / 16%);
transform: translateX(100%);
transition: transform 0.24s ease;
}
.pcat-drawer.open {
transform: translateX(0);
}
.pcat-drawer-hd {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
}
.pcat-drawer-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-drawer-close {
width: 28px;
height: 28px;
font-size: 18px;
line-height: 1;
color: #9ca3af;
cursor: pointer;
background: transparent;
border: none;
border-radius: 6px;
}
.pcat-drawer-close:hover {
color: #4b5563;
background: #f5f5f5;
}
.pcat-drawer-bd {
flex: 1;
padding: 16px 20px;
overflow-y: auto;
}
.pcat-drawer-ft {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
.pcat-form-group {
margin-bottom: 16px;
}
.pcat-form-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: #1f2937;
}
.pcat-form-label.required::before {
margin-right: 4px;
color: #ff4d4f;
content: '*';
}
.pcat-input,
.pcat-textarea {
width: 100%;
padding: 8px 10px;
font-size: 13px;
color: #1a1a2e;
outline: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
}
.pcat-input:focus,
.pcat-textarea:focus {
border-color: var(--pcat-primary);
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
}
.pcat-input-small {
width: 180px;
}
.pcat-textarea {
min-height: 72px;
resize: vertical;
}
.pcat-hint {
margin-top: 6px;
font-size: 12px;
color: #9ca3af;
}
.pcat-hint.mb-8 {
margin-top: 0;
margin-bottom: 8px;
}
.pcat-icon-upload-wrap {
display: block;
}
.pcat-icon-upload-wrap .ant-upload {
display: block;
width: 80px;
}
.pcat-icon-upload {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #bbb;
cursor: pointer;
border: 1px dashed #e5e7eb;
border-radius: 10px;
}
.pcat-icon-upload:hover {
color: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-icon-preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
}
.pcat-icon-upload-sign {
font-size: 20px;
line-height: 1;
}
.pcat-icon-upload-text {
font-size: 11px;
}
.pcat-drawer-channels {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pcat-drawer-ch {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 8px 14px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
user-select: none;
background: #fff;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
}
.pcat-drawer-ch-icon {
width: 14px;
height: 14px;
}
.pcat-drawer-ch:hover {
border-color: var(--pcat-primary);
}
.pcat-drawer-ch.checked {
font-weight: 500;
color: var(--pcat-primary);
background: rgb(22 119 255 / 6%);
border-color: var(--pcat-primary);
}
.pcat-ch-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: transparent;
border: 1.5px solid #d1d5db;
border-radius: 4px;
transition: all 0.2s ease;
}
.pcat-drawer-ch.checked .pcat-ch-check {
color: #fff;
background: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-toggle-row {
display: flex;
gap: 10px;
align-items: center;
}
.pcat-toggle {
position: relative;
width: 42px;
height: 24px;
cursor: pointer;
background: #d9d9d9;
border: none;
border-radius: 20px;
transition: all 0.2s ease;
}
.pcat-toggle::after {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
content: '';
background: #fff;
border-radius: 50%;
transition: all 0.2s ease;
}
.pcat-toggle.on {
background: var(--pcat-primary);
}
.pcat-toggle.on::after {
left: 20px;
}
.pcat-toggle-label {
font-size: 13px;
color: #4b5563;
}
.pcat-cropper-wrap {
height: 360px;
overflow: hidden;
border-radius: 10px;
}
.pcat-crop-hint {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
}

View File

@@ -0,0 +1,6 @@
@import './base.less';
@import './sidebar.less';
@import './detail.less';
@import './drawer.less';
@import './modal.less';
@import './responsive.less';

View File

@@ -0,0 +1,55 @@
.page-product-category {
.pcat-picker-search {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.pcat-picker-list {
max-height: 320px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.pcat-picker-item {
display: grid;
grid-template-columns: auto 1fr 140px auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
}
.pcat-picker-item:last-child {
border-bottom: none;
}
.pcat-picker-item:hover {
background: #fafcff;
}
.pcat-picker-item .name {
font-size: 13px;
color: #1a1a2e;
}
.pcat-picker-item .spu {
font-size: 12px;
color: #9ca3af;
}
.pcat-picker-item .price {
font-size: 12px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-picker-empty {
padding: 28px 14px;
font-size: 13px;
color: #9ca3af;
text-align: center;
}
}

View File

@@ -0,0 +1,48 @@
.page-product-category {
@media (width <= 1280px) {
.pcat-main {
flex-direction: column;
}
.pcat-left {
width: 100%;
max-height: 380px;
}
.pcat-attr-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 768px) {
.pcat-info-hd {
flex-direction: column;
}
.pcat-info-actions {
width: 100%;
}
.pcat-attr-grid {
grid-template-columns: 1fr;
}
.pcat-prod-table {
min-width: 680px;
}
.pcat-prod-card {
overflow-x: auto;
}
.pcat-picker-item {
grid-template-columns: auto 1fr;
gap: 6px 10px;
}
.pcat-picker-item .spu,
.pcat-picker-item .price {
grid-column: 2 / 3;
}
}
}

View File

@@ -0,0 +1,173 @@
.page-product-category {
.pcat-left {
display: flex;
flex-shrink: 0;
flex-direction: column;
width: 280px;
max-height: 640px;
overflow: hidden;
background: #fff;
border-radius: var(--pcat-radius);
box-shadow: var(--pcat-shadow);
}
.pcat-left-hd {
flex-shrink: 0;
padding: 14px 16px 12px;
border-bottom: 1px solid #f3f4f6;
}
.pcat-left-title {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.pcat-left-title span {
font-size: 12px;
font-weight: 400;
color: #9ca3af;
}
.pcat-ch-filter {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.pcat-ch-pill {
padding: 3px 10px;
font-size: 11px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
user-select: none;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 0.2s ease;
}
.pcat-ch-pill:hover {
color: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-ch-pill.active {
color: #fff;
background: var(--pcat-primary);
border-color: var(--pcat-primary);
}
.pcat-search input {
width: 100%;
height: 32px;
padding: 0 10px;
font-size: 12px;
color: #1a1a2e;
outline: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.pcat-search input:focus {
border-color: var(--pcat-primary);
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
}
.pcat-left-list {
flex: 1;
padding: 6px 0;
overflow-y: auto;
}
.pcat-left-empty {
padding: 30px 16px;
font-size: 13px;
color: #9ca3af;
text-align: center;
}
.pcat-cat-item {
display: flex;
gap: 8px;
align-items: center;
padding: 9px 16px;
font-size: 13px;
color: #4b5563;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.pcat-cat-item:hover {
background: rgb(22 119 255 / 4%);
}
.pcat-cat-item.active {
font-weight: 600;
color: var(--pcat-primary);
background: rgb(22 119 255 / 10%);
}
.pcat-cat-item.disabled {
opacity: 0.5;
}
.pcat-drag-handle {
flex-shrink: 0;
color: #bbb;
letter-spacing: -1px;
}
.pcat-cat-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pcat-cat-tags {
display: flex;
flex-shrink: 0;
gap: 3px;
}
.pcat-ch-mini {
padding: 1px 4px;
font-size: 10px;
font-weight: 600;
line-height: 14px;
color: #fff;
border-radius: 3px;
}
.pcat-ch-mini.wm {
background: #1890ff;
}
.pcat-ch-mini.zt {
background: #52c41a;
}
.pcat-ch-mini.ts {
background: #fa8c16;
}
.pcat-cat-badge {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
color: #6b7280;
background: #f8f9fb;
border-radius: 10px;
}
.pcat-cat-item.active .pcat-cat-badge {
color: var(--pcat-primary);
background: rgb(22 119 255 / 18%);
}
}

View File

@@ -0,0 +1,111 @@
/**
* 文件职责:分类管理页面类型与常量定义。
* 1. 统一页面、组件与组合式函数共享的类型。
* 2. 维护渠道展示元数据与格式化工具。
*/
import type {
ProductCategoryChannel,
ProductPickerItemDto,
ProductStatus,
ProductSwitchStatus,
} from '#/api/product';
export type ChannelFilter = 'all' | ProductCategoryChannel;
export type DrawerMode = 'create' | 'edit';
export interface CategoryFormModel {
channels: ProductCategoryChannel[];
description: string;
icon: string;
id: string;
name: string;
sort: number;
status: ProductSwitchStatus;
}
export interface CategoryStatsViewModel {
dineInCount: number;
enabledCategories: number;
pickupCount: number;
totalCategories: number;
totalProducts: number;
wmCount: number;
}
export interface CategoryChannelMeta {
cardClass: 'ts' | 'wm' | 'zt';
filterClass: 'ts' | 'wm' | 'zt';
label: string;
shortLabel: string;
subText: string;
}
export interface ProductPreviewItem extends ProductPickerItemDto {
monthlySales: number;
}
export interface StoreOption {
label: string;
value: string;
}
export const CATEGORY_CHANNEL_ORDER: ProductCategoryChannel[] = [
'wm',
'pickup',
'dine_in',
];
export const CATEGORY_CHANNEL_META: Record<
ProductCategoryChannel,
CategoryChannelMeta
> = {
wm: {
label: '外卖',
shortLabel: '外',
subText: '外卖平台展示此分类',
filterClass: 'wm',
cardClass: 'wm',
},
pickup: {
label: '自提',
shortLabel: '自',
subText: '到店自提展示此分类',
filterClass: 'zt',
cardClass: 'zt',
},
dine_in: {
label: '堂食',
shortLabel: '堂',
subText: '堂食扫码点餐展示此分类',
filterClass: 'ts',
cardClass: 'ts',
},
};
/** 获取商品状态文案。 */
export function getProductStatusText(status: ProductStatus) {
if (status === 'on_sale') return '在售';
if (status === 'sold_out') return '售罄';
return '下架';
}
/** 获取商品状态样式。 */
export function getProductStatusClass(status: ProductStatus) {
if (status === 'on_sale') return 'on-sale';
if (status === 'sold_out') return 'sold-out';
return 'off-shelf';
}
/** 金额格式化。 */
export function formatPrice(price: number) {
return `¥${price.toFixed(2)}`;
}
/** 稳定生成月销预览值。 */
export function deriveMonthlySales(seed: string) {
let hash = 0;
for (const char of seed) {
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 10_007;
}
return (hash % 160) + 20;
}

View File

@@ -1,60 +1,171 @@
<script setup lang="ts">
/**
* 文件职责:商品详情骨架页。
* 1. 根据路由参数加载商品详情数据
* 2. 展示基础信息并提供返回入口
* 文件职责:商品详情编辑页。
* 1. 根据路由参数加载商品详情与分类
* 2. 提供商品基础信息编辑与保存能力
*/
import type { ProductDetailDto } from '#/api/product';
import { computed, ref, watch } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { Button, Card, Descriptions, Empty, Spin, Tag } from 'ant-design-vue';
import {
Button,
Card,
Empty,
Form,
Input,
InputNumber,
message,
Select,
Space,
Spin,
Tag,
} from 'ant-design-vue';
import { getProductDetailApi } from '#/api/product';
import {
getProductCategoryListApi,
getProductDetailApi,
saveProductApi,
} from '#/api/product';
import {
formatCurrency,
resolveStatusMeta,
tagsToText,
textToTags,
} from '../list/composables/product-list-page/helpers';
const route = useRoute();
const router = useRouter();
const isLoading = ref(false);
const isSubmitting = ref(false);
const detail = ref<null | ProductDetailDto>(null);
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
const form = reactive({
id: '',
name: '',
subtitle: '',
categoryId: '',
kind: 'single' as 'combo' | 'single',
description: '',
price: 0,
originalPrice: null as null | number,
stock: 0,
status: 'off_shelf' as 'off_shelf' | 'on_sale',
tagsText: '',
});
const storeId = computed(() => String(route.query.storeId || ''));
const productId = computed(() => String(route.query.productId || ''));
const statusMeta = computed(() => {
if (!detail.value) return resolveStatusMeta('off_shelf');
return resolveStatusMeta(detail.value.status);
if (form.status === 'on_sale') {
return { color: 'green', label: '在售' };
}
return { color: 'default', label: '下架' };
});
/** 加载商品详情。 */
const originalPriceInput = computed<number | undefined>({
get: () => form.originalPrice ?? undefined,
set: (value) => {
form.originalPrice = value ?? null;
},
});
/** 将详情映射到编辑表单。 */
function patchForm(data: ProductDetailDto) {
form.id = data.id;
form.name = data.name;
form.subtitle = data.subtitle;
form.categoryId = data.categoryId;
form.kind = data.kind;
form.description = data.description;
form.price = data.price;
form.originalPrice = data.originalPrice;
form.stock = data.stock;
form.status = data.status === 'on_sale' ? 'on_sale' : 'off_shelf';
form.tagsText = tagsToText(data.tags);
}
/** 加载商品详情与分类。 */
async function loadDetail() {
if (!storeId.value || !productId.value) {
detail.value = null;
categoryOptions.value = [];
return;
}
isLoading.value = true;
try {
detail.value = await getProductDetailApi({
storeId: storeId.value,
productId: productId.value,
});
const [detailData, categories] = await Promise.all([
getProductDetailApi({
storeId: storeId.value,
productId: productId.value,
}),
getProductCategoryListApi(storeId.value),
]);
detail.value = detailData;
categoryOptions.value = categories.map((item) => ({
label: item.name,
value: item.id,
}));
patchForm(detailData);
} catch (error) {
console.error(error);
detail.value = null;
categoryOptions.value = [];
} finally {
isLoading.value = false;
}
}
/** 保存商品详情。 */
async function saveDetail() {
if (!storeId.value || !form.id) return;
if (!form.name.trim()) {
message.warning('请输入商品名称');
return;
}
if (!form.categoryId) {
message.warning('请选择商品分类');
return;
}
isSubmitting.value = true;
try {
const saved = await saveProductApi({
storeId: storeId.value,
id: form.id,
name: form.name.trim(),
subtitle: form.subtitle.trim(),
categoryId: form.categoryId,
kind: form.kind,
description: form.description.trim(),
price: Number(form.price || 0),
originalPrice:
form.originalPrice && Number(form.originalPrice) > 0
? Number(form.originalPrice)
: null,
stock: Math.max(0, Math.floor(Number(form.stock || 0))),
status: form.status,
shelfMode: form.status === 'on_sale' ? 'now' : 'draft',
tags: textToTags(form.tagsText),
});
detail.value = saved;
patchForm(saved);
message.success('商品详情已保存');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
}
/** 返回商品列表页。 */
function goBack() {
router.push('/product/list');
@@ -66,7 +177,12 @@ watch([storeId, productId], loadDetail, { immediate: true });
<template>
<Page title="商品详情" content-class="space-y-4 page-product-detail">
<Card :bordered="false">
<Button @click="goBack">返回商品列表</Button>
<Space>
<Button @click="goBack">返回商品列表</Button>
<Button type="primary" :loading="isSubmitting" @click="saveDetail">
保存商品
</Button>
</Space>
</Card>
<Spin :spinning="isLoading">
@@ -85,51 +201,94 @@ watch([storeId, productId], loadDetail, { immediate: true });
</Tag>
</div>
<Descriptions :column="2" bordered class="product-detail-descriptions">
<Descriptions.Item label="商品ID">{{ detail.id }}</Descriptions.Item>
<Descriptions.Item label="分类">
{{ detail.categoryName || '--' }}
</Descriptions.Item>
<Descriptions.Item label="商品类型">
{{ detail.kind === 'combo' ? '套餐' : '单品' }}
</Descriptions.Item>
<Descriptions.Item label="售价">
{{ formatCurrency(detail.price) }}
</Descriptions.Item>
<Descriptions.Item label="原价">
{{
detail.originalPrice ? formatCurrency(detail.originalPrice) : '--'
}}
</Descriptions.Item>
<Descriptions.Item label="库存">
{{ detail.stock }}
</Descriptions.Item>
<Descriptions.Item label="月销">
{{ detail.salesMonthly }}
</Descriptions.Item>
<Descriptions.Item label="沽清模式">
{{
detail.soldoutMode === 'today'
? '今日沽清'
: detail.soldoutMode === 'timed'
? '定时沽清'
: detail.soldoutMode === 'permanent'
? '永久沽清'
: '--'
}}
</Descriptions.Item>
<Descriptions.Item label="标签" :span="2">
<div class="product-detail-tags">
<Tag v-for="tag in detail.tags" :key="`${detail.id}-${tag}`">
{{ tag }}
</Tag>
<span v-if="detail.tags.length === 0">--</span>
</div>
</Descriptions.Item>
<Descriptions.Item label="描述" :span="2">
{{ detail.description || '--' }}
</Descriptions.Item>
</Descriptions>
<Form layout="vertical">
<Space style="display: flex; width: 100%">
<Form.Item label="商品名称" required style="flex: 1">
<Input v-model:value="form.name" :maxlength="30" show-count />
</Form.Item>
<Form.Item label="副标题" style="flex: 1">
<Input v-model:value="form.subtitle" :maxlength="60" show-count />
</Form.Item>
</Space>
<Space style="display: flex; width: 100%">
<Form.Item label="分类" style="flex: 1">
<Select
v-model:value="form.categoryId"
:options="categoryOptions"
placeholder="请选择分类"
/>
</Form.Item>
<Form.Item label="商品类型" style="flex: 1">
<Select
v-model:value="form.kind"
:options="[
{ label: '单品', value: 'single' },
{ label: '套餐', value: 'combo' },
]"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '在售', value: 'on_sale' },
{ label: '下架', value: 'off_shelf' },
]"
/>
</Form.Item>
</Space>
<Space style="display: flex; width: 100%">
<Form.Item label="售价" style="flex: 1">
<InputNumber
v-model:value="form.price"
:min="0"
:step="0.5"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="原价" style="flex: 1">
<InputNumber
v-model:value="originalPriceInput"
:min="0"
:step="0.5"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="库存" style="flex: 1">
<InputNumber
v-model:value="form.stock"
:min="0"
:step="1"
style="width: 100%"
/>
</Form.Item>
</Space>
<Form.Item label="标签(逗号分隔)">
<Input
v-model:value="form.tagsText"
placeholder="例如:招牌,新品,辣"
/>
</Form.Item>
<Form.Item label="商品描述">
<Input.TextArea
v-model:value="form.description"
:rows="4"
:maxlength="500"
show-count
/>
</Form.Item>
</Form>
<div class="detail-extra">
<span>商品ID{{ detail.id }}</span>
<span>SPU{{ detail.spuCode }}</span>
<span>月销{{ detail.salesMonthly }}</span>
<span>当前售价{{ formatCurrency(detail.price) }}</span>
</div>
</Card>
<Card v-else :bordered="false">

View File

@@ -59,16 +59,15 @@
font-weight: 600;
}
.product-detail-descriptions .ant-descriptions-item-label {
width: 120px;
font-weight: 600;
color: #374151;
}
.product-detail-tags {
.detail-extra {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 14px;
align-items: center;
padding-top: 12px;
margin-top: 8px;
font-size: 13px;
color: #6b7280;
border-top: 1px dashed #e5e7eb;
}
}

View File

@@ -0,0 +1,424 @@
<script setup lang="ts">
/**
* 文件职责:商品标签管理页面。
* 1. 管理标签基础信息与启停状态。
* 2. 管理标签关联商品。
*/
import type { ProductSwitchStatus } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import {
changeProductLabelStatusApi,
deleteProductLabelApi,
getProductLabelListApi,
saveProductLabelApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
type StatusFilter = '' | ProductSwitchStatus;
interface LabelRow {
color: string;
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<LabelRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingLabelId = ref('');
const form = reactive({
name: '',
description: '',
color: '#1677ff',
status: 'enabled' as ProductSwitchStatus,
sort: 1,
productIds: [] as string[],
});
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingLabelId.value ? '编辑标签' : '新增标签',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载标签列表。 */
async function loadLabels() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductLabelListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as LabelRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载标签失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 重置表单。 */
function resetForm() {
editingLabelId.value = '';
form.name = '';
form.description = '';
form.color = '#1677ff';
form.status = 'enabled';
form.sort = rows.value.length + 1;
form.productIds = [];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: LabelRow) {
editingLabelId.value = row.id;
form.name = row.name;
form.description = row.description;
form.color = row.color;
form.status = row.status;
form.sort = row.sort;
form.productIds = [...row.productIds];
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 保存标签。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入标签名称');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductLabelApi({
storeId: selectedStoreId.value,
id: editingLabelId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
color: form.color.trim() || '#1677ff',
status: form.status,
sort: form.sort,
productIds: [...form.productIds],
});
message.success(editingLabelId.value ? '标签已更新' : '标签已创建');
isDrawerOpen.value = false;
await loadLabels();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除标签。 */
function removeLabel(row: LabelRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除标签「${row.name}」吗?`,
async onOk() {
await deleteProductLabelApi({
storeId: selectedStoreId.value,
labelId: row.id,
});
message.success('标签已删除');
await loadLabels();
},
});
}
/** 切换标签状态。 */
async function toggleLabelStatus(row: LabelRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductLabelStatusApi({
storeId: selectedStoreId.value,
labelId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 重置筛选条件。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadLabels();
}
watch(selectedStoreId, loadLabels);
onMounted(loadStores);
</script>
<template>
<Page title="商品标签" content-class="space-y-4 page-product-labels">
<Card :bordered="false">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索标签名称"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 140px"
/>
<Button type="primary" @click="loadLabels">查询</Button>
<Button @click="resetFilters">重置</Button>
<Button type="primary" @click="openCreateDrawer">新增标签</Button>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column title="标签名称" key="name" :width="160">
<template #default="{ record }">
<Tag :color="record.color">{{ record.name }}</Tag>
</template>
</Table.Column>
<Table.Column title="描述" data-index="description" key="description" />
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="排序" data-index="sort" key="sort" :width="80" />
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="(checked) => toggleLabelStatus(record, checked === true)"
/>
</template>
</Table.Column>
<Table.Column
title="更新时间"
data-index="updatedAt"
key="updatedAt"
:width="170"
/>
<Table.Column title="操作" key="action" :width="170">
<template #default="{ record }">
<Space size="small">
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
<Button danger size="small" @click="removeLabel(record)">
删除
</Button>
</Space>
</template>
</Table.Column>
</Table>
</Card>
<Drawer
:open="isDrawerOpen"
:title="drawerTitle"
width="520"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="标签名称" required>
<Input v-model:value="form.name" :maxlength="20" show-count />
</Form.Item>
<Form.Item label="标签颜色">
<Input v-model:value="form.color" placeholder="#1677ff" />
</Form.Item>
<Form.Item label="标签描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Space style="display: flex; width: 100%">
<Form.Item label="排序" style="flex: 1">
<InputNumber
v-model:value="form.sort"
:min="1"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Space>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:商品标签页面样式。 */
.page-product-labels {
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
</style>

View File

@@ -0,0 +1,559 @@
<script setup lang="ts">
/**
* 文件职责:时段供应页面。
* 1. 管理供应时段模板。
* 2. 管理时段模板与商品关联关系。
*/
import type { ProductSwitchStatus } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Divider,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import {
changeProductScheduleStatusApi,
deleteProductScheduleApi,
getProductScheduleListApi,
saveProductScheduleApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
type StatusFilter = '' | ProductSwitchStatus;
interface SlotForm {
id: string;
weekDays: number[];
startTime: string;
endTime: string;
}
interface ScheduleRow {
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
slots: SlotForm[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<ScheduleRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingScheduleId = ref('');
const form = reactive({
name: '',
description: '',
status: 'enabled' as ProductSwitchStatus,
sort: 1,
productIds: [] as string[],
slots: [] as SlotForm[],
});
const weekDayOptions = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 7 },
];
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingScheduleId.value ? '编辑时段' : '新增时段',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载时段列表。 */
async function loadSchedules() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductScheduleListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as ScheduleRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载时段失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 重置表单。 */
function resetForm() {
editingScheduleId.value = '';
form.name = '';
form.description = '';
form.status = 'enabled';
form.sort = rows.value.length + 1;
form.productIds = [];
form.slots = [
{
id: '',
weekDays: [1, 2, 3, 4, 5, 6, 7],
startTime: '09:00',
endTime: '21:00',
},
];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: ScheduleRow) {
editingScheduleId.value = row.id;
form.name = row.name;
form.description = row.description;
form.status = row.status;
form.sort = row.sort;
form.productIds = [...row.productIds];
form.slots = row.slots.map((slot) => ({
...slot,
weekDays: [...slot.weekDays],
}));
if (form.slots.length === 0) {
form.slots = [
{
id: '',
weekDays: [1, 2, 3, 4, 5, 6, 7],
startTime: '09:00',
endTime: '21:00',
},
];
}
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 新增时段行。 */
function addSlot() {
form.slots.push({
id: '',
weekDays: [1, 2, 3, 4, 5, 6, 7],
startTime: '09:00',
endTime: '21:00',
});
}
/** 删除时段行。 */
function removeSlot(index: number) {
if (form.slots.length <= 1) {
message.warning('至少保留一个时段');
return;
}
form.slots.splice(index, 1);
}
/** 保存时段。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入时段名称');
return;
}
if (
form.slots.some(
(slot) =>
slot.weekDays.length === 0 ||
!slot.startTime.trim() ||
!slot.endTime.trim(),
)
) {
message.warning('请完整填写时段信息');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductScheduleApi({
storeId: selectedStoreId.value,
id: editingScheduleId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
status: form.status,
sort: form.sort,
productIds: [...form.productIds],
slots: form.slots.map((slot) => ({
id: slot.id || undefined,
weekDays: [...slot.weekDays],
startTime: slot.startTime.trim(),
endTime: slot.endTime.trim(),
})),
});
message.success(editingScheduleId.value ? '时段已更新' : '时段已创建');
isDrawerOpen.value = false;
await loadSchedules();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除时段模板。 */
function removeSchedule(row: ScheduleRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除时段「${row.name}」吗?`,
async onOk() {
await deleteProductScheduleApi({
storeId: selectedStoreId.value,
scheduleId: row.id,
});
message.success('时段已删除');
await loadSchedules();
},
});
}
/** 切换时段状态。 */
async function toggleScheduleStatus(row: ScheduleRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductScheduleStatusApi({
storeId: selectedStoreId.value,
scheduleId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 格式化星期展示文案。 */
function formatWeekDays(weekDays: number[]) {
const map: Record<number, string> = {
1: '一',
2: '二',
3: '三',
4: '四',
5: '五',
6: '六',
7: '日',
};
return [...weekDays]
.toSorted((a, b) => a - b)
.map((day) => map[day] || day)
.join('/');
}
/** 重置筛选。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadSchedules();
}
watch(selectedStoreId, loadSchedules);
onMounted(loadStores);
</script>
<template>
<Page title="时段供应" content-class="space-y-4 page-product-schedule">
<Card :bordered="false">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索时段名称"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 140px"
/>
<Button type="primary" @click="loadSchedules">查询</Button>
<Button @click="resetFilters">重置</Button>
<Button type="primary" @click="openCreateDrawer">新增时段</Button>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column
title="时段名称"
data-index="name"
key="name"
:width="180"
/>
<Table.Column title="供应时段" key="slots">
<template #default="{ record }">
<Space direction="vertical" size="small">
<Tag
v-for="slot in record.slots"
:key="slot.id || `${slot.startTime}-${slot.endTime}`"
>
{{ formatWeekDays(slot.weekDays) }} {{ slot.startTime }}-{{
slot.endTime
}}
</Tag>
</Space>
</template>
</Table.Column>
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="
(checked) => toggleScheduleStatus(record, checked === true)
"
/>
</template>
</Table.Column>
<Table.Column
title="更新时间"
data-index="updatedAt"
key="updatedAt"
:width="170"
/>
<Table.Column title="操作" key="action" :width="170">
<template #default="{ record }">
<Space size="small">
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
<Button danger size="small" @click="removeSchedule(record)">
删除
</Button>
</Space>
</template>
</Table.Column>
</Table>
</Card>
<Drawer
:open="isDrawerOpen"
:title="drawerTitle"
width="660"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="时段名称" required>
<Input v-model:value="form.name" :maxlength="30" show-count />
</Form.Item>
<Form.Item label="时段描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Divider orientation="left">时段配置</Divider>
<div v-for="(slot, index) in form.slots" :key="index" class="slot-row">
<Select
v-model:value="slot.weekDays"
mode="multiple"
:options="weekDayOptions"
style="width: 220px"
placeholder="选择星期"
/>
<Input
v-model:value="slot.startTime"
style="width: 110px"
placeholder="开始 HH:mm"
/>
<Input
v-model:value="slot.endTime"
style="width: 110px"
placeholder="结束 HH:mm"
/>
<Button danger @click="removeSlot(index)">删除</Button>
</div>
<Button @click="addSlot">新增时段</Button>
<Divider />
<Space style="display: flex; width: 100%">
<Form.Item label="排序" style="flex: 1">
<InputNumber
v-model:value="form.sort"
:min="1"
style="width: 100%"
/>
</Form.Item>
<Form.Item label="状态" style="flex: 1">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Space>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:时段供应页面样式。 */
.page-product-schedule {
.slot-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
</style>

View File

@@ -0,0 +1,502 @@
<script setup lang="ts">
/**
* 文件职责:规格做法管理页面。
* 1. 管理规格模板与规格值。
* 2. 管理规格与商品关联关系。
*/
import type { ProductSwitchStatus } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Divider,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import {
changeProductSpecStatusApi,
deleteProductSpecApi,
getProductSpecListApi,
saveProductSpecApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
type StatusFilter = '' | ProductSwitchStatus;
interface SpecValueForm {
id: string;
name: string;
extraPrice: number;
sort: number;
}
interface SpecRow {
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
values: SpecValueForm[];
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<SpecRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingSpecId = ref('');
const form = reactive({
name: '',
description: '',
status: 'enabled' as ProductSwitchStatus,
sort: 1,
productIds: [] as string[],
values: [] as SpecValueForm[],
});
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingSpecId.value ? '编辑规格' : '新增规格',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载规格列表。 */
async function loadSpecs() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductSpecListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as SpecRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载规格失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 初始化默认表单。 */
function resetForm() {
editingSpecId.value = '';
form.name = '';
form.description = '';
form.status = 'enabled';
form.sort = rows.value.length + 1;
form.productIds = [];
form.values = [
{
id: '',
name: '',
extraPrice: 0,
sort: 1,
},
];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: SpecRow) {
editingSpecId.value = row.id;
form.name = row.name;
form.description = row.description;
form.status = row.status;
form.sort = row.sort;
form.productIds = [...row.productIds];
form.values = row.values.map((item) => ({ ...item }));
if (form.values.length === 0) {
form.values = [{ id: '', name: '', extraPrice: 0, sort: 1 }];
}
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 增加规格值行。 */
function addSpecValue() {
form.values.push({
id: '',
name: '',
extraPrice: 0,
sort: form.values.length + 1,
});
}
/** 删除规格值行。 */
function removeSpecValue(index: number) {
if (form.values.length <= 1) {
message.warning('至少保留一个规格值');
return;
}
form.values.splice(index, 1);
form.values.forEach((item, idx) => {
item.sort = idx + 1;
});
}
/** 保存规格。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入规格名称');
return;
}
if (form.values.some((item) => !item.name.trim())) {
message.warning('规格值名称不能为空');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductSpecApi({
storeId: selectedStoreId.value,
id: editingSpecId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
status: form.status,
sort: form.sort,
productIds: [...form.productIds],
values: form.values.map((item) => ({
id: item.id || undefined,
name: item.name.trim(),
extraPrice: item.extraPrice,
sort: item.sort,
})),
});
message.success(editingSpecId.value ? '规格已更新' : '规格已创建');
isDrawerOpen.value = false;
await loadSpecs();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除规格。 */
function removeSpec(row: SpecRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除规格「${row.name}」吗?`,
async onOk() {
await deleteProductSpecApi({
storeId: selectedStoreId.value,
specId: row.id,
});
message.success('规格已删除');
await loadSpecs();
},
});
}
/** 切换规格启停状态。 */
async function toggleSpecStatus(row: SpecRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductSpecStatusApi({
storeId: selectedStoreId.value,
specId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 重置筛选。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadSpecs();
}
watch(selectedStoreId, loadSpecs);
onMounted(loadStores);
</script>
<template>
<Page title="规格做法" content-class="space-y-4 page-product-specs">
<Card :bordered="false">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
/>
<Input
v-model:value="keyword"
style="width: 220px"
placeholder="搜索规格名称"
/>
<Select
v-model:value="statusFilter"
:options="statusOptions"
style="width: 140px"
/>
<Button type="primary" @click="loadSpecs">查询</Button>
<Button @click="resetFilters">重置</Button>
<Button type="primary" @click="openCreateDrawer">新增规格</Button>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column
title="规格名称"
data-index="name"
key="name"
:width="160"
/>
<Table.Column title="规格值" key="values">
<template #default="{ record }">
<Space wrap size="small">
<Tag v-for="value in record.values" :key="value.id || value.name">
{{ value.name }}
<template v-if="value.extraPrice">
+{{ value.extraPrice }}
</template>
</Tag>
</Space>
</template>
</Table.Column>
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="排序" data-index="sort" key="sort" :width="80" />
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="(checked) => toggleSpecStatus(record, checked === true)"
/>
</template>
</Table.Column>
<Table.Column
title="更新时间"
data-index="updatedAt"
key="updatedAt"
:width="170"
/>
<Table.Column title="操作" key="action" :width="170">
<template #default="{ record }">
<Space size="small">
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
<Button danger size="small" @click="removeSpec(record)">
删除
</Button>
</Space>
</template>
</Table.Column>
</Table>
</Card>
<Drawer
:open="isDrawerOpen"
:title="drawerTitle"
width="560"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="规格名称" required>
<Input v-model:value="form.name" :maxlength="20" show-count />
</Form.Item>
<Form.Item label="规格描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Divider orientation="left">规格值</Divider>
<div
v-for="(item, index) in form.values"
:key="index"
class="spec-value-row"
>
<Input v-model:value="item.name" placeholder="规格值名称,如:微辣" />
<InputNumber
v-model:value="item.extraPrice"
:step="0.5"
style="width: 120px"
placeholder="加价"
/>
<Button danger @click="removeSpecValue(index)">删除</Button>
</div>
<Button @click="addSpecValue">新增规格值</Button>
<Divider />
<Form.Item label="排序">
<InputNumber v-model:value="form.sort" :min="1" style="width: 100%" />
</Form.Item>
<Form.Item label="状态">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:规格做法页面样式。 */
.page-product-specs {
.spec-value-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
</style>

View File

@@ -14,8 +14,10 @@ interface StoreOption {
interface Props {
copyButtonText?: string;
copyPosition?: 'left' | 'right';
copyDisabled?: boolean;
isStoreLoading: boolean;
showCopyButton?: boolean;
selectedStoreId: string;
storeOptions: StoreOption[];
storePlaceholder?: string;
@@ -23,7 +25,9 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
copyButtonText: '复制到其他门店',
copyPosition: 'right',
copyDisabled: false,
showCopyButton: true,
storePlaceholder: '请选择门店',
});
@@ -54,10 +58,22 @@ function handleStoreChange(value: unknown) {
:disabled="props.isStoreLoading || props.storeOptions.length === 0"
@update:value="(value) => handleStoreChange(value)"
/>
<div class="store-scope-spacer"></div>
<Button :disabled="props.copyDisabled" @click="emit('copy')">
<Button
v-if="props.showCopyButton && props.copyPosition === 'left'"
:disabled="props.copyDisabled"
@click="emit('copy')"
>
{{ props.copyButtonText }}
</Button>
<div class="store-scope-spacer"></div>
<Button
v-if="props.showCopyButton && props.copyPosition === 'right'"
:disabled="props.copyDisabled"
@click="emit('copy')"
>
{{ props.copyButtonText }}
</Button>
<slot name="actions"></slot>
</div>
</Card>
</template>

View File

@@ -8,8 +8,8 @@ import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import DeliveryCommonSettingsCard from './components/DeliveryCommonSettingsCard.vue';
import DeliveryModeCard from './components/DeliveryModeCard.vue';
import DeliveryTierDrawer from './components/DeliveryTierDrawer.vue';

View File

@@ -10,8 +10,8 @@ import { Page } from '@vben/common-ui';
import { Card, Empty, message, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import DineInAreaDrawer from './components/DineInAreaDrawer.vue';
import DineInAreaSection from './components/DineInAreaSection.vue';
import DineInBasicSettingsCard from './components/DineInBasicSettingsCard.vue';

View File

@@ -10,8 +10,8 @@ import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import FeesDeliveryCard from './components/FeesDeliveryCard.vue';
import FeesOtherCard from './components/FeesOtherCard.vue';
import FeesPackagingCard from './components/FeesPackagingCard.vue';

View File

@@ -3,8 +3,8 @@ import { Page } from '@vben/common-ui';
import { Button, Card, Empty, Popconfirm, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import AddSlotDrawer from './components/AddSlotDrawer.vue';
import DayEditDrawer from './components/DayEditDrawer.vue';
import HolidayDrawer from './components/HolidayDrawer.vue';

View File

@@ -8,8 +8,8 @@ import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import PickupBasicSettingsCard from './components/PickupBasicSettingsCard.vue';
import PickupBigSlotSection from './components/PickupBigSlotSection.vue';
import PickupFineRuleSection from './components/PickupFineRuleSection.vue';

View File

@@ -8,8 +8,8 @@ import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
import ShiftTemplateCard from './components/ShiftTemplateCard.vue';
import StaffEditorDrawer from './components/StaffEditorDrawer.vue';
import StaffFilterBar from './components/StaffFilterBar.vue';

View File

@@ -163,16 +163,26 @@ const adjustCropperToAspectRatio = () => {
const containerWidthVal = containerWidth.value;
const containerHeightVal = containerHeight.value;
// 根据比例计算裁剪框尺寸
let newHeight: number, newWidth: number;
// 有固定比例时保留默认留白,避免初始裁剪框占满导致拖拽不便
const padding = Math.min(
CROPPER_CONSTANTS.MAX_PADDING,
Math.floor(containerWidthVal * CROPPER_CONSTANTS.PADDING_RATIO),
Math.floor(containerHeightVal * CROPPER_CONSTANTS.PADDING_RATIO),
);
const maxCropWidth = Math.max(
CROPPER_CONSTANTS.MIN_WIDTH,
containerWidthVal - padding * 2,
);
const maxCropHeight = Math.max(
CROPPER_CONSTANTS.MIN_HEIGHT,
containerHeightVal - padding * 2,
);
// 先按宽度优先计算
newWidth = containerWidthVal;
newHeight = newWidth / ratio;
// 如果高度超出容器,按高度优先计算
if (newHeight > containerHeightVal) {
newHeight = containerHeightVal;
// 根据比例在可用区域内计算裁剪框尺寸
let newWidth = maxCropWidth;
let newHeight = newWidth / ratio;
if (newHeight > maxCropHeight) {
newHeight = maxCropHeight;
newWidth = newHeight * ratio;
}