diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 54d3866..11a49c8 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -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('/product/spec/copy', data); +} + /** 获取加料组列表。 */ export async function getProductAddonGroupListApi(params: ProductAddonQuery) { return requestClient.get( diff --git a/apps/web-antd/src/views/product/category/composables/category-page/constants.ts b/apps/web-antd/src/views/product/category/composables/category-page/constants.ts new file mode 100644 index 0000000..1013839 --- /dev/null +++ b/apps/web-antd/src/views/product/category/composables/category-page/constants.ts @@ -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 []; +} diff --git a/apps/web-antd/src/views/product/category/composables/category-page/copy-actions.ts b/apps/web-antd/src/views/product/category/composables/category-page/copy-actions.ts new file mode 100644 index 0000000..ada6d25 --- /dev/null +++ b/apps/web-antd/src/views/product/category/composables/category-page/copy-actions.ts @@ -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; + copyTargetStoreIds: Ref; + isCopyModalOpen: Ref; + isCopySubmitting: Ref; + selectedStoreId: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/product/category/composables/category-page/data-actions.ts b/apps/web-antd/src/views/product/category/composables/category-page/data-actions.ts new file mode 100644 index 0000000..391436e --- /dev/null +++ b/apps/web-antd/src/views/product/category/composables/category-page/data-actions.ts @@ -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; + categoryProducts: Ref; + isCategoryLoading: Ref; + isCategoryProductsLoading: Ref; + isStoreLoading: Ref; + selectedCategoryId: Ref; + selectedStoreId: Ref; + stores: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/product/category/composables/category-page/drawer-actions.ts b/apps/web-antd/src/views/product/category/composables/category-page/drawer-actions.ts new file mode 100644 index 0000000..d602956 --- /dev/null +++ b/apps/web-antd/src/views/product/category/composables/category-page/drawer-actions.ts @@ -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; + drawerMode: Ref; + form: CategoryFormModel; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadCategories: (preferredCategoryId?: string) => Promise; + selectedCategory: ComputedRef; + selectedStoreId: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/product/category/composables/category-page/picker-actions.ts b/apps/web-antd/src/views/product/category/composables/category-page/picker-actions.ts new file mode 100644 index 0000000..c18589c --- /dev/null +++ b/apps/web-antd/src/views/product/category/composables/category-page/picker-actions.ts @@ -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; + isPickerLoading: Ref; + isPickerOpen: Ref; + isPickerSubmitting: Ref; + loadCategories: (preferredCategoryId?: string) => Promise; + pickerKeyword: Ref; + pickerProducts: Ref; + pickerSelectedIds: Ref; + selectedCategory: ComputedRef; + selectedStoreId: Ref; +} + +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, + }; +} diff --git a/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts b/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts index 2cfc199..bc8b03e 100644 --- a/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts +++ b/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts @@ -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'; diff --git a/apps/web-antd/src/views/product/specs/components/SpecEditorDrawer.vue b/apps/web-antd/src/views/product/specs/components/SpecEditorDrawer.vue new file mode 100644 index 0000000..55afcee --- /dev/null +++ b/apps/web-antd/src/views/product/specs/components/SpecEditorDrawer.vue @@ -0,0 +1,231 @@ + + + diff --git a/apps/web-antd/src/views/product/specs/components/SpecTemplateCard.vue b/apps/web-antd/src/views/product/specs/components/SpecTemplateCard.vue new file mode 100644 index 0000000..1cf4589 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/components/SpecTemplateCard.vue @@ -0,0 +1,88 @@ + + + diff --git a/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts b/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts new file mode 100644 index 0000000..0775edb --- /dev/null +++ b/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts @@ -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([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const rows = ref([]); + const isLoading = ref(false); + const keyword = ref(''); + const typeFilter = ref('all'); + + const isDrawerOpen = ref(false); + const isDrawerSubmitting = ref(false); + const drawerMode = ref<'create' | 'edit'>('create'); + + const form = reactive({ + 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 = 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, + }; +} diff --git a/apps/web-antd/src/views/product/specs/index.vue b/apps/web-antd/src/views/product/specs/index.vue index cdfcfba..3e77b1e 100644 --- a/apps/web-antd/src/views/product/specs/index.vue +++ b/apps/web-antd/src/views/product/specs/index.vue @@ -1,502 +1,171 @@ + + + -
- - - - - - - - - - - - - - - - - - -