feat: 重构规格做法页并对齐原型抽屉样式
This commit is contained in:
@@ -22,6 +22,12 @@ export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm';
|
|||||||
/** 通用启停状态。 */
|
/** 通用启停状态。 */
|
||||||
export type ProductSwitchStatus = 'disabled' | 'enabled';
|
export type ProductSwitchStatus = 'disabled' | 'enabled';
|
||||||
|
|
||||||
|
/** 规格做法模板类型。 */
|
||||||
|
export type ProductSpecType = 'method' | 'spec';
|
||||||
|
|
||||||
|
/** 规格做法选择方式。 */
|
||||||
|
export type ProductSpecSelectionType = 'multi' | 'single';
|
||||||
|
|
||||||
/** 商品选择器项。 */
|
/** 商品选择器项。 */
|
||||||
export interface ProductPickerItemDto {
|
export interface ProductPickerItemDto {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
@@ -242,13 +248,15 @@ export interface ProductSpecValueDto {
|
|||||||
|
|
||||||
/** 规格配置。 */
|
/** 规格配置。 */
|
||||||
export interface ProductSpecDto {
|
export interface ProductSpecDto {
|
||||||
description: string;
|
|
||||||
id: string;
|
id: string;
|
||||||
|
isRequired: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
productCount: number;
|
productCount: number;
|
||||||
productIds: string[];
|
productIds: string[];
|
||||||
|
selectionType: ProductSpecSelectionType;
|
||||||
sort: number;
|
sort: number;
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
|
type: ProductSpecType;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
values: ProductSpecValueDto[];
|
values: ProductSpecValueDto[];
|
||||||
}
|
}
|
||||||
@@ -258,17 +266,20 @@ export interface ProductSpecQuery {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
status?: ProductSwitchStatus;
|
status?: ProductSwitchStatus;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
|
type?: ProductSpecType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存规格参数。 */
|
/** 保存规格参数。 */
|
||||||
export interface SaveProductSpecDto {
|
export interface SaveProductSpecDto {
|
||||||
description: string;
|
|
||||||
id?: string;
|
id?: string;
|
||||||
|
isRequired: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
productIds: string[];
|
productIds: string[];
|
||||||
|
selectionType: ProductSpecSelectionType;
|
||||||
sort: number;
|
sort: number;
|
||||||
status: ProductSwitchStatus;
|
status: ProductSwitchStatus;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
|
type: ProductSpecType;
|
||||||
values: Array<{
|
values: Array<{
|
||||||
extraPrice: number;
|
extraPrice: number;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -290,6 +301,13 @@ export interface ChangeProductSpecStatusDto {
|
|||||||
storeId: string;
|
storeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复制规格参数。 */
|
||||||
|
export interface CopyProductSpecDto {
|
||||||
|
newName?: string;
|
||||||
|
specId: string;
|
||||||
|
storeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 加料项。 */
|
/** 加料项。 */
|
||||||
export interface ProductAddonItemDto {
|
export interface ProductAddonItemDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -641,6 +659,11 @@ export async function changeProductSpecStatusApi(
|
|||||||
return requestClient.post('/product/spec/status', data);
|
return requestClient.post('/product/spec/status', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复制规格模板。 */
|
||||||
|
export async function copyProductSpecApi(data: CopyProductSpecDto) {
|
||||||
|
return requestClient.post<ProductSpecDto>('/product/spec/copy', data);
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取加料组列表。 */
|
/** 获取加料组列表。 */
|
||||||
export async function getProductAddonGroupListApi(params: ProductAddonQuery) {
|
export async function getProductAddonGroupListApi(params: ProductAddonQuery) {
|
||||||
return requestClient.get<ProductAddonGroupDto[]>(
|
return requestClient.get<ProductAddonGroupDto[]>(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:分类管理常量与基础转换工具。
|
||||||
|
* 1. 统一渠道字段标准化逻辑。
|
||||||
|
* 2. 避免页面编排层重复处理映射细节。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ProductCategoryChannel,
|
||||||
|
ProductCategoryManageDto,
|
||||||
|
} from '#/api/product';
|
||||||
|
|
||||||
|
import { CATEGORY_CHANNEL_ORDER } from '../../types';
|
||||||
|
|
||||||
|
export function normalizeCategoryChannels(
|
||||||
|
channels: ProductCategoryManageDto['channels'],
|
||||||
|
) {
|
||||||
|
const source = Array.isArray(channels)
|
||||||
|
? (channels as Array<'ts' | 'zt' | ProductCategoryChannel>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalized = source
|
||||||
|
.map((channel) => {
|
||||||
|
if (channel === 'zt') return 'pickup';
|
||||||
|
if (channel === 'ts') return 'dine_in';
|
||||||
|
return channel;
|
||||||
|
})
|
||||||
|
.filter((channel): channel is ProductCategoryChannel =>
|
||||||
|
CATEGORY_CHANNEL_ORDER.includes(channel as ProductCategoryChannel),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unique = [...new Set(normalized)];
|
||||||
|
if (unique.length > 0) {
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:分类管理复制弹窗动作。
|
||||||
|
* 1. 维护复制目标门店选中状态。
|
||||||
|
* 2. 管理复制弹窗开关与提交行为。
|
||||||
|
*/
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
interface CreateCopyActionsOptions {
|
||||||
|
copyCandidates: ComputedRef<StoreListItemDto[]>;
|
||||||
|
copyTargetStoreIds: Ref<string[]>;
|
||||||
|
isCopyModalOpen: Ref<boolean>;
|
||||||
|
isCopySubmitting: Ref<boolean>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCopyActions(options: CreateCopyActionsOptions) {
|
||||||
|
function openCopyModal() {
|
||||||
|
if (!options.selectedStoreId.value) {
|
||||||
|
message.warning('请先选择门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.copyTargetStoreIds.value = [];
|
||||||
|
options.isCopyModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCopyModalOpen(value: boolean) {
|
||||||
|
options.isCopyModalOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyCheckAll(checked: boolean) {
|
||||||
|
options.copyTargetStoreIds.value = checked
|
||||||
|
? options.copyCandidates.value.map((item) => item.id)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCopyStore(storeId: string, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
options.copyTargetStoreIds.value = [
|
||||||
|
...new Set([storeId, ...options.copyTargetStoreIds.value]),
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.copyTargetStoreIds.value = options.copyTargetStoreIds.value.filter(
|
||||||
|
(item) => item !== storeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopySubmit() {
|
||||||
|
if (options.copyTargetStoreIds.value.length === 0) {
|
||||||
|
message.warning('请至少选择一个目标门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.isCopySubmitting.value = true;
|
||||||
|
try {
|
||||||
|
message.info('复制到其他门店功能开发中');
|
||||||
|
options.isCopyModalOpen.value = false;
|
||||||
|
options.copyTargetStoreIds.value = [];
|
||||||
|
} finally {
|
||||||
|
options.isCopySubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleCopyCheckAll,
|
||||||
|
handleCopySubmit,
|
||||||
|
openCopyModal,
|
||||||
|
setCopyModalOpen,
|
||||||
|
toggleCopyStore,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { ProductCategoryManageDto } from '#/api/product';
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
import type { ProductPreviewItem } from '#/views/product/category/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:分类管理数据动作。
|
||||||
|
* 1. 加载门店列表、分类列表与分类商品数据。
|
||||||
|
* 2. 处理门店/分类切换时的数据一致性。
|
||||||
|
*/
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getProductCategoryManageListApi,
|
||||||
|
searchProductPickerApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
import { getStoreListApi } from '#/api/store';
|
||||||
|
import { deriveMonthlySales } from '#/views/product/category/types';
|
||||||
|
|
||||||
|
import { normalizeCategoryChannels } from './constants';
|
||||||
|
|
||||||
|
interface CreateDataActionsOptions {
|
||||||
|
categories: Ref<ProductCategoryManageDto[]>;
|
||||||
|
categoryProducts: Ref<ProductPreviewItem[]>;
|
||||||
|
isCategoryLoading: Ref<boolean>;
|
||||||
|
isCategoryProductsLoading: Ref<boolean>;
|
||||||
|
isStoreLoading: Ref<boolean>;
|
||||||
|
selectedCategoryId: Ref<string>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
stores: Ref<StoreListItemDto[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDataActions(options: CreateDataActionsOptions) {
|
||||||
|
async function loadStores() {
|
||||||
|
options.isStoreLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getStoreListApi({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
});
|
||||||
|
options.stores.value = result.items ?? [];
|
||||||
|
if (options.stores.value.length === 0) {
|
||||||
|
options.selectedStoreId.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelected = options.stores.value.some(
|
||||||
|
(item) => item.id === options.selectedStoreId.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasSelected) {
|
||||||
|
options.selectedStoreId.value = options.stores.value[0]?.id ?? '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error('加载门店失败');
|
||||||
|
} finally {
|
||||||
|
options.isStoreLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategoryProducts() {
|
||||||
|
if (!options.selectedStoreId.value || !options.selectedCategoryId.value) {
|
||||||
|
options.categoryProducts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isCategoryProductsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const list = await searchProductPickerApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
categoryId: options.selectedCategoryId.value,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
options.categoryProducts.value = list.map((item) => ({
|
||||||
|
...item,
|
||||||
|
monthlySales: deriveMonthlySales(item.id),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
options.categoryProducts.value = [];
|
||||||
|
message.error('加载分类商品失败');
|
||||||
|
} finally {
|
||||||
|
options.isCategoryProductsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategories(preferredCategoryId = '') {
|
||||||
|
if (!options.selectedStoreId.value) {
|
||||||
|
options.categories.value = [];
|
||||||
|
options.selectedCategoryId.value = '';
|
||||||
|
options.categoryProducts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isCategoryLoading.value = true;
|
||||||
|
const previousCategoryId = options.selectedCategoryId.value;
|
||||||
|
try {
|
||||||
|
const result = await getProductCategoryManageListApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
options.categories.value = [...result]
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
channels: normalizeCategoryChannels(item.channels),
|
||||||
|
}))
|
||||||
|
.toSorted((a, b) => a.sort - b.sort);
|
||||||
|
|
||||||
|
const nextCategoryId =
|
||||||
|
(preferredCategoryId &&
|
||||||
|
options.categories.value.some((item) => item.id === preferredCategoryId)
|
||||||
|
? preferredCategoryId
|
||||||
|
: '') ||
|
||||||
|
(previousCategoryId &&
|
||||||
|
options.categories.value.some((item) => item.id === previousCategoryId)
|
||||||
|
? previousCategoryId
|
||||||
|
: '') ||
|
||||||
|
options.categories.value[0]?.id ||
|
||||||
|
'';
|
||||||
|
|
||||||
|
options.selectedCategoryId.value = nextCategoryId;
|
||||||
|
|
||||||
|
if (nextCategoryId && nextCategoryId === previousCategoryId) {
|
||||||
|
await loadCategoryProducts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
options.categories.value = [];
|
||||||
|
options.selectedCategoryId.value = '';
|
||||||
|
options.categoryProducts.value = [];
|
||||||
|
message.error('加载分类失败');
|
||||||
|
} finally {
|
||||||
|
options.isCategoryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadCategories,
|
||||||
|
loadCategoryProducts,
|
||||||
|
loadStores,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { ProductCategoryManageDto } from '#/api/product';
|
||||||
|
import type {
|
||||||
|
CategoryFormModel,
|
||||||
|
DrawerMode,
|
||||||
|
} from '#/views/product/category/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:分类编辑抽屉动作。
|
||||||
|
* 1. 管理新增/编辑抽屉与表单状态。
|
||||||
|
* 2. 提交保存与删除分类操作。
|
||||||
|
*/
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteProductCategoryApi,
|
||||||
|
saveProductCategoryApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
import { CATEGORY_CHANNEL_ORDER } from '#/views/product/category/types';
|
||||||
|
|
||||||
|
import { normalizeCategoryChannels } from './constants';
|
||||||
|
|
||||||
|
interface CreateDrawerActionsOptions {
|
||||||
|
categories: Ref<ProductCategoryManageDto[]>;
|
||||||
|
drawerMode: Ref<DrawerMode>;
|
||||||
|
form: CategoryFormModel;
|
||||||
|
isDrawerOpen: Ref<boolean>;
|
||||||
|
isDrawerSubmitting: Ref<boolean>;
|
||||||
|
loadCategories: (preferredCategoryId?: string) => Promise<void>;
|
||||||
|
selectedCategory: ComputedRef<ProductCategoryManageDto | undefined>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||||
|
function resetDrawerForm() {
|
||||||
|
options.form.id = '';
|
||||||
|
options.form.name = '';
|
||||||
|
options.form.description = '';
|
||||||
|
options.form.icon = '';
|
||||||
|
options.form.channels = [...CATEGORY_CHANNEL_ORDER];
|
||||||
|
options.form.sort = options.categories.value.length + 1;
|
||||||
|
options.form.status = 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormName(value: string) {
|
||||||
|
options.form.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormDescription(value: string) {
|
||||||
|
options.form.description = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormIcon(value: string) {
|
||||||
|
options.form.icon = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormSort(value: number) {
|
||||||
|
options.form.sort =
|
||||||
|
Number.isFinite(value) && value > 0 ? Math.floor(value) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer() {
|
||||||
|
options.drawerMode.value = 'create';
|
||||||
|
resetDrawerForm();
|
||||||
|
options.isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer() {
|
||||||
|
const current = options.selectedCategory.value;
|
||||||
|
if (!current) {
|
||||||
|
message.warning('请先选择分类');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.drawerMode.value = 'edit';
|
||||||
|
options.form.id = current.id;
|
||||||
|
options.form.name = current.name;
|
||||||
|
options.form.description = current.description;
|
||||||
|
options.form.icon = current.icon;
|
||||||
|
options.form.channels = normalizeCategoryChannels(current.channels);
|
||||||
|
options.form.sort = current.sort;
|
||||||
|
options.form.status = current.status;
|
||||||
|
options.isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDrawerOpen(value: boolean) {
|
||||||
|
options.isDrawerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDrawerChannel(channel: CategoryFormModel['channels'][number]) {
|
||||||
|
if (options.form.channels.includes(channel)) {
|
||||||
|
options.form.channels = options.form.channels.filter(
|
||||||
|
(item) => item !== channel,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.form.channels = [...options.form.channels, channel];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDrawerStatus() {
|
||||||
|
options.form.status =
|
||||||
|
options.form.status === 'enabled' ? 'disabled' : 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDrawer() {
|
||||||
|
if (!options.selectedStoreId.value) return;
|
||||||
|
if (!options.form.name.trim()) {
|
||||||
|
message.warning('请输入分类名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.form.channels.length === 0) {
|
||||||
|
message.warning('请至少选择一个销售渠道');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isDrawerSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const result = await saveProductCategoryApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
id: options.form.id || undefined,
|
||||||
|
name: options.form.name.trim(),
|
||||||
|
description: options.form.description.trim(),
|
||||||
|
icon: options.form.icon.trim() || 'lucide:folder',
|
||||||
|
channels: [...options.form.channels],
|
||||||
|
sort: options.form.sort,
|
||||||
|
status: options.form.status,
|
||||||
|
});
|
||||||
|
message.success(
|
||||||
|
options.drawerMode.value === 'create' ? '分类添加成功' : '保存成功',
|
||||||
|
);
|
||||||
|
options.isDrawerOpen.value = false;
|
||||||
|
await options.loadCategories(result.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
options.isDrawerSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedCategory() {
|
||||||
|
const current = options.selectedCategory.value;
|
||||||
|
if (!options.selectedStoreId.value || !current) return;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认删除分类「${current.name}」吗?`,
|
||||||
|
content: '若分类下还有商品会删除失败。',
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
await deleteProductCategoryApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
categoryId: current.id,
|
||||||
|
});
|
||||||
|
message.success('分类已删除');
|
||||||
|
await options.loadCategories();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
removeSelectedCategory,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormDescription,
|
||||||
|
setFormIcon,
|
||||||
|
setFormName,
|
||||||
|
setFormSort,
|
||||||
|
submitDrawer,
|
||||||
|
toggleDrawerChannel,
|
||||||
|
toggleDrawerStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProductCategoryManageDto,
|
||||||
|
ProductPickerItemDto,
|
||||||
|
} from '#/api/product';
|
||||||
|
import type { ProductPreviewItem } from '#/views/product/category/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:分类商品绑定动作。
|
||||||
|
* 1. 管理商品选择弹窗与关键字检索。
|
||||||
|
* 2. 执行商品绑定与解绑操作。
|
||||||
|
*/
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bindCategoryProductsApi,
|
||||||
|
searchProductPickerApi,
|
||||||
|
unbindCategoryProductApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
|
||||||
|
interface CreatePickerActionsOptions {
|
||||||
|
categoryProducts: Ref<ProductPreviewItem[]>;
|
||||||
|
isPickerLoading: Ref<boolean>;
|
||||||
|
isPickerOpen: Ref<boolean>;
|
||||||
|
isPickerSubmitting: Ref<boolean>;
|
||||||
|
loadCategories: (preferredCategoryId?: string) => Promise<void>;
|
||||||
|
pickerKeyword: Ref<string>;
|
||||||
|
pickerProducts: Ref<ProductPickerItemDto[]>;
|
||||||
|
pickerSelectedIds: Ref<string[]>;
|
||||||
|
selectedCategory: ComputedRef<ProductCategoryManageDto | undefined>;
|
||||||
|
selectedStoreId: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPickerActions(options: CreatePickerActionsOptions) {
|
||||||
|
async function openProductPicker() {
|
||||||
|
if (!options.selectedStoreId.value || !options.selectedCategory.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.pickerKeyword.value = '';
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
options.pickerSelectedIds.value = [];
|
||||||
|
options.isPickerOpen.value = true;
|
||||||
|
await loadPickerProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerOpen(value: boolean) {
|
||||||
|
options.isPickerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerKeyword(value: string) {
|
||||||
|
options.pickerKeyword.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPickerProducts() {
|
||||||
|
if (!options.selectedStoreId.value || !options.selectedCategory.value) {
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isPickerLoading.value = true;
|
||||||
|
try {
|
||||||
|
const list = await searchProductPickerApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
keyword: options.pickerKeyword.value.trim() || undefined,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
const currentIds = new Set(
|
||||||
|
options.categoryProducts.value.map((item) => item.id),
|
||||||
|
);
|
||||||
|
options.pickerProducts.value = list.filter(
|
||||||
|
(item) => !currentIds.has(item.id),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
options.pickerProducts.value = [];
|
||||||
|
message.error('加载可选商品失败');
|
||||||
|
} finally {
|
||||||
|
options.isPickerLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePickerProduct(id: string) {
|
||||||
|
if (options.pickerSelectedIds.value.includes(id)) {
|
||||||
|
options.pickerSelectedIds.value = options.pickerSelectedIds.value.filter(
|
||||||
|
(item) => item !== id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.pickerSelectedIds.value = [...options.pickerSelectedIds.value, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProductPicker() {
|
||||||
|
const current = options.selectedCategory.value;
|
||||||
|
if (!options.selectedStoreId.value || !current) return;
|
||||||
|
if (options.pickerSelectedIds.value.length === 0) {
|
||||||
|
message.warning('请至少选择一个商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.isPickerSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await bindCategoryProductsApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
categoryId: current.id,
|
||||||
|
productIds: [...options.pickerSelectedIds.value],
|
||||||
|
});
|
||||||
|
message.success('商品已添加到分类');
|
||||||
|
options.isPickerOpen.value = false;
|
||||||
|
await options.loadCategories(current.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
options.isPickerSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindProduct(item: ProductPreviewItem) {
|
||||||
|
const current = options.selectedCategory.value;
|
||||||
|
if (!options.selectedStoreId.value || !current) return;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认将「${item.name}」移出当前分类吗?`,
|
||||||
|
content: '移出后商品会自动归入其他分类。',
|
||||||
|
okText: '确认移出',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
await unbindCategoryProductApi({
|
||||||
|
storeId: options.selectedStoreId.value,
|
||||||
|
categoryId: current.id,
|
||||||
|
productId: item.id,
|
||||||
|
});
|
||||||
|
message.success('商品已移出');
|
||||||
|
await options.loadCategories(current.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadPickerProducts,
|
||||||
|
openProductPicker,
|
||||||
|
setPickerKeyword,
|
||||||
|
setPickerOpen,
|
||||||
|
submitProductPicker,
|
||||||
|
togglePickerProduct,
|
||||||
|
unbindProduct,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,12 +8,11 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件职责:分类管理页面状态与行为聚合。
|
* 文件职责:分类管理页面状态与行为编排。
|
||||||
* 1. 管理门店、分类、商品预览、抽屉与弹窗状态。
|
* 1. 维护页面状态、衍生视图数据与监听器。
|
||||||
* 2. 统一封装分类 CRUD 与商品绑定/解绑流程。
|
* 2. 组合数据、抽屉、商品选择、复制等动作模块。
|
||||||
*/
|
*/
|
||||||
import type {
|
import type {
|
||||||
ProductCategoryChannel,
|
|
||||||
ProductCategoryManageDto,
|
ProductCategoryManageDto,
|
||||||
ProductPickerItemDto,
|
ProductPickerItemDto,
|
||||||
} from '#/api/product';
|
} from '#/api/product';
|
||||||
@@ -21,44 +20,11 @@ import type { StoreListItemDto } from '#/api/store';
|
|||||||
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { message, Modal } from 'ant-design-vue';
|
import { CATEGORY_CHANNEL_ORDER } from '../types';
|
||||||
|
import { createCopyActions } from './category-page/copy-actions';
|
||||||
import {
|
import { createDataActions } from './category-page/data-actions';
|
||||||
bindCategoryProductsApi,
|
import { createDrawerActions } from './category-page/drawer-actions';
|
||||||
deleteProductCategoryApi,
|
import { createPickerActions } from './category-page/picker-actions';
|
||||||
getProductCategoryManageListApi,
|
|
||||||
saveProductCategoryApi,
|
|
||||||
searchProductPickerApi,
|
|
||||||
unbindCategoryProductApi,
|
|
||||||
} from '#/api/product';
|
|
||||||
import { getStoreListApi } from '#/api/store';
|
|
||||||
|
|
||||||
import { CATEGORY_CHANNEL_ORDER, deriveMonthlySales } from '../types';
|
|
||||||
|
|
||||||
function normalizeCategoryChannels(
|
|
||||||
channels: ProductCategoryManageDto['channels'],
|
|
||||||
) {
|
|
||||||
const source = Array.isArray(channels)
|
|
||||||
? (channels as Array<'ts' | 'zt' | ProductCategoryChannel>)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const normalized = source
|
|
||||||
.map((channel) => {
|
|
||||||
if (channel === 'zt') return 'pickup';
|
|
||||||
if (channel === 'ts') return 'dine_in';
|
|
||||||
return channel;
|
|
||||||
})
|
|
||||||
.filter((channel): channel is ProductCategoryChannel =>
|
|
||||||
CATEGORY_CHANNEL_ORDER.includes(channel as ProductCategoryChannel),
|
|
||||||
);
|
|
||||||
|
|
||||||
const unique = [...new Set(normalized)];
|
|
||||||
if (unique.length > 0) {
|
|
||||||
return unique;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分类管理页面组合式状态。 */
|
/** 分类管理页面组合式状态。 */
|
||||||
export function useProductCategoryPage() {
|
export function useProductCategoryPage() {
|
||||||
@@ -185,105 +151,75 @@ export function useProductCategoryPage() {
|
|||||||
copyTargetStoreIds.value.length < copyCandidates.value.length,
|
copyTargetStoreIds.value.length < copyCandidates.value.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
async function loadStores() {
|
const { loadCategories, loadCategoryProducts, loadStores } =
|
||||||
isStoreLoading.value = true;
|
createDataActions({
|
||||||
try {
|
stores,
|
||||||
const result = await getStoreListApi({
|
selectedStoreId,
|
||||||
page: 1,
|
isStoreLoading,
|
||||||
pageSize: 200,
|
categories,
|
||||||
|
isCategoryLoading,
|
||||||
|
selectedCategoryId,
|
||||||
|
categoryProducts,
|
||||||
|
isCategoryProductsLoading,
|
||||||
});
|
});
|
||||||
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 = '') {
|
const {
|
||||||
if (!selectedStoreId.value) {
|
handleCopyCheckAll,
|
||||||
categories.value = [];
|
handleCopySubmit,
|
||||||
selectedCategoryId.value = '';
|
openCopyModal,
|
||||||
categoryProducts.value = [];
|
setCopyModalOpen,
|
||||||
return;
|
toggleCopyStore,
|
||||||
}
|
} = createCopyActions({
|
||||||
|
copyCandidates,
|
||||||
isCategoryLoading.value = true;
|
copyTargetStoreIds,
|
||||||
const previousCategoryId = selectedCategoryId.value;
|
isCopyModalOpen,
|
||||||
try {
|
isCopySubmitting,
|
||||||
const result = await getProductCategoryManageListApi({
|
selectedStoreId,
|
||||||
storeId: selectedStoreId.value,
|
|
||||||
});
|
});
|
||||||
categories.value = [...result]
|
|
||||||
.map((item) => ({
|
|
||||||
...item,
|
|
||||||
channels: normalizeCategoryChannels(item.channels),
|
|
||||||
}))
|
|
||||||
.toSorted((a, b) => a.sort - b.sort);
|
|
||||||
|
|
||||||
const nextCategoryId =
|
const {
|
||||||
(preferredCategoryId &&
|
openCreateDrawer,
|
||||||
categories.value.some((item) => item.id === preferredCategoryId)
|
openEditDrawer,
|
||||||
? preferredCategoryId
|
removeSelectedCategory,
|
||||||
: '') ||
|
setDrawerOpen,
|
||||||
(previousCategoryId &&
|
setFormDescription,
|
||||||
categories.value.some((item) => item.id === previousCategoryId)
|
setFormIcon,
|
||||||
? previousCategoryId
|
setFormName,
|
||||||
: '') ||
|
setFormSort,
|
||||||
categories.value[0]?.id ||
|
submitDrawer,
|
||||||
'';
|
toggleDrawerChannel,
|
||||||
selectedCategoryId.value = nextCategoryId;
|
toggleDrawerStatus,
|
||||||
|
} = createDrawerActions({
|
||||||
if (nextCategoryId && nextCategoryId === previousCategoryId) {
|
categories,
|
||||||
await loadCategoryProducts();
|
drawerMode,
|
||||||
}
|
form,
|
||||||
} catch (error) {
|
isDrawerOpen,
|
||||||
console.error(error);
|
isDrawerSubmitting,
|
||||||
categories.value = [];
|
loadCategories,
|
||||||
selectedCategoryId.value = '';
|
selectedCategory,
|
||||||
categoryProducts.value = [];
|
selectedStoreId,
|
||||||
message.error('加载分类失败');
|
});
|
||||||
} finally {
|
|
||||||
isCategoryLoading.value = false;
|
const {
|
||||||
}
|
loadPickerProducts,
|
||||||
}
|
openProductPicker,
|
||||||
|
setPickerKeyword,
|
||||||
async function loadCategoryProducts() {
|
setPickerOpen,
|
||||||
if (!selectedStoreId.value || !selectedCategoryId.value) {
|
submitProductPicker,
|
||||||
categoryProducts.value = [];
|
togglePickerProduct,
|
||||||
return;
|
unbindProduct,
|
||||||
}
|
} = createPickerActions({
|
||||||
|
categoryProducts,
|
||||||
isCategoryProductsLoading.value = true;
|
isPickerLoading,
|
||||||
try {
|
isPickerOpen,
|
||||||
const list = await searchProductPickerApi({
|
isPickerSubmitting,
|
||||||
storeId: selectedStoreId.value,
|
loadCategories,
|
||||||
categoryId: selectedCategoryId.value,
|
pickerKeyword,
|
||||||
limit: 500,
|
pickerProducts,
|
||||||
|
pickerSelectedIds,
|
||||||
|
selectedCategory,
|
||||||
|
selectedStoreId,
|
||||||
});
|
});
|
||||||
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) {
|
function setSelectedStoreId(value: string) {
|
||||||
selectedStoreId.value = value;
|
selectedStoreId.value = value;
|
||||||
@@ -302,269 +238,6 @@ export function useProductCategoryPage() {
|
|||||||
selectedCategoryId.value = id;
|
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 = normalizeCategoryChannels(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, () => {
|
watch(selectedStoreId, () => {
|
||||||
categoryKeyword.value = '';
|
categoryKeyword.value = '';
|
||||||
channelFilter.value = 'all';
|
channelFilter.value = 'all';
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:规格做法模板编辑抽屉。
|
||||||
|
* 1. 承载模板基本信息与选项编辑表单。
|
||||||
|
* 2. 向父级抛出表单变更与提交事件。
|
||||||
|
*/
|
||||||
|
import type { SpecEditorForm } from '../types';
|
||||||
|
|
||||||
|
import { onBeforeUnmount, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: SpecEditorForm;
|
||||||
|
open: boolean;
|
||||||
|
submitText: string;
|
||||||
|
submitting: boolean;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
addOption: [];
|
||||||
|
close: [];
|
||||||
|
removeOption: [index: number];
|
||||||
|
setName: [value: string];
|
||||||
|
setOptionExtraPrice: [index: number, value: null | number];
|
||||||
|
setOptionName: [index: number, value: string];
|
||||||
|
setRequired: [value: boolean];
|
||||||
|
setSelectionType: [value: SpecEditorForm['selectionType']];
|
||||||
|
setType: [value: SpecEditorForm['type']];
|
||||||
|
submit: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (props.submitting) return;
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBodyLock(locked: boolean) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.body.style.overflow = locked ? 'hidden' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
setBodyLock(open);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
setBodyLock(false);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="g-drawer-mask" :class="{ open }" @click="closeDrawer"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="g-drawer psp-editor-drawer"
|
||||||
|
:class="{ open }"
|
||||||
|
style="width: 520px"
|
||||||
|
>
|
||||||
|
<div class="g-drawer-hd">
|
||||||
|
<span class="g-drawer-title">{{ title }}</span>
|
||||||
|
<button type="button" class="g-drawer-close" @click="closeDrawer">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-drawer-bd">
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">模板名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="g-input"
|
||||||
|
:value="form.name"
|
||||||
|
maxlength="20"
|
||||||
|
placeholder="如:杯型、辣度、甜度"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit('setName', (event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">模板类型</label>
|
||||||
|
<div class="psp-pill-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-pill-btn"
|
||||||
|
:class="{ active: form.type === 'spec' }"
|
||||||
|
@click="emit('setType', 'spec')"
|
||||||
|
>
|
||||||
|
规格
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-pill-btn"
|
||||||
|
:class="{ active: form.type === 'method' }"
|
||||||
|
@click="emit('setType', 'method')"
|
||||||
|
>
|
||||||
|
做法
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="g-hint">规格影响价格和库存,做法仅影响制作方式</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">选择方式</label>
|
||||||
|
<div class="psp-pill-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-pill-btn"
|
||||||
|
:class="{ active: form.selectionType === 'single' }"
|
||||||
|
@click="emit('setSelectionType', 'single')"
|
||||||
|
>
|
||||||
|
单选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-pill-btn"
|
||||||
|
:class="{ active: form.selectionType === 'multi' }"
|
||||||
|
@click="emit('setSelectionType', 'multi')"
|
||||||
|
>
|
||||||
|
多选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label">是否必选</label>
|
||||||
|
<div class="g-toggle-wrap">
|
||||||
|
<label class="g-toggle-input">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="form.isRequired"
|
||||||
|
@change="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setRequired',
|
||||||
|
(event.target as HTMLInputElement).checked,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="g-toggle-sl"></span>
|
||||||
|
</label>
|
||||||
|
<span class="g-toggle-label">{{
|
||||||
|
form.isRequired ? '是' : '否'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-form-group">
|
||||||
|
<label class="g-form-label required">选项列表</label>
|
||||||
|
<div class="psp-opt-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in form.values"
|
||||||
|
:key="index"
|
||||||
|
class="psp-opt-row"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="item.name"
|
||||||
|
placeholder="选项名称,如:大杯"
|
||||||
|
@input="
|
||||||
|
(event) =>
|
||||||
|
emit(
|
||||||
|
'setOptionName',
|
||||||
|
index,
|
||||||
|
(event.target as HTMLInputElement).value,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="psp-price-wrap">
|
||||||
|
<span>¥</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.extraPrice === 0 ? '' : item.extraPrice"
|
||||||
|
placeholder="加价金额"
|
||||||
|
@input="
|
||||||
|
(event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const next = target.value.trim();
|
||||||
|
emit(
|
||||||
|
'setOptionExtraPrice',
|
||||||
|
index,
|
||||||
|
next === '' ? null : Number(next),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-opt-del"
|
||||||
|
@click="emit('removeOption', index)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn psp-btn-dashed"
|
||||||
|
@click="emit('addOption')"
|
||||||
|
>
|
||||||
|
+ 添加选项
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="g-drawer-ft">
|
||||||
|
<button type="button" class="g-btn" @click="closeDrawer">取消</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-btn g-btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ submitText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文件职责:规格做法模板卡片。
|
||||||
|
* 1. 展示模板基础信息、选项与关联商品数。
|
||||||
|
* 2. 提供编辑、复制、删除操作入口。
|
||||||
|
*/
|
||||||
|
import type { ProductSpecDto } from '#/api/product';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: ProductSpecDto;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
copy: [item: ProductSpecDto];
|
||||||
|
edit: [item: ProductSpecDto];
|
||||||
|
remove: [item: ProductSpecDto];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatPrice(extraPrice: number) {
|
||||||
|
if (!extraPrice) return '';
|
||||||
|
const abs = Math.abs(extraPrice);
|
||||||
|
const absText = Number.isInteger(abs)
|
||||||
|
? String(abs)
|
||||||
|
: abs.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
return `${extraPrice > 0 ? '+' : '-'}${absText}元`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit() {
|
||||||
|
emit('edit', props.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy() {
|
||||||
|
emit('copy', props.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove() {
|
||||||
|
emit('remove', props.item);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="psp-card" :class="{ disabled: item.status === 'disabled' }">
|
||||||
|
<div class="psp-card-hd">
|
||||||
|
<span class="name">{{ item.name }}</span>
|
||||||
|
<span
|
||||||
|
class="g-tag"
|
||||||
|
:class="item.type === 'spec' ? 'g-tag-blue' : 'g-tag-orange'"
|
||||||
|
>
|
||||||
|
{{ item.type === 'spec' ? '规格' : '做法' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="item.status === 'disabled'" class="g-tag g-tag-gray">
|
||||||
|
已停用
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="psp-card-meta">
|
||||||
|
{{ item.selectionType === 'single' ? '单选' : '多选' }} ·
|
||||||
|
{{ item.isRequired ? '必选' : '可选' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="psp-pills">
|
||||||
|
<span
|
||||||
|
v-for="value in item.values"
|
||||||
|
:key="value.id || value.name"
|
||||||
|
class="psp-pill"
|
||||||
|
>
|
||||||
|
{{ value.name }}
|
||||||
|
<span v-if="value.extraPrice" class="price">
|
||||||
|
{{ formatPrice(value.extraPrice) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="psp-card-assoc">已关联 {{ item.productCount }} 个商品</div>
|
||||||
|
|
||||||
|
<div class="psp-card-ft">
|
||||||
|
<button type="button" class="g-action" @click="handleEdit">编辑</button>
|
||||||
|
<button type="button" class="g-action" @click="handleCopy">复制</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="g-action g-action-danger"
|
||||||
|
@click="handleRemove"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import type {
|
||||||
|
ProductSpecCardViewModel,
|
||||||
|
SpecEditorForm,
|
||||||
|
SpecEditorValueForm,
|
||||||
|
SpecsTypeFilter,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import type { ProductSpecDto } from '#/api/product';
|
||||||
|
import type { StoreListItemDto } from '#/api/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件职责:规格做法页面状态与行为编排。
|
||||||
|
* 1. 管理门店、模板列表、筛选与统计状态。
|
||||||
|
* 2. 封装模板新增/编辑/删除/复制流程。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
copyProductSpecApi,
|
||||||
|
deleteProductSpecApi,
|
||||||
|
getProductSpecListApi,
|
||||||
|
saveProductSpecApi,
|
||||||
|
} from '#/api/product';
|
||||||
|
import { getStoreListApi } from '#/api/store';
|
||||||
|
|
||||||
|
const DEFAULT_OPTION: SpecEditorValueForm = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
extraPrice: 0,
|
||||||
|
sort: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useProductSpecsPage() {
|
||||||
|
const stores = ref<StoreListItemDto[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const isStoreLoading = ref(false);
|
||||||
|
|
||||||
|
const rows = ref<ProductSpecCardViewModel[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const keyword = ref('');
|
||||||
|
const typeFilter = ref<SpecsTypeFilter>('all');
|
||||||
|
|
||||||
|
const isDrawerOpen = ref(false);
|
||||||
|
const isDrawerSubmitting = ref(false);
|
||||||
|
const drawerMode = ref<'create' | 'edit'>('create');
|
||||||
|
|
||||||
|
const form = reactive<SpecEditorForm>({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: 'spec',
|
||||||
|
selectionType: 'single',
|
||||||
|
isRequired: true,
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
productIds: [],
|
||||||
|
values: [{ ...DEFAULT_OPTION }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeOptions = computed(() =>
|
||||||
|
stores.value.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
if (typeFilter.value === 'all') {
|
||||||
|
return rows.value;
|
||||||
|
}
|
||||||
|
return rows.value.filter((item) => item.type === typeFilter.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const specCount = computed(
|
||||||
|
() => rows.value.filter((item) => item.type === 'spec').length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const methodCount = computed(
|
||||||
|
() => rows.value.filter((item) => item.type === 'method').length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalProducts = computed(() =>
|
||||||
|
rows.value.reduce((sum, item) => sum + item.productCount, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
drawerMode.value === 'create' ? '添加模板' : '编辑模板',
|
||||||
|
);
|
||||||
|
|
||||||
|
const drawerSubmitText = computed(() =>
|
||||||
|
drawerMode.value === 'create' ? '确认添加' : '保存修改',
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
rows.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 list = await getProductSpecListApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
});
|
||||||
|
rows.value = list;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
rows.value = [];
|
||||||
|
message.error('加载模板失败');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedStoreId(value: string) {
|
||||||
|
selectedStoreId.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKeyword(value: string) {
|
||||||
|
keyword.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTypeFilter(value: SpecsTypeFilter) {
|
||||||
|
typeFilter.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDrawerOpen(value: boolean) {
|
||||||
|
isDrawerOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormName(value: string) {
|
||||||
|
form.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormType(value: SpecEditorForm['type']) {
|
||||||
|
form.type = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormSelectionType(value: SpecEditorForm['selectionType']) {
|
||||||
|
form.selectionType = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormIsRequired(value: boolean) {
|
||||||
|
form.isRequired = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptionName(index: number, value: string) {
|
||||||
|
const current = form.values[index];
|
||||||
|
if (!current) return;
|
||||||
|
current.name = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptionExtraPrice(index: number, value: null | number) {
|
||||||
|
const current = form.values[index];
|
||||||
|
if (!current) return;
|
||||||
|
current.extraPrice = Number(value ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption() {
|
||||||
|
form.values.push({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
extraPrice: 0,
|
||||||
|
sort: form.values.length + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(index: number) {
|
||||||
|
if (form.values.length <= 1) {
|
||||||
|
message.warning('至少保留一个选项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.values.splice(index, 1);
|
||||||
|
form.values.forEach((item, idx) => {
|
||||||
|
item.sort = idx + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.id = '';
|
||||||
|
form.name = '';
|
||||||
|
form.type = 'spec';
|
||||||
|
form.selectionType = 'single';
|
||||||
|
form.isRequired = true;
|
||||||
|
form.sort = rows.value.length + 1;
|
||||||
|
form.status = 'enabled';
|
||||||
|
form.productIds = [];
|
||||||
|
form.values = [{ ...DEFAULT_OPTION }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer() {
|
||||||
|
drawerMode.value = 'create';
|
||||||
|
resetForm();
|
||||||
|
isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer(item: ProductSpecDto) {
|
||||||
|
drawerMode.value = 'edit';
|
||||||
|
form.id = item.id;
|
||||||
|
form.name = item.name;
|
||||||
|
form.type = item.type;
|
||||||
|
form.selectionType = item.selectionType;
|
||||||
|
form.isRequired = item.isRequired;
|
||||||
|
form.sort = item.sort;
|
||||||
|
form.status = item.status;
|
||||||
|
form.productIds = [...item.productIds];
|
||||||
|
form.values = item.values.map((value, index) => ({
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
extraPrice: value.extraPrice,
|
||||||
|
sort: value.sort || index + 1,
|
||||||
|
}));
|
||||||
|
if (form.values.length === 0) {
|
||||||
|
form.values = [{ ...DEFAULT_OPTION }];
|
||||||
|
}
|
||||||
|
isDrawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDrawer() {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
message.warning('请输入模板名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValues = form.values
|
||||||
|
.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
name: item.name.trim(),
|
||||||
|
sort: index + 1,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.name);
|
||||||
|
if (normalizedValues.length === 0) {
|
||||||
|
message.warning('请至少添加一个选项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueNames = new Set(
|
||||||
|
normalizedValues.map((item) => item.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (uniqueNames.size !== normalizedValues.length) {
|
||||||
|
message.warning('选项名称不能重复');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDrawerSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await saveProductSpecApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
id: form.id || undefined,
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
selectionType: form.selectionType,
|
||||||
|
isRequired: form.isRequired,
|
||||||
|
sort: form.sort,
|
||||||
|
status: form.status,
|
||||||
|
productIds: [...form.productIds],
|
||||||
|
values: normalizedValues.map((item) => ({
|
||||||
|
id: item.id || undefined,
|
||||||
|
name: item.name,
|
||||||
|
extraPrice: item.extraPrice,
|
||||||
|
sort: item.sort,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
message.success(
|
||||||
|
drawerMode.value === 'create' ? '模板已添加' : '模板已保存',
|
||||||
|
);
|
||||||
|
isDrawerOpen.value = false;
|
||||||
|
await loadSpecs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
isDrawerSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTemplate(item: ProductSpecDto) {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: `确认删除模板「${item.name}」吗?`,
|
||||||
|
content: '删除后将移除该模板及其选项。',
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
async onOk() {
|
||||||
|
await deleteProductSpecApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
specId: item.id,
|
||||||
|
});
|
||||||
|
message.success('模板已删除');
|
||||||
|
await loadSpecs();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTemplate(item: ProductSpecDto) {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
try {
|
||||||
|
await copyProductSpecApi({
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
specId: item.id,
|
||||||
|
});
|
||||||
|
message.success('模板复制成功');
|
||||||
|
await loadSpecs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let keywordSearchTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
|
||||||
|
watch(selectedStoreId, () => {
|
||||||
|
keyword.value = '';
|
||||||
|
typeFilter.value = 'all';
|
||||||
|
void loadSpecs();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(keyword, () => {
|
||||||
|
if (!selectedStoreId.value) return;
|
||||||
|
if (keywordSearchTimer) {
|
||||||
|
clearTimeout(keywordSearchTimer);
|
||||||
|
keywordSearchTimer = null;
|
||||||
|
}
|
||||||
|
keywordSearchTimer = setTimeout(() => {
|
||||||
|
void loadSpecs();
|
||||||
|
keywordSearchTimer = null;
|
||||||
|
}, 220);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (keywordSearchTimer) {
|
||||||
|
clearTimeout(keywordSearchTimer);
|
||||||
|
keywordSearchTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(loadStores);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawerSubmitText,
|
||||||
|
drawerTitle,
|
||||||
|
filteredRows,
|
||||||
|
form,
|
||||||
|
isDrawerOpen,
|
||||||
|
isDrawerSubmitting,
|
||||||
|
isLoading,
|
||||||
|
isStoreLoading,
|
||||||
|
keyword,
|
||||||
|
methodCount,
|
||||||
|
openCreateDrawer,
|
||||||
|
openEditDrawer,
|
||||||
|
removeOption,
|
||||||
|
removeTemplate,
|
||||||
|
copyTemplate,
|
||||||
|
loadSpecs,
|
||||||
|
selectedStoreId,
|
||||||
|
setDrawerOpen,
|
||||||
|
setFormIsRequired,
|
||||||
|
setFormName,
|
||||||
|
setFormSelectionType,
|
||||||
|
setFormType,
|
||||||
|
setKeyword,
|
||||||
|
setOptionExtraPrice,
|
||||||
|
setOptionName,
|
||||||
|
setSelectedStoreId,
|
||||||
|
setTypeFilter,
|
||||||
|
specCount,
|
||||||
|
storeOptions,
|
||||||
|
submitDrawer,
|
||||||
|
totalProducts,
|
||||||
|
typeFilter,
|
||||||
|
addOption,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,502 +1,171 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 文件职责:规格做法管理页面。
|
* 文件职责:规格做法管理页面主视图。
|
||||||
* 1. 管理规格模板与规格值。
|
* 1. 还原原型的工具栏、统计条、卡片网格与右侧抽屉。
|
||||||
* 2. 管理规格与商品关联关系。
|
* 2. 通过真实 TenantAPI 完成模板增删改查与复制。
|
||||||
*/
|
*/
|
||||||
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 { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
import {
|
import { Button, Card, Empty, Input, Select, Spin } from 'ant-design-vue';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
Drawer,
|
|
||||||
Empty,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
message,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
|
||||||
import {
|
import SpecEditorDrawer from './components/SpecEditorDrawer.vue';
|
||||||
changeProductSpecStatusApi,
|
import SpecTemplateCard from './components/SpecTemplateCard.vue';
|
||||||
deleteProductSpecApi,
|
import { useProductSpecsPage } from './composables/useProductSpecsPage';
|
||||||
getProductSpecListApi,
|
|
||||||
saveProductSpecApi,
|
|
||||||
searchProductPickerApi,
|
|
||||||
} from '#/api/product';
|
|
||||||
import { getStoreListApi } from '#/api/store';
|
|
||||||
|
|
||||||
type StatusFilter = '' | ProductSwitchStatus;
|
const {
|
||||||
|
addOption,
|
||||||
interface SpecValueForm {
|
copyTemplate,
|
||||||
id: string;
|
drawerSubmitText,
|
||||||
name: string;
|
drawerTitle,
|
||||||
extraPrice: number;
|
filteredRows,
|
||||||
sort: number;
|
form,
|
||||||
}
|
isDrawerOpen,
|
||||||
|
isDrawerSubmitting,
|
||||||
interface SpecRow {
|
isLoading,
|
||||||
description: string;
|
isStoreLoading,
|
||||||
id: string;
|
keyword,
|
||||||
name: string;
|
methodCount,
|
||||||
productCount: number;
|
openCreateDrawer,
|
||||||
productIds: string[];
|
openEditDrawer,
|
||||||
sort: number;
|
removeOption,
|
||||||
status: ProductSwitchStatus;
|
removeTemplate,
|
||||||
updatedAt: string;
|
loadSpecs,
|
||||||
values: SpecValueForm[];
|
selectedStoreId,
|
||||||
}
|
setDrawerOpen,
|
||||||
|
setFormIsRequired,
|
||||||
const stores = ref<StoreListItemDto[]>([]);
|
setFormName,
|
||||||
const selectedStoreId = ref('');
|
setFormSelectionType,
|
||||||
const isStoreLoading = ref(false);
|
setFormType,
|
||||||
|
setKeyword,
|
||||||
const rows = ref<SpecRow[]>([]);
|
setOptionExtraPrice,
|
||||||
const isLoading = ref(false);
|
setOptionName,
|
||||||
const keyword = ref('');
|
setSelectedStoreId,
|
||||||
const statusFilter = ref<StatusFilter>('');
|
setTypeFilter,
|
||||||
|
specCount,
|
||||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
storeOptions,
|
||||||
const isDrawerOpen = ref(false);
|
submitDrawer,
|
||||||
const isDrawerSubmitting = ref(false);
|
totalProducts,
|
||||||
const editingSpecId = ref('');
|
typeFilter,
|
||||||
|
} = useProductSpecsPage();
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="规格做法" content-class="space-y-4 page-product-specs">
|
<Page title="规格做法" content-class="page-product-specs">
|
||||||
<Card :bordered="false">
|
<div class="psp-page">
|
||||||
<Space wrap>
|
<div class="psp-toolbar">
|
||||||
<Select
|
<Select
|
||||||
v-model:value="selectedStoreId"
|
class="psp-store-select"
|
||||||
|
:value="selectedStoreId"
|
||||||
:options="storeOptions"
|
:options="storeOptions"
|
||||||
:loading="isStoreLoading"
|
:loading="isStoreLoading"
|
||||||
style="width: 240px"
|
|
||||||
placeholder="请选择门店"
|
placeholder="请选择门店"
|
||||||
|
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
|
||||||
/>
|
/>
|
||||||
<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">
|
<div class="psp-filter-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-filter-tab"
|
||||||
|
:class="{ active: typeFilter === 'all' }"
|
||||||
|
@click="setTypeFilter('all')"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
<span class="count">{{ specCount + methodCount }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-filter-tab"
|
||||||
|
:class="{ active: typeFilter === 'spec' }"
|
||||||
|
@click="setTypeFilter('spec')"
|
||||||
|
>
|
||||||
|
规格
|
||||||
|
<span class="count">{{ specCount }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="psp-filter-tab"
|
||||||
|
:class="{ active: typeFilter === 'method' }"
|
||||||
|
@click="setTypeFilter('method')"
|
||||||
|
>
|
||||||
|
做法
|
||||||
|
<span class="count">{{ methodCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="psp-spacer"></span>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
class="psp-search"
|
||||||
|
:value="keyword"
|
||||||
|
placeholder="搜索模板名称…"
|
||||||
|
@update:value="setKeyword"
|
||||||
|
@press-enter="loadSpecs"
|
||||||
|
/>
|
||||||
|
<Button type="primary" @click="openCreateDrawer">+ 添加模板</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="storeOptions.length === 0" :bordered="false">
|
||||||
<Empty description="暂无门店,请先创建门店" />
|
<Empty description="暂无门店,请先创建门店" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-else :bordered="false">
|
<template v-else>
|
||||||
<Table
|
<div class="psp-stats">
|
||||||
row-key="id"
|
<div class="psp-stat">
|
||||||
:data-source="rows"
|
<span class="psp-stat-dot" style="background: #1890ff"></span>
|
||||||
:loading="isLoading"
|
规格模板
|
||||||
:pagination="false"
|
<span class="psp-stat-num">{{ specCount }}</span>
|
||||||
size="middle"
|
</div>
|
||||||
>
|
<div class="psp-stat">
|
||||||
<Table.Column
|
<span class="psp-stat-dot" style="background: #fa8c16"></span>
|
||||||
title="规格名称"
|
做法模板
|
||||||
data-index="name"
|
<span class="psp-stat-num">{{ methodCount }}</span>
|
||||||
key="name"
|
</div>
|
||||||
:width="160"
|
<div class="psp-stat">
|
||||||
/>
|
<span class="psp-stat-dot" style="background: #52c41a"></span>
|
||||||
<Table.Column title="规格值" key="values">
|
总关联商品
|
||||||
<template #default="{ record }">
|
<span class="psp-stat-num">{{ totalProducts }}</span>
|
||||||
<Space wrap size="small">
|
</div>
|
||||||
<Tag v-for="value in record.values" :key="value.id || value.name">
|
</div>
|
||||||
{{ 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
|
<Spin :spinning="isLoading">
|
||||||
|
<div v-if="filteredRows.length > 0" class="psp-grid">
|
||||||
|
<SpecTemplateCard
|
||||||
|
v-for="item in filteredRows"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
@edit="openEditDrawer"
|
||||||
|
@copy="copyTemplate"
|
||||||
|
@remove="removeTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-else :bordered="false">
|
||||||
|
<Empty description="暂无模板" />
|
||||||
|
</Card>
|
||||||
|
</Spin>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpecEditorDrawer
|
||||||
:open="isDrawerOpen"
|
:open="isDrawerOpen"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
width="560"
|
:submit-text="drawerSubmitText"
|
||||||
:destroy-on-close="true"
|
:submitting="isDrawerSubmitting"
|
||||||
@update:open="setDrawerOpen"
|
:form="form"
|
||||||
>
|
@close="setDrawerOpen(false)"
|
||||||
<Form layout="vertical">
|
@set-name="setFormName"
|
||||||
<Form.Item label="规格名称" required>
|
@set-type="setFormType"
|
||||||
<Input v-model:value="form.name" :maxlength="20" show-count />
|
@set-selection-type="setFormSelectionType"
|
||||||
</Form.Item>
|
@set-required="setFormIsRequired"
|
||||||
<Form.Item label="规格描述">
|
@set-option-name="setOptionName"
|
||||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
@set-option-extra-price="setOptionExtraPrice"
|
||||||
</Form.Item>
|
@add-option="addOption"
|
||||||
<Form.Item label="关联商品">
|
@remove-option="removeOption"
|
||||||
<Select
|
@submit="submitDrawer"
|
||||||
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>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style lang="less">
|
||||||
/* 文件职责:规格做法页面样式。 */
|
@import './styles/index.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>
|
</style>
|
||||||
|
|||||||
845
apps/web-antd/src/views/product/specs/styles/index.less
Normal file
845
apps/web-antd/src/views/product/specs/styles/index.less
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:规格做法页面样式。
|
||||||
|
* 1. 对齐原型的工具栏、统计条、卡片网格与抽屉视觉。
|
||||||
|
* 2. 统一模板标签、操作按钮与选项编辑样式。
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--g-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--g-shadow-sm: 0 1px 2px rgb(0 0 0 / 4%);
|
||||||
|
--g-shadow-md: 0 4px 12px rgb(0 0 0 / 7%), 0 1px 3px rgb(0 0 0 / 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgb(0 0 0 / 45%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-mask.open {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: -6px 0 16px rgb(0 0 0 / 8%);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-hd {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
height: 54px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-close:hover {
|
||||||
|
color: #333;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-bd {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-drawer-ft {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-editor-drawer {
|
||||||
|
.g-form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-label {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-label.required::before {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
content: '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-input:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
transition: all var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: var(--g-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-btn.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-sl {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 11px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-sl::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
content: '';
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 15%);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input:checked + .g-toggle-sl {
|
||||||
|
background: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input:checked + .g-toggle-sl::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-row input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-row input[type='text']:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-price-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 34px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-price-wrap > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-price-wrap input[type='number'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-del {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-del:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-btn-dashed {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-btn-dashed:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-product-specs {
|
||||||
|
@media (width <= 1200px) {
|
||||||
|
.psp-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-store-select {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-store-select .ant-select-selector {
|
||||||
|
height: 34px !important;
|
||||||
|
font-size: 13px;
|
||||||
|
border-color: #e5e7eb !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-store-select .ant-select-selection-item {
|
||||||
|
line-height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-search {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-search .ant-input {
|
||||||
|
height: 34px;
|
||||||
|
padding-left: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E")
|
||||||
|
10px center no-repeat;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-filter-tab {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-filter-tab:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-filter-tab.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1677ff;
|
||||||
|
background: rgb(22 119 255 / 10%);
|
||||||
|
border-color: rgb(22 119 255 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-filter-tab .count {
|
||||||
|
margin-left: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-stat {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-stat-num {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-stat-dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card:hover {
|
||||||
|
box-shadow: 0 8px 24px rgb(15 23 42 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card-hd {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card-hd .name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill .price {
|
||||||
|
margin-left: 2px;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card-assoc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-card-ft {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-tag-blue {
|
||||||
|
color: #1677ff;
|
||||||
|
background: rgb(22 119 255 / 10%);
|
||||||
|
border-color: rgb(22 119 255 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-tag-orange {
|
||||||
|
color: #fa8c16;
|
||||||
|
background: rgb(250 140 22 / 10%);
|
||||||
|
border-color: rgb(250 140 22 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-tag-gray {
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-action {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-action:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-action-danger:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-label {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-form-label.required::before {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
content: '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--g-shadow-sm);
|
||||||
|
transition: all var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: var(--g-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-primary {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn-primary:hover {
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-btn-dashed {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-btn-dashed:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-pill-btn.active {
|
||||||
|
color: #fff;
|
||||||
|
background: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-sl {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 11px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-sl::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
content: '';
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 15%);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input:checked + .g-toggle-sl {
|
||||||
|
background: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-input input:checked + .g-toggle-sl::before {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-row input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: var(--g-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-row input[type='text']:focus {
|
||||||
|
border-color: #1677ff;
|
||||||
|
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-price-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 34px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-price-wrap > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-price-wrap input[type='number'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-del {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psp-opt-del:hover {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/web-antd/src/views/product/specs/types.ts
Normal file
34
apps/web-antd/src/views/product/specs/types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 文件职责:规格做法页面共享类型定义。
|
||||||
|
* 1. 统一页面状态与表单模型类型。
|
||||||
|
* 2. 提供视图层筛选类型约束。
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ProductSpecDto,
|
||||||
|
ProductSpecSelectionType,
|
||||||
|
ProductSpecType,
|
||||||
|
ProductSwitchStatus,
|
||||||
|
} from '#/api/product';
|
||||||
|
|
||||||
|
export type SpecsTypeFilter = 'all' | ProductSpecType;
|
||||||
|
|
||||||
|
export interface SpecEditorValueForm {
|
||||||
|
extraPrice: number;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecEditorForm {
|
||||||
|
id: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
name: string;
|
||||||
|
productIds: string[];
|
||||||
|
selectionType: ProductSpecSelectionType;
|
||||||
|
sort: number;
|
||||||
|
status: ProductSwitchStatus;
|
||||||
|
type: ProductSpecType;
|
||||||
|
values: SpecEditorValueForm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductSpecCardViewModel = ProductSpecDto;
|
||||||
Reference in New Issue
Block a user