feat: 重构规格做法页并对齐原型抽屉样式

This commit is contained in:
2026-02-20 20:09:19 +08:00
parent b2a3a1b75f
commit 653eb247b2
13 changed files with 2426 additions and 878 deletions

View File

@@ -22,6 +22,12 @@ export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm';
/** 通用启停状态。 */
export type ProductSwitchStatus = 'disabled' | 'enabled';
/** 规格做法模板类型。 */
export type ProductSpecType = 'method' | 'spec';
/** 规格做法选择方式。 */
export type ProductSpecSelectionType = 'multi' | 'single';
/** 商品选择器项。 */
export interface ProductPickerItemDto {
categoryId: string;
@@ -242,13 +248,15 @@ export interface ProductSpecValueDto {
/** 规格配置。 */
export interface ProductSpecDto {
description: string;
id: string;
isRequired: boolean;
name: string;
productCount: number;
productIds: string[];
selectionType: ProductSpecSelectionType;
sort: number;
status: ProductSwitchStatus;
type: ProductSpecType;
updatedAt: string;
values: ProductSpecValueDto[];
}
@@ -258,17 +266,20 @@ export interface ProductSpecQuery {
keyword?: string;
status?: ProductSwitchStatus;
storeId: string;
type?: ProductSpecType;
}
/** 保存规格参数。 */
export interface SaveProductSpecDto {
description: string;
id?: string;
isRequired: boolean;
name: string;
productIds: string[];
selectionType: ProductSpecSelectionType;
sort: number;
status: ProductSwitchStatus;
storeId: string;
type: ProductSpecType;
values: Array<{
extraPrice: number;
id?: string;
@@ -290,6 +301,13 @@ export interface ChangeProductSpecStatusDto {
storeId: string;
}
/** 复制规格参数。 */
export interface CopyProductSpecDto {
newName?: string;
specId: string;
storeId: string;
}
/** 加料项。 */
export interface ProductAddonItemDto {
id: string;
@@ -641,6 +659,11 @@ export async function changeProductSpecStatusApi(
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) {
return requestClient.get<ProductAddonGroupDto[]>(

View File

@@ -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 [];
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -8,12 +8,11 @@ import type {
} from '../types';
/**
* 文件职责:分类管理页面状态与行为聚合
* 1. 管理门店、分类、商品预览、抽屉与弹窗状态
* 2. 统一封装分类 CRUD 与商品绑定/解绑流程
* 文件职责:分类管理页面状态与行为编排
* 1. 维护页面状态、衍生视图数据与监听器
* 2. 组合数据、抽屉、商品选择、复制等动作模块
*/
import type {
ProductCategoryChannel,
ProductCategoryManageDto,
ProductPickerItemDto,
} from '#/api/product';
@@ -21,44 +20,11 @@ import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
bindCategoryProductsApi,
deleteProductCategoryApi,
getProductCategoryManageListApi,
saveProductCategoryApi,
searchProductPickerApi,
unbindCategoryProductApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
import { CATEGORY_CHANNEL_ORDER, deriveMonthlySales } from '../types';
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 [];
}
import { CATEGORY_CHANNEL_ORDER } from '../types';
import { createCopyActions } from './category-page/copy-actions';
import { createDataActions } from './category-page/data-actions';
import { createDrawerActions } from './category-page/drawer-actions';
import { createPickerActions } from './category-page/picker-actions';
/** 分类管理页面组合式状态。 */
export function useProductCategoryPage() {
@@ -185,105 +151,75 @@ export function useProductCategoryPage() {
copyTargetStoreIds.value.length < copyCandidates.value.length,
);
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
const { loadCategories, loadCategoryProducts, loadStores } =
createDataActions({
stores,
selectedStoreId,
isStoreLoading,
categories,
isCategoryLoading,
selectedCategoryId,
categoryProducts,
isCategoryProductsLoading,
});
async function loadCategories(preferredCategoryId = '') {
if (!selectedStoreId.value) {
categories.value = [];
selectedCategoryId.value = '';
categoryProducts.value = [];
return;
}
const {
handleCopyCheckAll,
handleCopySubmit,
openCopyModal,
setCopyModalOpen,
toggleCopyStore,
} = createCopyActions({
copyCandidates,
copyTargetStoreIds,
isCopyModalOpen,
isCopySubmitting,
selectedStoreId,
});
isCategoryLoading.value = true;
const previousCategoryId = selectedCategoryId.value;
try {
const result = await getProductCategoryManageListApi({
storeId: selectedStoreId.value,
});
categories.value = [...result]
.map((item) => ({
...item,
channels: normalizeCategoryChannels(item.channels),
}))
.toSorted((a, b) => a.sort - b.sort);
const {
openCreateDrawer,
openEditDrawer,
removeSelectedCategory,
setDrawerOpen,
setFormDescription,
setFormIcon,
setFormName,
setFormSort,
submitDrawer,
toggleDrawerChannel,
toggleDrawerStatus,
} = createDrawerActions({
categories,
drawerMode,
form,
isDrawerOpen,
isDrawerSubmitting,
loadCategories,
selectedCategory,
selectedStoreId,
});
const nextCategoryId =
(preferredCategoryId &&
categories.value.some((item) => item.id === preferredCategoryId)
? preferredCategoryId
: '') ||
(previousCategoryId &&
categories.value.some((item) => item.id === previousCategoryId)
? previousCategoryId
: '') ||
categories.value[0]?.id ||
'';
selectedCategoryId.value = nextCategoryId;
if (nextCategoryId && nextCategoryId === previousCategoryId) {
await loadCategoryProducts();
}
} catch (error) {
console.error(error);
categories.value = [];
selectedCategoryId.value = '';
categoryProducts.value = [];
message.error('加载分类失败');
} finally {
isCategoryLoading.value = false;
}
}
async function loadCategoryProducts() {
if (!selectedStoreId.value || !selectedCategoryId.value) {
categoryProducts.value = [];
return;
}
isCategoryProductsLoading.value = true;
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
categoryId: selectedCategoryId.value,
limit: 500,
});
categoryProducts.value = list.map((item) => ({
...item,
monthlySales: deriveMonthlySales(item.id),
}));
} catch (error) {
console.error(error);
categoryProducts.value = [];
message.error('加载分类商品失败');
} finally {
isCategoryProductsLoading.value = false;
}
}
const {
loadPickerProducts,
openProductPicker,
setPickerKeyword,
setPickerOpen,
submitProductPicker,
togglePickerProduct,
unbindProduct,
} = createPickerActions({
categoryProducts,
isPickerLoading,
isPickerOpen,
isPickerSubmitting,
loadCategories,
pickerKeyword,
pickerProducts,
pickerSelectedIds,
selectedCategory,
selectedStoreId,
});
function setSelectedStoreId(value: string) {
selectedStoreId.value = value;
@@ -302,269 +238,6 @@ export function useProductCategoryPage() {
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, () => {
categoryKeyword.value = '';
channelFilter.value = 'all';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -1,502 +1,171 @@
<script setup lang="ts">
/**
* 文件职责:规格做法管理页面。
* 1. 管理规格模板与规格值
* 2. 管理规格与商品关联关系
* 文件职责:规格做法管理页面主视图
* 1. 还原原型的工具栏、统计条、卡片网格与右侧抽屉
* 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 {
Button,
Card,
Divider,
Drawer,
Empty,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { Button, Card, Empty, Input, Select, Spin } from 'ant-design-vue';
import {
changeProductSpecStatusApi,
deleteProductSpecApi,
getProductSpecListApi,
saveProductSpecApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
import SpecEditorDrawer from './components/SpecEditorDrawer.vue';
import SpecTemplateCard from './components/SpecTemplateCard.vue';
import { useProductSpecsPage } from './composables/useProductSpecsPage';
type StatusFilter = '' | ProductSwitchStatus;
interface SpecValueForm {
id: string;
name: string;
extraPrice: number;
sort: number;
}
interface SpecRow {
description: string;
id: string;
name: string;
productCount: number;
productIds: string[];
sort: number;
status: ProductSwitchStatus;
updatedAt: string;
values: SpecValueForm[];
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const rows = ref<SpecRow[]>([]);
const isLoading = ref(false);
const keyword = ref('');
const statusFilter = ref<StatusFilter>('');
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
const isDrawerOpen = ref(false);
const isDrawerSubmitting = ref(false);
const editingSpecId = ref('');
const form = reactive({
name: '',
description: '',
status: 'enabled' as ProductSwitchStatus,
sort: 1,
productIds: [] as string[],
values: [] as SpecValueForm[],
});
const storeOptions = computed(() =>
stores.value.map((item) => ({
label: item.name,
value: item.id,
})),
);
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
];
const drawerTitle = computed(() =>
editingSpecId.value ? '编辑规格' : '新增规格',
);
/** 控制抽屉开关。 */
function setDrawerOpen(value: boolean) {
isDrawerOpen.value = value;
}
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
}
/** 加载规格列表。 */
async function loadSpecs() {
if (!selectedStoreId.value) {
rows.value = [];
return;
}
isLoading.value = true;
try {
const result = await getProductSpecListApi({
storeId: selectedStoreId.value,
keyword: keyword.value.trim() || undefined,
status: (statusFilter.value || undefined) as
| ProductSwitchStatus
| undefined,
});
rows.value = result as SpecRow[];
} catch (error) {
console.error(error);
rows.value = [];
message.error('加载规格失败');
} finally {
isLoading.value = false;
}
}
/** 加载商品选择器。 */
async function loadPickerOptions() {
if (!selectedStoreId.value) {
pickerOptions.value = [];
return;
}
try {
const list = await searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
});
pickerOptions.value = list.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
} catch (error) {
console.error(error);
pickerOptions.value = [];
message.error('加载商品失败');
}
}
/** 初始化默认表单。 */
function resetForm() {
editingSpecId.value = '';
form.name = '';
form.description = '';
form.status = 'enabled';
form.sort = rows.value.length + 1;
form.productIds = [];
form.values = [
{
id: '',
name: '',
extraPrice: 0,
sort: 1,
},
];
}
/** 打开新增抽屉。 */
async function openCreateDrawer() {
resetForm();
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 打开编辑抽屉。 */
async function openEditDrawer(row: SpecRow) {
editingSpecId.value = row.id;
form.name = row.name;
form.description = row.description;
form.status = row.status;
form.sort = row.sort;
form.productIds = [...row.productIds];
form.values = row.values.map((item) => ({ ...item }));
if (form.values.length === 0) {
form.values = [{ id: '', name: '', extraPrice: 0, sort: 1 }];
}
await loadPickerOptions();
isDrawerOpen.value = true;
}
/** 增加规格值行。 */
function addSpecValue() {
form.values.push({
id: '',
name: '',
extraPrice: 0,
sort: form.values.length + 1,
});
}
/** 删除规格值行。 */
function removeSpecValue(index: number) {
if (form.values.length <= 1) {
message.warning('至少保留一个规格值');
return;
}
form.values.splice(index, 1);
form.values.forEach((item, idx) => {
item.sort = idx + 1;
});
}
/** 保存规格。 */
async function submitDrawer() {
if (!selectedStoreId.value) return;
if (!form.name.trim()) {
message.warning('请输入规格名称');
return;
}
if (form.values.some((item) => !item.name.trim())) {
message.warning('规格值名称不能为空');
return;
}
isDrawerSubmitting.value = true;
try {
await saveProductSpecApi({
storeId: selectedStoreId.value,
id: editingSpecId.value || undefined,
name: form.name.trim(),
description: form.description.trim(),
status: form.status,
sort: form.sort,
productIds: [...form.productIds],
values: form.values.map((item) => ({
id: item.id || undefined,
name: item.name.trim(),
extraPrice: item.extraPrice,
sort: item.sort,
})),
});
message.success(editingSpecId.value ? '规格已更新' : '规格已创建');
isDrawerOpen.value = false;
await loadSpecs();
} catch (error) {
console.error(error);
} finally {
isDrawerSubmitting.value = false;
}
}
/** 删除规格。 */
function removeSpec(row: SpecRow) {
if (!selectedStoreId.value) return;
Modal.confirm({
title: `确认删除规格「${row.name}」吗?`,
async onOk() {
await deleteProductSpecApi({
storeId: selectedStoreId.value,
specId: row.id,
});
message.success('规格已删除');
await loadSpecs();
},
});
}
/** 切换规格启停状态。 */
async function toggleSpecStatus(row: SpecRow, checked: boolean) {
if (!selectedStoreId.value) return;
await changeProductSpecStatusApi({
storeId: selectedStoreId.value,
specId: row.id,
status: checked ? 'enabled' : 'disabled',
});
row.status = checked ? 'enabled' : 'disabled';
message.success('状态已更新');
}
/** 重置筛选。 */
function resetFilters() {
keyword.value = '';
statusFilter.value = '';
loadSpecs();
}
watch(selectedStoreId, loadSpecs);
onMounted(loadStores);
const {
addOption,
copyTemplate,
drawerSubmitText,
drawerTitle,
filteredRows,
form,
isDrawerOpen,
isDrawerSubmitting,
isLoading,
isStoreLoading,
keyword,
methodCount,
openCreateDrawer,
openEditDrawer,
removeOption,
removeTemplate,
loadSpecs,
selectedStoreId,
setDrawerOpen,
setFormIsRequired,
setFormName,
setFormSelectionType,
setFormType,
setKeyword,
setOptionExtraPrice,
setOptionName,
setSelectedStoreId,
setTypeFilter,
specCount,
storeOptions,
submitDrawer,
totalProducts,
typeFilter,
} = useProductSpecsPage();
</script>
<template>
<Page title="规格做法" content-class="space-y-4 page-product-specs">
<Card :bordered="false">
<Space wrap>
<Page title="规格做法" content-class="page-product-specs">
<div class="psp-page">
<div class="psp-toolbar">
<Select
v-model:value="selectedStoreId"
class="psp-store-select"
:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
@update:value="(value) => setSelectedStoreId(String(value ?? ''))"
/>
<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
v-model:value="keyword"
style="width: 220px"
placeholder="搜索规格名称"
class="psp-search"
:value="keyword"
placeholder="搜索模板名称"
@update:value="setKeyword"
@press-enter="loadSpecs"
/>
<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>
<Button type="primary" @click="openCreateDrawer">+ 添加模板</Button>
</div>
<Card v-if="!selectedStoreId" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-if="storeOptions.length === 0" :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
<Card v-else :bordered="false">
<Table
row-key="id"
:data-source="rows"
:loading="isLoading"
:pagination="false"
size="middle"
>
<Table.Column
title="规格名称"
data-index="name"
key="name"
:width="160"
/>
<Table.Column title="规格值" key="values">
<template #default="{ record }">
<Space wrap size="small">
<Tag v-for="value in record.values" :key="value.id || value.name">
{{ value.name }}
<template v-if="value.extraPrice">
+{{ value.extraPrice }}
</template>
</Tag>
</Space>
</template>
</Table.Column>
<Table.Column
title="关联商品数"
data-index="productCount"
key="productCount"
:width="100"
/>
<Table.Column title="排序" data-index="sort" key="sort" :width="80" />
<Table.Column title="状态" key="status" :width="90">
<template #default="{ record }">
<Switch
:checked="record.status === 'enabled'"
size="small"
@change="(checked) => toggleSpecStatus(record, checked === true)"
<template v-else>
<div class="psp-stats">
<div class="psp-stat">
<span class="psp-stat-dot" style="background: #1890ff"></span>
规格模板
<span class="psp-stat-num">{{ specCount }}</span>
</div>
<div class="psp-stat">
<span class="psp-stat-dot" style="background: #fa8c16"></span>
做法模板
<span class="psp-stat-num">{{ methodCount }}</span>
</div>
<div class="psp-stat">
<span class="psp-stat-dot" style="background: #52c41a"></span>
总关联商品
<span class="psp-stat-num">{{ totalProducts }}</span>
</div>
</div>
<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"
/>
</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>
</div>
<Drawer
<Card v-else :bordered="false">
<Empty description="暂无模板" />
</Card>
</Spin>
</template>
</div>
<SpecEditorDrawer
:open="isDrawerOpen"
:title="drawerTitle"
width="560"
:destroy-on-close="true"
@update:open="setDrawerOpen"
>
<Form layout="vertical">
<Form.Item label="规格名称" required>
<Input v-model:value="form.name" :maxlength="20" show-count />
</Form.Item>
<Form.Item label="规格描述">
<Input v-model:value="form.description" :maxlength="100" show-count />
</Form.Item>
<Form.Item label="关联商品">
<Select
v-model:value="form.productIds"
mode="multiple"
:options="pickerOptions"
placeholder="请选择商品"
/>
</Form.Item>
<Divider orientation="left">规格值</Divider>
<div
v-for="(item, index) in form.values"
:key="index"
class="spec-value-row"
>
<Input v-model:value="item.name" placeholder="规格值名称,如:微辣" />
<InputNumber
v-model:value="item.extraPrice"
:step="0.5"
style="width: 120px"
placeholder="加价"
/>
<Button danger @click="removeSpecValue(index)">删除</Button>
</div>
<Button @click="addSpecValue">新增规格值</Button>
<Divider />
<Form.Item label="排序">
<InputNumber v-model:value="form.sort" :min="1" style="width: 100%" />
</Form.Item>
<Form.Item label="状态">
<Select
v-model:value="form.status"
:options="[
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
]"
/>
</Form.Item>
</Form>
<template #footer>
<Space>
<Button @click="setDrawerOpen(false)">取消</Button>
<Button
type="primary"
:loading="isDrawerSubmitting"
@click="submitDrawer"
>
保存
</Button>
</Space>
</template>
</Drawer>
:submit-text="drawerSubmitText"
:submitting="isDrawerSubmitting"
:form="form"
@close="setDrawerOpen(false)"
@set-name="setFormName"
@set-type="setFormType"
@set-selection-type="setFormSelectionType"
@set-required="setFormIsRequired"
@set-option-name="setOptionName"
@set-option-extra-price="setOptionExtraPrice"
@add-option="addOption"
@remove-option="removeOption"
@submit="submitDrawer"
/>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:规格做法页面样式。 */
.page-product-specs {
.spec-value-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
:deep(.ant-table-cell) {
vertical-align: middle;
}
}
<style lang="less">
@import './styles/index.less';
</style>

View 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;
}
}

View 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;