From 539f9e70aa925c9db71343e04cf957537941ae6e Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sat, 21 Feb 2026 09:07:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86specs=E4=B8=8Eadd?= =?UTF-8?q?ons=E9=A1=B5=E9=9D=A2=E7=BB=93=E6=9E=84=E5=AF=B9=E9=BD=90catego?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../composables/addons-page/constants.ts | 20 + .../composables/addons-page/data-actions.ts | 78 ++ .../composables/addons-page/drawer-actions.ts | 241 +++++ .../composables/addons-page/group-actions.ts | 175 ++++ .../composables/addons-page/picker-actions.ts | 109 +++ .../composables/useProductAddonsPage.ts | 525 ++--------- .../src/views/product/addons/styles/base.less | 110 +++ .../src/views/product/addons/styles/card.less | 108 +++ .../views/product/addons/styles/drawer.less | 243 +++++ .../views/product/addons/styles/index.less | 626 +------------ .../views/product/addons/styles/layout.less | 81 ++ .../views/product/addons/styles/modal.less | 70 ++ .../product/addons/styles/responsive.less | 11 + .../specs/composables/specs-page/constants.ts | 18 + .../composables/specs-page/data-actions.ts | 80 ++ .../composables/specs-page/drawer-actions.ts | 202 +++++ .../specs-page/template-actions.ts | 56 ++ .../specs/composables/useProductSpecsPage.ts | 287 +----- .../src/views/product/specs/styles/base.less | 93 ++ .../src/views/product/specs/styles/card.less | 365 ++++++++ .../views/product/specs/styles/drawer.less | 258 ++++++ .../src/views/product/specs/styles/index.less | 850 +----------------- .../views/product/specs/styles/layout.less | 114 +++ .../product/specs/styles/responsive.less | 15 + 24 files changed, 2563 insertions(+), 2172 deletions(-) create mode 100644 apps/web-antd/src/views/product/addons/composables/addons-page/constants.ts create mode 100644 apps/web-antd/src/views/product/addons/composables/addons-page/data-actions.ts create mode 100644 apps/web-antd/src/views/product/addons/composables/addons-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/product/addons/composables/addons-page/group-actions.ts create mode 100644 apps/web-antd/src/views/product/addons/composables/addons-page/picker-actions.ts create mode 100644 apps/web-antd/src/views/product/addons/styles/base.less create mode 100644 apps/web-antd/src/views/product/addons/styles/card.less create mode 100644 apps/web-antd/src/views/product/addons/styles/drawer.less create mode 100644 apps/web-antd/src/views/product/addons/styles/layout.less create mode 100644 apps/web-antd/src/views/product/addons/styles/modal.less create mode 100644 apps/web-antd/src/views/product/addons/styles/responsive.less create mode 100644 apps/web-antd/src/views/product/specs/composables/specs-page/constants.ts create mode 100644 apps/web-antd/src/views/product/specs/composables/specs-page/data-actions.ts create mode 100644 apps/web-antd/src/views/product/specs/composables/specs-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/product/specs/composables/specs-page/template-actions.ts create mode 100644 apps/web-antd/src/views/product/specs/styles/base.less create mode 100644 apps/web-antd/src/views/product/specs/styles/card.less create mode 100644 apps/web-antd/src/views/product/specs/styles/drawer.less create mode 100644 apps/web-antd/src/views/product/specs/styles/layout.less create mode 100644 apps/web-antd/src/views/product/specs/styles/responsive.less diff --git a/apps/web-antd/src/views/product/addons/composables/addons-page/constants.ts b/apps/web-antd/src/views/product/addons/composables/addons-page/constants.ts new file mode 100644 index 0000000..2d80077 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/composables/addons-page/constants.ts @@ -0,0 +1,20 @@ +import type { AddonItemForm } from '../../types'; + +/** + * 文件职责:加料管理常量与基础构造方法。 + */ +export const DEFAULT_ITEM: AddonItemForm = { + id: '', + name: '', + price: 0, + stock: 999, + sort: 1, + status: 'enabled', +}; + +export function createDefaultAddonItem(sort = 1): AddonItemForm { + return { + ...DEFAULT_ITEM, + sort, + }; +} diff --git a/apps/web-antd/src/views/product/addons/composables/addons-page/data-actions.ts b/apps/web-antd/src/views/product/addons/composables/addons-page/data-actions.ts new file mode 100644 index 0000000..808c0c1 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/composables/addons-page/data-actions.ts @@ -0,0 +1,78 @@ +import type { Ref } from 'vue'; + +import type { ProductAddonGroupDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:加料管理数据动作。 + * 1. 加载门店列表与加料组列表。 + * 2. 维护门店切换时的数据一致性。 + */ +import { message } from 'ant-design-vue'; + +import { getProductAddonGroupListApi } from '#/api/product'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + isLoading: Ref; + isStoreLoading: Ref; + rows: 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 = ''; + options.rows.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 loadAddonGroups() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + return; + } + + options.isLoading.value = true; + try { + const list = await getProductAddonGroupListApi({ + storeId: options.selectedStoreId.value, + }); + options.rows.value = list; + } catch (error) { + console.error(error); + options.rows.value = []; + message.error('加载加料组失败'); + } finally { + options.isLoading.value = false; + } + } + + return { + loadAddonGroups, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/product/addons/composables/addons-page/drawer-actions.ts b/apps/web-antd/src/views/product/addons/composables/addons-page/drawer-actions.ts new file mode 100644 index 0000000..f6462b3 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/composables/addons-page/drawer-actions.ts @@ -0,0 +1,241 @@ +import type { Ref } from 'vue'; + +import type { + ProductAddonGroupDto, + SaveProductAddonGroupDto, +} from '#/api/product'; +import type { + AddonEditorForm, + AddonGroupCardViewModel, +} from '#/views/product/addons/types'; + +/** + * 文件职责:加料编辑抽屉动作。 + * 1. 管理加料组新增/编辑表单状态。 + * 2. 处理表单校验与保存提交。 + */ +import { message } from 'ant-design-vue'; + +import { saveProductAddonGroupApi } from '#/api/product'; + +import { createDefaultAddonItem } from './constants'; + +interface CreateDrawerActionsOptions { + drawerMode: Ref<'create' | 'edit'>; + editingGroupId: Ref; + editingGroupName: Ref; + editingProductIds: Ref; + form: AddonEditorForm; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadAddonGroups: () => Promise; + rows: Ref; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormDescription(value: string) { + options.form.description = value; + } + + function setFormRequired(value: boolean) { + options.form.required = value; + if (value && options.form.minSelect < 1) { + options.form.minSelect = 1; + } + } + + function setFormMinSelect(value: number) { + options.form.minSelect = Math.max(0, value); + if (options.form.required && options.form.minSelect < 1) { + options.form.minSelect = 1; + } + } + + function setFormMaxSelect(value: number) { + options.form.maxSelect = Math.max(1, value); + } + + function setItemName(index: number, value: string) { + const current = options.form.items[index]; + if (!current) return; + current.name = value; + } + + function setItemPrice(index: number, value: number) { + const current = options.form.items[index]; + if (!current) return; + current.price = Number.isNaN(value) ? 0 : Math.max(0, value); + } + + function setItemStock(index: number, value: number) { + const current = options.form.items[index]; + if (!current) return; + current.stock = Number.isNaN(value) ? 0 : Math.max(0, Math.floor(value)); + } + + function addItem() { + options.form.items.push( + createDefaultAddonItem(options.form.items.length + 1), + ); + } + + function removeItem(index: number) { + if (options.form.items.length <= 1) { + message.warning('至少保留一个加料项'); + return; + } + options.form.items.splice(index, 1); + options.form.items.forEach((item, idx) => { + item.sort = idx + 1; + }); + } + + function resetForm() { + options.editingGroupId.value = ''; + options.editingGroupName.value = ''; + options.editingProductIds.value = []; + options.form.name = ''; + options.form.description = ''; + options.form.required = false; + options.form.minSelect = 0; + options.form.maxSelect = 1; + options.form.sort = options.rows.value.length + 1; + options.form.status = 'enabled'; + options.form.items = [createDefaultAddonItem()]; + } + + function openCreateDrawer() { + options.drawerMode.value = 'create'; + resetForm(); + options.isDrawerOpen.value = true; + } + + function openEditDrawer(item: AddonGroupCardViewModel) { + options.drawerMode.value = 'edit'; + options.editingGroupId.value = item.id; + options.editingGroupName.value = item.name; + options.editingProductIds.value = [...item.productIds]; + options.form.name = item.name; + options.form.description = item.description; + options.form.required = item.required; + options.form.minSelect = item.minSelect; + options.form.maxSelect = item.maxSelect; + options.form.sort = item.sort; + options.form.status = item.status; + options.form.items = item.items.map((option, index) => ({ + id: option.id, + name: option.name, + price: option.price, + stock: option.stock, + sort: option.sort || index + 1, + status: option.status, + })); + if (options.form.items.length === 0) { + options.form.items = [createDefaultAddonItem()]; + } + options.isDrawerOpen.value = true; + } + + function normalizeSubmitItems() { + return options.form.items + .map((item, index) => ({ + ...item, + name: item.name.trim(), + sort: index + 1, + price: Number(item.price.toFixed(2)), + })) + .filter((item) => item.name); + } + + function buildSavePayload( + items: ProductAddonGroupDto['items'], + ): SaveProductAddonGroupDto { + return { + storeId: options.selectedStoreId.value, + id: options.editingGroupId.value || undefined, + name: options.form.name.trim(), + description: options.form.description.trim(), + required: options.form.required, + minSelect: options.form.minSelect, + maxSelect: options.form.maxSelect, + sort: options.form.sort, + status: options.form.status, + productIds: [...options.editingProductIds.value], + items: items.map((item) => ({ + id: item.id || undefined, + name: item.name, + price: item.price, + stock: item.stock, + sort: item.sort, + status: item.status, + })), + }; + } + + async function submitDrawer() { + if (!options.selectedStoreId.value) return; + if (!options.form.name.trim()) { + message.warning('请输入加料组名称'); + return; + } + + const normalizedItems = normalizeSubmitItems(); + if (normalizedItems.length === 0) { + message.warning('至少保留一个加料项'); + return; + } + + const uniqueNames = new Set( + normalizedItems.map((item) => item.name.toLowerCase()), + ); + if (uniqueNames.size !== normalizedItems.length) { + message.warning('加料项名称不能重复'); + return; + } + + if (options.form.maxSelect < options.form.minSelect) { + message.warning('最大可选数量不能小于最小可选数量'); + return; + } + + options.isDrawerSubmitting.value = true; + try { + await saveProductAddonGroupApi(buildSavePayload(normalizedItems)); + message.success( + options.drawerMode.value === 'create' ? '加料组已创建' : '加料组已更新', + ); + options.isDrawerOpen.value = false; + await options.loadAddonGroups(); + } catch (error) { + console.error(error); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + addItem, + openCreateDrawer, + openEditDrawer, + removeItem, + setDrawerOpen, + setFormDescription, + setFormMaxSelect, + setFormMinSelect, + setFormName, + setFormRequired, + setItemName, + setItemPrice, + setItemStock, + submitDrawer, + }; +} diff --git a/apps/web-antd/src/views/product/addons/composables/addons-page/group-actions.ts b/apps/web-antd/src/views/product/addons/composables/addons-page/group-actions.ts new file mode 100644 index 0000000..25ef67d --- /dev/null +++ b/apps/web-antd/src/views/product/addons/composables/addons-page/group-actions.ts @@ -0,0 +1,175 @@ +import type { Ref } from 'vue'; + +import type { + ProductAddonGroupDto, + SaveProductAddonGroupDto, +} from '#/api/product'; +import type { AddonGroupCardViewModel } from '#/views/product/addons/types'; + +/** + * 文件职责:加料组卡片动作。 + * 1. 处理卡片级启用/删除操作。 + * 2. 处理加料项改名与移除。 + */ +import { h } from 'vue'; + +import { Input, message, Modal } from 'ant-design-vue'; + +import { + changeProductAddonGroupStatusApi, + deleteProductAddonGroupApi, + saveProductAddonGroupApi, +} from '#/api/product'; + +interface CreateGroupActionsOptions { + loadAddonGroups: () => Promise; + selectedStoreId: Ref; +} + +export function createGroupActions(options: CreateGroupActionsOptions) { + function removeGroup(item: AddonGroupCardViewModel) { + if (!options.selectedStoreId.value) return; + Modal.confirm({ + title: `确认删除加料组「${item.name}」吗?`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + await deleteProductAddonGroupApi({ + storeId: options.selectedStoreId.value, + groupId: item.id, + }); + message.success('加料组已删除'); + await options.loadAddonGroups(); + }, + }); + } + + async function enableGroup(item: AddonGroupCardViewModel) { + if (!options.selectedStoreId.value) return; + try { + await changeProductAddonGroupStatusApi({ + storeId: options.selectedStoreId.value, + groupId: item.id, + status: 'enabled', + }); + message.success('加料组已启用'); + await options.loadAddonGroups(); + } catch (error) { + console.error(error); + } + } + + async function saveGroupInline( + group: AddonGroupCardViewModel, + items: ProductAddonGroupDto['items'], + ) { + if (!options.selectedStoreId.value) return; + + const payload: SaveProductAddonGroupDto = { + storeId: options.selectedStoreId.value, + id: group.id, + name: group.name, + description: group.description, + required: group.required, + minSelect: group.minSelect, + maxSelect: group.maxSelect, + sort: group.sort, + status: group.status, + productIds: [...group.productIds], + items: items.map((item, index) => ({ + id: item.id || undefined, + name: item.name.trim(), + price: Number(item.price.toFixed(2)), + stock: item.stock, + sort: index + 1, + status: item.status, + })), + }; + + await saveProductAddonGroupApi(payload); + } + + function renameItem(payload: { + group: AddonGroupCardViewModel; + itemId: string; + }) { + const currentItem = payload.group.items.find( + (item) => item.id === payload.itemId, + ); + if (!currentItem) return; + + let draftName = currentItem.name; + Modal.confirm({ + title: `改名 - ${currentItem.name}`, + okText: '确认', + cancelText: '取消', + content: () => + h(Input, { + value: draftName, + maxlength: 30, + placeholder: '请输入新名称', + 'onUpdate:value': (value: string) => { + draftName = value; + }, + }), + async onOk() { + const nextName = draftName.trim(); + if (!nextName) { + message.warning('加料项名称不能为空'); + throw new Error('invalid-name'); + } + + const nextItems = payload.group.items.map((item) => + item.id === payload.itemId ? { ...item, name: nextName } : item, + ); + const uniqueNames = new Set( + nextItems.map((item) => item.name.toLowerCase()), + ); + if (uniqueNames.size !== nextItems.length) { + message.warning('加料项名称不能重复'); + throw new Error('duplicate-name'); + } + + await saveGroupInline(payload.group, nextItems); + message.success('加料项名称已更新'); + await options.loadAddonGroups(); + }, + }); + } + + function removeCardItem(payload: { + group: AddonGroupCardViewModel; + itemId: string; + }) { + if (payload.group.items.length <= 1) { + message.warning('至少保留一个加料项'); + return; + } + + const currentItem = payload.group.items.find( + (item) => item.id === payload.itemId, + ); + if (!currentItem) return; + + Modal.confirm({ + title: `确认移除选项「${currentItem.name}」吗?`, + okText: '确认移除', + cancelText: '取消', + async onOk() { + const nextItems = payload.group.items.filter( + (item) => item.id !== payload.itemId, + ); + await saveGroupInline(payload.group, nextItems); + message.success('加料项已移除'); + await options.loadAddonGroups(); + }, + }); + } + + return { + enableGroup, + removeCardItem, + removeGroup, + renameItem, + }; +} diff --git a/apps/web-antd/src/views/product/addons/composables/addons-page/picker-actions.ts b/apps/web-antd/src/views/product/addons/composables/addons-page/picker-actions.ts new file mode 100644 index 0000000..8c253e9 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/composables/addons-page/picker-actions.ts @@ -0,0 +1,109 @@ +import type { Ref } from 'vue'; + +import type { ProductPickerItemDto } from '#/api/product'; +import type { AddonGroupCardViewModel } from '#/views/product/addons/types'; + +/** + * 文件职责:加料关联商品动作。 + * 1. 管理商品选择弹窗与检索状态。 + * 2. 提交加料组关联商品绑定。 + */ +import { message } from 'ant-design-vue'; + +import { + bindProductAddonGroupProductsApi, + searchProductPickerApi, +} from '#/api/product'; + +interface CreatePickerActionsOptions { + bindingGroupId: Ref; + isPickerLoading: Ref; + isPickerOpen: Ref; + isPickerSubmitting: Ref; + loadAddonGroups: () => Promise; + pickerKeyword: Ref; + pickerProducts: Ref; + pickerSelectedIds: Ref; + pickerTitle: Ref; + selectedStoreId: Ref; +} + +export function createPickerActions(options: CreatePickerActionsOptions) { + async function loadPickerProducts() { + if (!options.selectedStoreId.value) { + options.pickerProducts.value = []; + return; + } + options.isPickerLoading.value = true; + try { + options.pickerProducts.value = await searchProductPickerApi({ + storeId: options.selectedStoreId.value, + keyword: options.pickerKeyword.value.trim() || undefined, + limit: 500, + }); + } catch (error) { + console.error(error); + options.pickerProducts.value = []; + message.error('加载商品失败'); + } finally { + options.isPickerLoading.value = false; + } + } + + async function openBindProducts(item: AddonGroupCardViewModel) { + options.bindingGroupId.value = item.id; + options.pickerTitle.value = `关联商品 - ${item.name}`; + options.pickerKeyword.value = ''; + options.pickerSelectedIds.value = [...item.productIds]; + options.pickerProducts.value = []; + options.isPickerOpen.value = true; + await loadPickerProducts(); + } + + function setPickerOpen(value: boolean) { + options.isPickerOpen.value = value; + } + + function setPickerKeyword(value: string) { + options.pickerKeyword.value = value; + } + + 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 submitPicker() { + if (!options.selectedStoreId.value || !options.bindingGroupId.value) return; + + options.isPickerSubmitting.value = true; + try { + await bindProductAddonGroupProductsApi({ + storeId: options.selectedStoreId.value, + groupId: options.bindingGroupId.value, + productIds: [...options.pickerSelectedIds.value], + }); + message.success('关联商品已更新'); + options.isPickerOpen.value = false; + await options.loadAddonGroups(); + } catch (error) { + console.error(error); + } finally { + options.isPickerSubmitting.value = false; + } + } + + return { + loadPickerProducts, + openBindProducts, + setPickerKeyword, + setPickerOpen, + submitPicker, + togglePickerProduct, + }; +} diff --git a/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts b/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts index b9a7ca6..fed61e4 100644 --- a/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts +++ b/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts @@ -1,10 +1,6 @@ -import type { - AddonEditorForm, - AddonGroupCardViewModel, - AddonItemForm, -} from '../types'; +import type { AddonEditorForm, AddonGroupCardViewModel } from '../types'; -import type { ProductAddonGroupDto, ProductPickerItemDto } from '#/api/product'; +import type { ProductPickerItemDto } from '#/api/product'; import type { StoreListItemDto } from '#/api/store'; /** @@ -12,28 +8,14 @@ import type { StoreListItemDto } from '#/api/store'; * 1. 管理门店、加料组卡片、统计与筛选状态。 * 2. 封装加料组新增编辑、选项改名移除、关联商品流程。 */ -import { computed, h, onMounted, reactive, ref, watch } from 'vue'; +import { computed, onMounted, reactive, ref, watch } from 'vue'; -import { Input, message, Modal } from 'ant-design-vue'; +import { createDataActions } from './addons-page/data-actions'; +import { createDrawerActions } from './addons-page/drawer-actions'; +import { createGroupActions } from './addons-page/group-actions'; +import { createPickerActions } from './addons-page/picker-actions'; -import { - bindProductAddonGroupProductsApi, - changeProductAddonGroupStatusApi, - deleteProductAddonGroupApi, - getProductAddonGroupListApi, - saveProductAddonGroupApi, - searchProductPickerApi, -} from '#/api/product'; -import { getStoreListApi } from '#/api/store'; - -const DEFAULT_ITEM: AddonItemForm = { - id: '', - name: '', - price: 0, - stock: 999, - sort: 1, - status: 'enabled', -}; +import { createDefaultAddonItem } from './addons-page/constants'; export function useProductAddonsPage() { const stores = ref([]); @@ -59,7 +41,7 @@ export function useProductAddonsPage() { maxSelect: 1, sort: 1, status: 'enabled', - items: [{ ...DEFAULT_ITEM }], + items: [createDefaultAddonItem()], }); const isPickerOpen = ref(false); @@ -106,54 +88,13 @@ export function useProductAddonsPage() { 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 loadAddonGroups() { - if (!selectedStoreId.value) { - rows.value = []; - return; - } - - isLoading.value = true; - try { - const list = await getProductAddonGroupListApi({ - storeId: selectedStoreId.value, - }); - rows.value = list; - } catch (error) { - console.error(error); - rows.value = []; - message.error('加载加料组失败'); - } finally { - isLoading.value = false; - } - } + const { loadAddonGroups, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + rows, + isLoading, + }); function setSelectedStoreId(value: string) { selectedStoreId.value = value; @@ -163,393 +104,59 @@ export function useProductAddonsPage() { keyword.value = value; } - function setDrawerOpen(value: boolean) { - isDrawerOpen.value = value; - } + const { + addItem, + openCreateDrawer, + openEditDrawer, + removeItem, + setDrawerOpen, + setFormDescription, + setFormMaxSelect, + setFormMinSelect, + setFormName, + setFormRequired, + setItemName, + setItemPrice, + setItemStock, + submitDrawer, + } = createDrawerActions({ + drawerMode, + editingGroupId, + editingGroupName, + editingProductIds, + form, + isDrawerOpen, + isDrawerSubmitting, + loadAddonGroups, + rows, + selectedStoreId, + }); - function setFormName(value: string) { - form.name = value; - } - - function setFormDescription(value: string) { - form.description = value; - } - - function setFormRequired(value: boolean) { - form.required = value; - if (value && form.minSelect < 1) { - form.minSelect = 1; - } - } - - function setFormMinSelect(value: number) { - form.minSelect = Math.max(0, value); - if (form.required && form.minSelect < 1) { - form.minSelect = 1; - } - } - - function setFormMaxSelect(value: number) { - form.maxSelect = Math.max(1, value); - } - - function setItemName(index: number, value: string) { - const current = form.items[index]; - if (!current) return; - current.name = value; - } - - function setItemPrice(index: number, value: number) { - const current = form.items[index]; - if (!current) return; - current.price = Number.isNaN(value) ? 0 : Math.max(0, value); - } - - function setItemStock(index: number, value: number) { - const current = form.items[index]; - if (!current) return; - current.stock = Number.isNaN(value) ? 0 : Math.max(0, Math.floor(value)); - } - - function addItem() { - form.items.push({ - id: '', - name: '', - price: 0, - stock: 999, - sort: form.items.length + 1, - status: 'enabled', + const { enableGroup, removeCardItem, removeGroup, renameItem } = + createGroupActions({ + selectedStoreId, + loadAddonGroups, }); - } - function removeItem(index: number) { - if (form.items.length <= 1) { - message.warning('至少保留一个加料项'); - return; - } - form.items.splice(index, 1); - form.items.forEach((item, idx) => { - item.sort = idx + 1; - }); - } - - function resetForm() { - editingGroupId.value = ''; - editingGroupName.value = ''; - editingProductIds.value = []; - form.name = ''; - form.description = ''; - form.required = false; - form.minSelect = 0; - form.maxSelect = 1; - form.sort = rows.value.length + 1; - form.status = 'enabled'; - form.items = [{ ...DEFAULT_ITEM }]; - } - - function openCreateDrawer() { - drawerMode.value = 'create'; - resetForm(); - isDrawerOpen.value = true; - } - - function openEditDrawer(item: AddonGroupCardViewModel) { - drawerMode.value = 'edit'; - editingGroupId.value = item.id; - editingGroupName.value = item.name; - editingProductIds.value = [...item.productIds]; - form.name = item.name; - form.description = item.description; - form.required = item.required; - form.minSelect = item.minSelect; - form.maxSelect = item.maxSelect; - form.sort = item.sort; - form.status = item.status; - form.items = item.items.map((option, index) => ({ - id: option.id, - name: option.name, - price: option.price, - stock: option.stock, - sort: option.sort || index + 1, - status: option.status, - })); - if (form.items.length === 0) { - form.items = [{ ...DEFAULT_ITEM }]; - } - isDrawerOpen.value = true; - } - - async function submitDrawer() { - if (!selectedStoreId.value) return; - if (!form.name.trim()) { - message.warning('请输入加料组名称'); - return; - } - - const normalizedItems = form.items - .map((item, index) => ({ - ...item, - name: item.name.trim(), - sort: index + 1, - price: Number(item.price.toFixed(2)), - })) - .filter((item) => item.name); - if (normalizedItems.length === 0) { - message.warning('至少保留一个加料项'); - return; - } - - const uniqueNames = new Set( - normalizedItems.map((item) => item.name.toLowerCase()), - ); - if (uniqueNames.size !== normalizedItems.length) { - message.warning('加料项名称不能重复'); - return; - } - - if (form.maxSelect < form.minSelect) { - message.warning('最大可选数量不能小于最小可选数量'); - return; - } - - isDrawerSubmitting.value = true; - try { - await saveProductAddonGroupApi({ - storeId: selectedStoreId.value, - id: editingGroupId.value || undefined, - name: form.name.trim(), - description: form.description.trim(), - required: form.required, - minSelect: form.minSelect, - maxSelect: form.maxSelect, - sort: form.sort, - status: form.status, - productIds: [...editingProductIds.value], - items: normalizedItems.map((item) => ({ - id: item.id || undefined, - name: item.name, - price: item.price, - stock: item.stock, - sort: item.sort, - status: item.status, - })), - }); - message.success( - drawerMode.value === 'create' ? '加料组已创建' : '加料组已更新', - ); - isDrawerOpen.value = false; - await loadAddonGroups(); - } catch (error) { - console.error(error); - } finally { - isDrawerSubmitting.value = false; - } - } - - function removeGroup(item: AddonGroupCardViewModel) { - if (!selectedStoreId.value) return; - Modal.confirm({ - title: `确认删除加料组「${item.name}」吗?`, - okText: '确认删除', - cancelText: '取消', - async onOk() { - await deleteProductAddonGroupApi({ - storeId: selectedStoreId.value, - groupId: item.id, - }); - message.success('加料组已删除'); - await loadAddonGroups(); - }, - }); - } - - async function enableGroup(item: AddonGroupCardViewModel) { - if (!selectedStoreId.value) return; - try { - await changeProductAddonGroupStatusApi({ - storeId: selectedStoreId.value, - groupId: item.id, - status: 'enabled', - }); - message.success('加料组已启用'); - await loadAddonGroups(); - } catch (error) { - console.error(error); - } - } - - async function saveGroupInline( - group: AddonGroupCardViewModel, - items: ProductAddonGroupDto['items'], - ) { - if (!selectedStoreId.value) return; - await saveProductAddonGroupApi({ - storeId: selectedStoreId.value, - id: group.id, - name: group.name, - description: group.description, - required: group.required, - minSelect: group.minSelect, - maxSelect: group.maxSelect, - sort: group.sort, - status: group.status, - productIds: [...group.productIds], - items: items.map((item, index) => ({ - id: item.id || undefined, - name: item.name.trim(), - price: Number(item.price.toFixed(2)), - stock: item.stock, - sort: index + 1, - status: item.status, - })), - }); - } - - function renameItem(payload: { - group: AddonGroupCardViewModel; - itemId: string; - }) { - const currentItem = payload.group.items.find( - (item) => item.id === payload.itemId, - ); - if (!currentItem) return; - - let draftName = currentItem.name; - Modal.confirm({ - title: `改名 - ${currentItem.name}`, - okText: '确认', - cancelText: '取消', - content: () => - h(Input, { - value: draftName, - maxlength: 30, - placeholder: '请输入新名称', - 'onUpdate:value': (value: string) => { - draftName = value; - }, - }), - async onOk() { - const nextName = draftName.trim(); - if (!nextName) { - message.warning('加料项名称不能为空'); - throw new Error('invalid-name'); - } - - const nextItems = payload.group.items.map((item) => - item.id === payload.itemId ? { ...item, name: nextName } : item, - ); - const uniqueNames = new Set( - nextItems.map((item) => item.name.toLowerCase()), - ); - if (uniqueNames.size !== nextItems.length) { - message.warning('加料项名称不能重复'); - throw new Error('duplicate-name'); - } - - await saveGroupInline(payload.group, nextItems); - message.success('加料项名称已更新'); - await loadAddonGroups(); - }, - }); - } - - function removeCardItem(payload: { - group: AddonGroupCardViewModel; - itemId: string; - }) { - if (payload.group.items.length <= 1) { - message.warning('至少保留一个加料项'); - return; - } - - const currentItem = payload.group.items.find( - (item) => item.id === payload.itemId, - ); - if (!currentItem) return; - - Modal.confirm({ - title: `确认移除选项「${currentItem.name}」吗?`, - okText: '确认移除', - cancelText: '取消', - async onOk() { - const nextItems = payload.group.items.filter( - (item) => item.id !== payload.itemId, - ); - await saveGroupInline(payload.group, nextItems); - message.success('加料项已移除'); - await loadAddonGroups(); - }, - }); - } - - async function loadPickerProducts() { - if (!selectedStoreId.value) { - pickerProducts.value = []; - return; - } - isPickerLoading.value = true; - try { - pickerProducts.value = await searchProductPickerApi({ - storeId: selectedStoreId.value, - keyword: pickerKeyword.value.trim() || undefined, - limit: 500, - }); - } catch (error) { - console.error(error); - pickerProducts.value = []; - message.error('加载商品失败'); - } finally { - isPickerLoading.value = false; - } - } - - async function openBindProducts(item: AddonGroupCardViewModel) { - bindingGroupId.value = item.id; - pickerTitle.value = `关联商品 - ${item.name}`; - pickerKeyword.value = ''; - pickerSelectedIds.value = [...item.productIds]; - pickerProducts.value = []; - isPickerOpen.value = true; - await loadPickerProducts(); - } - - function setPickerOpen(value: boolean) { - isPickerOpen.value = value; - } - - function setPickerKeyword(value: string) { - pickerKeyword.value = value; - } - - 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 submitPicker() { - if (!selectedStoreId.value || !bindingGroupId.value) return; - - isPickerSubmitting.value = true; - try { - await bindProductAddonGroupProductsApi({ - storeId: selectedStoreId.value, - groupId: bindingGroupId.value, - productIds: [...pickerSelectedIds.value], - }); - message.success('关联商品已更新'); - isPickerOpen.value = false; - await loadAddonGroups(); - } catch (error) { - console.error(error); - } finally { - isPickerSubmitting.value = false; - } - } + const { + loadPickerProducts, + openBindProducts, + setPickerKeyword, + setPickerOpen, + submitPicker, + togglePickerProduct, + } = createPickerActions({ + selectedStoreId, + bindingGroupId, + isPickerOpen, + isPickerLoading, + isPickerSubmitting, + pickerKeyword, + pickerProducts, + pickerSelectedIds, + pickerTitle, + loadAddonGroups, + }); watch(selectedStoreId, () => { keyword.value = ''; diff --git a/apps/web-antd/src/views/product/addons/styles/base.less b/apps/web-antd/src/views/product/addons/styles/base.less new file mode 100644 index 0000000..6aea3bd --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/base.less @@ -0,0 +1,110 @@ +/** + * 文件职责:加料管理页面基础样式。 + * 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-action { + padding: 0; + font-size: 13px; + color: #1677ff; + cursor: pointer; + background: none; + border: none; +} + +.g-action + .g-action { + margin-left: 12px; +} + +.g-action-danger { + color: #ef4444; +} + +.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; +} diff --git a/apps/web-antd/src/views/product/addons/styles/card.less b/apps/web-antd/src/views/product/addons/styles/card.less new file mode 100644 index 0000000..83ca4d9 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/card.less @@ -0,0 +1,108 @@ +.page-product-addons { + .pad-card { + padding: 20px; + margin-bottom: 16px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + transition: var(--g-transition); + } + + .pad-card:hover { + box-shadow: var(--g-shadow-md); + } + + .pad-card.disabled { + opacity: 0.5; + } + + .pad-card-header { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 14px; + } + + .pad-card-name { + font-size: 14px; + font-weight: 600; + color: #1a1a2e; + } + + .pad-tag-required { + color: #ef4444; + background: #fef2f2; + } + + .pad-tag-optional { + color: #3b82f6; + background: #eff6ff; + } + + .pad-tag-disabled { + color: #9ca3af; + background: #f3f4f6; + } + + .pad-rule { + margin-left: 4px; + font-size: 12px; + color: #9ca3af; + } + + .pad-table { + width: 100%; + margin-bottom: 12px; + font-size: 13px; + border-collapse: collapse; + } + + .pad-table th { + padding: 8px 12px; + font-weight: 600; + color: #6b7280; + text-align: left; + background: #f8f9fb; + border-bottom: 1px solid #f3f4f6; + } + + .pad-table td { + padding: 8px 12px; + color: #1a1a2e; + border-bottom: 1px solid #f3f4f6; + } + + .pad-table tr:last-child td { + border-bottom: none; + } + + .pad-table tr:hover td { + background: rgb(22 119 255 / 3%); + } + + .pad-stock-ok { + font-size: 12px; + font-weight: 600; + color: #22c55e; + } + + .pad-stock-low { + font-size: 12px; + font-weight: 600; + color: #f59e0b; + } + + .pad-assoc { + margin-bottom: 12px; + font-size: 12px; + color: #9ca3af; + } + + .pad-card-footer { + display: flex; + gap: 8px; + align-items: center; + padding-top: 12px; + border-top: 1px solid #f3f4f6; + } +} diff --git a/apps/web-antd/src/views/product/addons/styles/drawer.less b/apps/web-antd/src/views/product/addons/styles/drawer.less new file mode 100644 index 0000000..8eecb6b --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/drawer.less @@ -0,0 +1,243 @@ +.page-product-addons { + .pad-editor-drawer { + .g-form-group { + margin-bottom: 16px; + } + + .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 { + font-size: 12px; + color: #9ca3af; + } + + .g-input, + .g-textarea { + width: 100%; + padding: 0 10px; + font-size: 13px; + color: #1a1a2e; + outline: none; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: var(--g-transition); + } + + .g-input { + height: 34px; + } + + .g-textarea { + min-height: 66px; + padding-top: 8px; + line-height: 1.5; + resize: vertical; + } + + .g-input:focus, + .g-textarea:focus { + border-color: #1677ff; + box-shadow: 0 0 0 3px rgb(22 119 255 / 12%); + } + + .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; + } + + .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); + } + + .pad-sel-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px; + } + + .pad-sel-row span { + font-size: 13px; + font-weight: 500; + color: #4b5563; + } + + .pad-input-num { + width: 80px; + } + + .pad-opt-list-header { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + color: #6b7280; + } + + .pad-opt-list-header .h-name { + flex: 1; + } + + .pad-opt-list-header .h-price, + .pad-opt-list-header .h-stock { + width: 80px; + } + + .pad-opt-list-header .h-act { + width: 34px; + } + + .pad-opt-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .pad-opt-row { + display: flex; + gap: 8px; + align-items: center; + } + + .pad-opt-name { + flex: 1; + } + + .pad-opt-price, + .pad-opt-stock { + width: 80px; + } + + .pad-opt-del { + display: inline-flex; + 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; + } + + .pad-opt-del:hover { + color: #ef4444; + background: #fef2f2; + border-color: #ef4444; + } + + .pad-btn-dashed { + justify-content: center; + width: 100%; + margin-top: 8px; + color: #9ca3af; + border-style: dashed; + } + + .pad-btn-dashed:hover { + color: #1677ff; + border-color: #1677ff; + } + } +} diff --git a/apps/web-antd/src/views/product/addons/styles/index.less b/apps/web-antd/src/views/product/addons/styles/index.less index 1938f46..9d1b3c2 100644 --- a/apps/web-antd/src/views/product/addons/styles/index.less +++ b/apps/web-antd/src/views/product/addons/styles/index.less @@ -1,620 +1,6 @@ -/** - * 文件职责:加料管理页面样式。 - * 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-action { - padding: 0; - font-size: 13px; - color: #1677ff; - cursor: pointer; - background: none; - border: none; -} - -.g-action + .g-action { - margin-left: 12px; -} - -.g-action-danger { - color: #ef4444; -} - -.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; -} - -.page-product-addons { - @media (width <= 1200px) { - .pad-toolbar { - flex-wrap: wrap; - } - - .pad-spacer { - display: none; - } - } - - .pad-page { - display: flex; - flex-direction: column; - gap: 16px; - max-width: 960px; - } - - .pad-toolbar { - display: flex; - gap: 12px; - align-items: center; - padding: 12px 16px; - background: #fff; - border-radius: 10px; - box-shadow: var(--g-shadow-sm); - } - - .pad-store-select { - width: 220px; - } - - .pad-store-select .ant-select-selector { - height: 34px !important; - border-color: #e5e7eb !important; - border-radius: 8px !important; - } - - .pad-store-select .ant-select-selection-item { - line-height: 32px !important; - } - - .pad-search { - width: 220px; - } - - .pad-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; - } - - .pad-spacer { - flex: 1; - } - - .pad-stats { - display: flex; - gap: 24px; - padding: 10px 16px; - font-size: 13px; - color: #4b5563; - background: #fff; - border-radius: 10px; - box-shadow: var(--g-shadow-sm); - } - - .pad-stats span { - display: flex; - gap: 6px; - align-items: center; - } - - .pad-stats strong { - font-weight: 600; - color: #1a1a2e; - } - - .pad-card { - padding: 20px; - margin-bottom: 16px; - background: #fff; - border-radius: 10px; - box-shadow: var(--g-shadow-sm); - transition: var(--g-transition); - } - - .pad-card:hover { - box-shadow: var(--g-shadow-md); - } - - .pad-card.disabled { - opacity: 0.5; - } - - .pad-card-header { - display: flex; - gap: 10px; - align-items: center; - margin-bottom: 14px; - } - - .pad-card-name { - font-size: 14px; - font-weight: 600; - color: #1a1a2e; - } - - .pad-tag-required { - color: #ef4444; - background: #fef2f2; - } - - .pad-tag-optional { - color: #3b82f6; - background: #eff6ff; - } - - .pad-tag-disabled { - color: #9ca3af; - background: #f3f4f6; - } - - .pad-rule { - margin-left: 4px; - font-size: 12px; - color: #9ca3af; - } - - .pad-table { - width: 100%; - margin-bottom: 12px; - font-size: 13px; - border-collapse: collapse; - } - - .pad-table th { - padding: 8px 12px; - font-weight: 600; - color: #6b7280; - text-align: left; - background: #f8f9fb; - border-bottom: 1px solid #f3f4f6; - } - - .pad-table td { - padding: 8px 12px; - color: #1a1a2e; - border-bottom: 1px solid #f3f4f6; - } - - .pad-table tr:last-child td { - border-bottom: none; - } - - .pad-table tr:hover td { - background: rgb(22 119 255 / 3%); - } - - .pad-stock-ok { - font-size: 12px; - font-weight: 600; - color: #22c55e; - } - - .pad-stock-low { - font-size: 12px; - font-weight: 600; - color: #f59e0b; - } - - .pad-assoc { - margin-bottom: 12px; - font-size: 12px; - color: #9ca3af; - } - - .pad-card-footer { - display: flex; - gap: 8px; - align-items: center; - padding-top: 12px; - border-top: 1px solid #f3f4f6; - } - - .pad-empty { - padding: 28px 14px; - font-size: 13px; - color: #9ca3af; - text-align: center; - background: #fff; - border-radius: 10px; - box-shadow: var(--g-shadow-sm); - } - - .pad-editor-drawer { - .g-form-group { - margin-bottom: 16px; - } - - .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 { - font-size: 12px; - color: #9ca3af; - } - - .g-input, - .g-textarea { - width: 100%; - padding: 0 10px; - font-size: 13px; - color: #1a1a2e; - outline: none; - border: 1px solid #e5e7eb; - border-radius: 8px; - transition: var(--g-transition); - } - - .g-input { - height: 34px; - } - - .g-textarea { - min-height: 66px; - padding-top: 8px; - line-height: 1.5; - resize: vertical; - } - - .g-input:focus, - .g-textarea:focus { - border-color: #1677ff; - box-shadow: 0 0 0 3px rgb(22 119 255 / 12%); - } - - .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; - } - - .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); - } - - .pad-sel-row { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 16px; - } - - .pad-sel-row span { - font-size: 13px; - font-weight: 500; - color: #4b5563; - } - - .pad-input-num { - width: 80px; - } - - .pad-opt-list-header { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 8px; - font-size: 12px; - font-weight: 600; - color: #6b7280; - } - - .pad-opt-list-header .h-name { - flex: 1; - } - - .pad-opt-list-header .h-price, - .pad-opt-list-header .h-stock { - width: 80px; - } - - .pad-opt-list-header .h-act { - width: 34px; - } - - .pad-opt-list { - display: flex; - flex-direction: column; - gap: 10px; - } - - .pad-opt-row { - display: flex; - gap: 8px; - align-items: center; - } - - .pad-opt-name { - flex: 1; - } - - .pad-opt-price, - .pad-opt-stock { - width: 80px; - } - - .pad-opt-del { - display: inline-flex; - 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; - } - - .pad-opt-del:hover { - color: #ef4444; - background: #fef2f2; - border-color: #ef4444; - } - - .pad-btn-dashed { - justify-content: center; - width: 100%; - margin-top: 8px; - color: #9ca3af; - border-style: dashed; - } - - .pad-btn-dashed:hover { - color: #1677ff; - border-color: #1677ff; - } - } - - .pad-picker-search { - display: flex; - gap: 10px; - margin-bottom: 10px; - } - - .pad-btn { - height: 32px; - padding: 0 12px; - font-size: 13px; - color: #1f1f1f; - cursor: pointer; - background: #fff; - border: 1px solid #d9d9d9; - border-radius: 6px; - } - - .pad-btn-sm { - flex-shrink: 0; - } - - .pad-picker-list { - max-height: 320px; - overflow-y: auto; - border: 1px solid #f0f0f0; - border-radius: 8px; - } - - .pad-picker-item { - display: grid; - grid-template-columns: auto 1fr 140px auto; - gap: 10px; - align-items: center; - padding: 10px 12px; - cursor: pointer; - border-bottom: 1px solid #f5f5f5; - } - - .pad-picker-item:last-child { - border-bottom: none; - } - - .pad-picker-item:hover { - background: #fafcff; - } - - .pad-picker-item .name { - font-size: 13px; - color: #1a1a2e; - } - - .pad-picker-item .spu { - font-size: 12px; - color: #9ca3af; - } - - .pad-picker-item .price { - font-size: 12px; - font-weight: 600; - color: #1a1a2e; - } - - .pad-picker-empty { - padding: 28px 14px; - font-size: 13px; - color: #9ca3af; - text-align: center; - } -} +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './modal.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/addons/styles/layout.less b/apps/web-antd/src/views/product/addons/styles/layout.less new file mode 100644 index 0000000..50edbd6 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/layout.less @@ -0,0 +1,81 @@ +.page-product-addons { + .pad-page { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 960px; + } + + .pad-toolbar { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 16px; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .pad-store-select { + width: 220px; + } + + .pad-store-select .ant-select-selector { + height: 34px !important; + border-color: #e5e7eb !important; + border-radius: 8px !important; + } + + .pad-store-select .ant-select-selection-item { + line-height: 32px !important; + } + + .pad-search { + width: 220px; + } + + .pad-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; + } + + .pad-spacer { + flex: 1; + } + + .pad-stats { + display: flex; + gap: 24px; + padding: 10px 16px; + font-size: 13px; + color: #4b5563; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } + + .pad-stats span { + display: flex; + gap: 6px; + align-items: center; + } + + .pad-stats strong { + font-weight: 600; + color: #1a1a2e; + } + + .pad-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + background: #fff; + border-radius: 10px; + box-shadow: var(--g-shadow-sm); + } +} diff --git a/apps/web-antd/src/views/product/addons/styles/modal.less b/apps/web-antd/src/views/product/addons/styles/modal.less new file mode 100644 index 0000000..5f9a6d5 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/modal.less @@ -0,0 +1,70 @@ +.page-product-addons { + .pad-picker-search { + display: flex; + gap: 10px; + margin-bottom: 10px; + } + + .pad-btn { + height: 32px; + padding: 0 12px; + font-size: 13px; + color: #1f1f1f; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + } + + .pad-btn-sm { + flex-shrink: 0; + } + + .pad-picker-list { + max-height: 320px; + overflow-y: auto; + border: 1px solid #f0f0f0; + border-radius: 8px; + } + + .pad-picker-item { + display: grid; + grid-template-columns: auto 1fr 140px auto; + gap: 10px; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid #f5f5f5; + } + + .pad-picker-item:last-child { + border-bottom: none; + } + + .pad-picker-item:hover { + background: #fafcff; + } + + .pad-picker-item .name { + font-size: 13px; + color: #1a1a2e; + } + + .pad-picker-item .spu { + font-size: 12px; + color: #9ca3af; + } + + .pad-picker-item .price { + font-size: 12px; + font-weight: 600; + color: #1a1a2e; + } + + .pad-picker-empty { + padding: 28px 14px; + font-size: 13px; + color: #9ca3af; + text-align: center; + } +} diff --git a/apps/web-antd/src/views/product/addons/styles/responsive.less b/apps/web-antd/src/views/product/addons/styles/responsive.less new file mode 100644 index 0000000..1d3b679 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/responsive.less @@ -0,0 +1,11 @@ +.page-product-addons { + @media (width <= 1200px) { + .pad-toolbar { + flex-wrap: wrap; + } + + .pad-spacer { + display: none; + } + } +} diff --git a/apps/web-antd/src/views/product/specs/composables/specs-page/constants.ts b/apps/web-antd/src/views/product/specs/composables/specs-page/constants.ts new file mode 100644 index 0000000..e15a0e7 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/composables/specs-page/constants.ts @@ -0,0 +1,18 @@ +import type { SpecEditorValueForm } from '../../types'; + +/** + * 文件职责:规格做法页面常量与基础构造。 + */ +export const DEFAULT_OPTION: SpecEditorValueForm = { + id: '', + name: '', + extraPrice: 0, + sort: 1, +}; + +export function createDefaultOption(sort = 1): SpecEditorValueForm { + return { + ...DEFAULT_OPTION, + sort, + }; +} diff --git a/apps/web-antd/src/views/product/specs/composables/specs-page/data-actions.ts b/apps/web-antd/src/views/product/specs/composables/specs-page/data-actions.ts new file mode 100644 index 0000000..ab4f9cd --- /dev/null +++ b/apps/web-antd/src/views/product/specs/composables/specs-page/data-actions.ts @@ -0,0 +1,80 @@ +import type { Ref } from 'vue'; + +import type { ProductSpecDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:规格做法页面数据动作。 + * 1. 加载门店列表与模板列表。 + * 2. 维护门店切换时的数据一致性。 + */ +import { message } from 'ant-design-vue'; + +import { getProductSpecListApi } from '#/api/product'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + isLoading: Ref; + isStoreLoading: Ref; + keyword: Ref; + rows: 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 = ''; + options.rows.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 loadSpecs() { + if (!options.selectedStoreId.value) { + options.rows.value = []; + return; + } + + options.isLoading.value = true; + try { + const list = await getProductSpecListApi({ + storeId: options.selectedStoreId.value, + keyword: options.keyword.value.trim() || undefined, + }); + options.rows.value = list; + } catch (error) { + console.error(error); + options.rows.value = []; + message.error('加载模板失败'); + } finally { + options.isLoading.value = false; + } + } + + return { + loadSpecs, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/product/specs/composables/specs-page/drawer-actions.ts b/apps/web-antd/src/views/product/specs/composables/specs-page/drawer-actions.ts new file mode 100644 index 0000000..79ccd50 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/composables/specs-page/drawer-actions.ts @@ -0,0 +1,202 @@ +import type { Ref } from 'vue'; + +import type { ProductSpecDto, SaveProductSpecDto } from '#/api/product'; +import type { + ProductSpecCardViewModel, + SpecEditorForm, +} from '#/views/product/specs/types'; + +/** + * 文件职责:规格做法编辑抽屉动作。 + * 1. 管理模板新增/编辑抽屉与表单状态。 + * 2. 处理表单校验与保存提交。 + */ +import { message } from 'ant-design-vue'; + +import { saveProductSpecApi } from '#/api/product'; + +import { createDefaultOption } from './constants'; + +interface CreateDrawerActionsOptions { + drawerMode: Ref<'create' | 'edit'>; + form: SpecEditorForm; + isDrawerOpen: Ref; + isDrawerSubmitting: Ref; + loadSpecs: () => Promise; + rows: Ref; + selectedStoreId: Ref; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + function setDrawerOpen(value: boolean) { + options.isDrawerOpen.value = value; + } + + function setFormName(value: string) { + options.form.name = value; + } + + function setFormType(value: SpecEditorForm['type']) { + options.form.type = value; + } + + function setFormSelectionType(value: SpecEditorForm['selectionType']) { + options.form.selectionType = value; + } + + function setFormIsRequired(value: boolean) { + options.form.isRequired = value; + } + + function setOptionName(index: number, value: string) { + const current = options.form.values[index]; + if (!current) return; + current.name = value; + } + + function setOptionExtraPrice(index: number, value: null | number) { + const current = options.form.values[index]; + if (!current) return; + current.extraPrice = Number(value ?? 0); + } + + function addOption() { + options.form.values.push( + createDefaultOption(options.form.values.length + 1), + ); + } + + function removeOption(index: number) { + if (options.form.values.length <= 1) { + message.warning('至少保留一个选项'); + return; + } + options.form.values.splice(index, 1); + options.form.values.forEach((item, idx) => { + item.sort = idx + 1; + }); + } + + function resetForm() { + options.form.id = ''; + options.form.name = ''; + options.form.type = 'spec'; + options.form.selectionType = 'single'; + options.form.isRequired = true; + options.form.sort = options.rows.value.length + 1; + options.form.status = 'enabled'; + options.form.productIds = []; + options.form.values = [createDefaultOption()]; + } + + function openCreateDrawer() { + options.drawerMode.value = 'create'; + resetForm(); + options.isDrawerOpen.value = true; + } + + function openEditDrawer(item: ProductSpecDto) { + options.drawerMode.value = 'edit'; + options.form.id = item.id; + options.form.name = item.name; + options.form.type = item.type; + options.form.selectionType = item.selectionType; + options.form.isRequired = item.isRequired; + options.form.sort = item.sort; + options.form.status = item.status; + options.form.productIds = [...item.productIds]; + options.form.values = item.values.map((value, index) => ({ + id: value.id, + name: value.name, + extraPrice: value.extraPrice, + sort: value.sort || index + 1, + })); + if (options.form.values.length === 0) { + options.form.values = [createDefaultOption()]; + } + options.isDrawerOpen.value = true; + } + + function normalizeSubmitValues() { + return options.form.values + .map((item, index) => ({ + ...item, + name: item.name.trim(), + sort: index + 1, + })) + .filter((item) => item.name); + } + + function buildSavePayload( + values: SpecEditorForm['values'], + ): SaveProductSpecDto { + return { + storeId: options.selectedStoreId.value, + id: options.form.id || undefined, + name: options.form.name.trim(), + type: options.form.type, + selectionType: options.form.selectionType, + isRequired: options.form.isRequired, + sort: options.form.sort, + status: options.form.status, + productIds: [...options.form.productIds], + values: values.map((item) => ({ + id: item.id || undefined, + name: item.name, + extraPrice: item.extraPrice, + sort: item.sort, + })), + }; + } + + async function submitDrawer() { + if (!options.selectedStoreId.value) return; + if (!options.form.name.trim()) { + message.warning('请输入模板名称'); + return; + } + + const normalizedValues = normalizeSubmitValues(); + 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; + } + + options.isDrawerSubmitting.value = true; + try { + await saveProductSpecApi(buildSavePayload(normalizedValues)); + message.success( + options.drawerMode.value === 'create' ? '模板已添加' : '模板已保存', + ); + options.isDrawerOpen.value = false; + await options.loadSpecs(); + } catch (error) { + console.error(error); + } finally { + options.isDrawerSubmitting.value = false; + } + } + + return { + addOption, + openCreateDrawer, + openEditDrawer, + removeOption, + setDrawerOpen, + setFormIsRequired, + setFormName, + setFormSelectionType, + setFormType, + setOptionExtraPrice, + setOptionName, + submitDrawer, + }; +} diff --git a/apps/web-antd/src/views/product/specs/composables/specs-page/template-actions.ts b/apps/web-antd/src/views/product/specs/composables/specs-page/template-actions.ts new file mode 100644 index 0000000..3a8f366 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/composables/specs-page/template-actions.ts @@ -0,0 +1,56 @@ +import type { Ref } from 'vue'; + +import type { ProductSpecDto } from '#/api/product'; + +/** + * 文件职责:规格做法模板卡片动作。 + * 1. 处理模板删除。 + * 2. 处理模板复制。 + */ +import { message, Modal } from 'ant-design-vue'; + +import { copyProductSpecApi, deleteProductSpecApi } from '#/api/product'; + +interface CreateTemplateActionsOptions { + loadSpecs: () => Promise; + selectedStoreId: Ref; +} + +export function createTemplateActions(options: CreateTemplateActionsOptions) { + function removeTemplate(item: ProductSpecDto) { + if (!options.selectedStoreId.value) return; + Modal.confirm({ + title: `确认删除模板「${item.name}」吗?`, + content: '删除后将移除该模板及其选项。', + okText: '确认删除', + cancelText: '取消', + async onOk() { + await deleteProductSpecApi({ + storeId: options.selectedStoreId.value, + specId: item.id, + }); + message.success('模板已删除'); + await options.loadSpecs(); + }, + }); + } + + async function copyTemplate(item: ProductSpecDto) { + if (!options.selectedStoreId.value) return; + try { + await copyProductSpecApi({ + storeId: options.selectedStoreId.value, + specId: item.id, + }); + message.success('模板复制成功'); + await options.loadSpecs(); + } catch (error) { + console.error(error); + } + } + + return { + copyTemplate, + removeTemplate, + }; +} diff --git a/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts b/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts index 0775edb..e691948 100644 --- a/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts +++ b/apps/web-antd/src/views/product/specs/composables/useProductSpecsPage.ts @@ -1,11 +1,9 @@ import type { ProductSpecCardViewModel, SpecEditorForm, - SpecEditorValueForm, SpecsTypeFilter, } from '../types'; -import type { ProductSpecDto } from '#/api/product'; import type { StoreListItemDto } from '#/api/store'; /** @@ -22,22 +20,10 @@ import { 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, -}; +import { createDefaultOption } from './specs-page/constants'; +import { createDataActions } from './specs-page/data-actions'; +import { createDrawerActions } from './specs-page/drawer-actions'; +import { createTemplateActions } from './specs-page/template-actions'; export function useProductSpecsPage() { const stores = ref([]); @@ -62,7 +48,7 @@ export function useProductSpecsPage() { sort: 1, status: 'enabled', productIds: [], - values: [{ ...DEFAULT_OPTION }], + values: [createDefaultOption()], }); const storeOptions = computed(() => @@ -99,55 +85,14 @@ export function useProductSpecsPage() { 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; - } - } + const { loadSpecs, loadStores } = createDataActions({ + stores, + selectedStoreId, + isStoreLoading, + rows, + isLoading, + keyword, + }); function setSelectedStoreId(value: string) { selectedStoreId.value = value; @@ -161,187 +106,33 @@ export function useProductSpecsPage() { typeFilter.value = value; } - function setDrawerOpen(value: boolean) { - isDrawerOpen.value = value; - } + const { + addOption, + openCreateDrawer, + openEditDrawer, + removeOption, + setDrawerOpen, + setFormIsRequired, + setFormName, + setFormSelectionType, + setFormType, + setOptionExtraPrice, + setOptionName, + submitDrawer, + } = createDrawerActions({ + drawerMode, + form, + isDrawerOpen, + isDrawerSubmitting, + loadSpecs, + rows, + selectedStoreId, + }); - 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); - } - } + const { copyTemplate, removeTemplate } = createTemplateActions({ + selectedStoreId, + loadSpecs, + }); let keywordSearchTimer: null | ReturnType = null; diff --git a/apps/web-antd/src/views/product/specs/styles/base.less b/apps/web-antd/src/views/product/specs/styles/base.less new file mode 100644 index 0000000..4a42d9d --- /dev/null +++ b/apps/web-antd/src/views/product/specs/styles/base.less @@ -0,0 +1,93 @@ +/** + * 文件职责:规格做法页面基础样式。 + * 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; +} diff --git a/apps/web-antd/src/views/product/specs/styles/card.less b/apps/web-antd/src/views/product/specs/styles/card.less new file mode 100644 index 0000000..65a7b43 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/styles/card.less @@ -0,0 +1,365 @@ +.page-product-specs { + .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; + } +} diff --git a/apps/web-antd/src/views/product/specs/styles/drawer.less b/apps/web-antd/src/views/product/specs/styles/drawer.less new file mode 100644 index 0000000..4a0c0a5 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/styles/drawer.less @@ -0,0 +1,258 @@ +.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; + } +} diff --git a/apps/web-antd/src/views/product/specs/styles/index.less b/apps/web-antd/src/views/product/specs/styles/index.less index 192e8aa..1156c2c 100644 --- a/apps/web-antd/src/views/product/specs/styles/index.less +++ b/apps/web-antd/src/views/product/specs/styles/index.less @@ -1,845 +1,5 @@ -/** - * 文件职责:规格做法页面样式。 - * 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; - } -} +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/specs/styles/layout.less b/apps/web-antd/src/views/product/specs/styles/layout.less new file mode 100644 index 0000000..798df55 --- /dev/null +++ b/apps/web-antd/src/views/product/specs/styles/layout.less @@ -0,0 +1,114 @@ +.page-product-specs { + .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%; + } +} diff --git a/apps/web-antd/src/views/product/specs/styles/responsive.less b/apps/web-antd/src/views/product/specs/styles/responsive.less new file mode 100644 index 0000000..e16662b --- /dev/null +++ b/apps/web-antd/src/views/product/specs/styles/responsive.less @@ -0,0 +1,15 @@ +.page-product-specs { + @media (width <= 1200px) { + .psp-grid { + grid-template-columns: 1fr; + } + + .psp-toolbar { + flex-wrap: wrap; + } + + .psp-spacer { + display: none; + } + } +}