diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 11a49c8..aa84c9e 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -313,6 +313,7 @@ export interface ProductAddonItemDto { id: string; name: string; price: number; + stock: number; sort: number; status: ProductSwitchStatus; } @@ -350,6 +351,7 @@ export interface SaveProductAddonGroupDto { price: number; sort: number; status: ProductSwitchStatus; + stock: number; }>; maxSelect: number; minSelect: number; @@ -374,6 +376,13 @@ export interface ChangeProductAddonGroupStatusDto { storeId: string; } +/** 绑定加料组商品参数。 */ +export interface BindProductAddonGroupProductsDto { + groupId: string; + productIds: string[]; + storeId: string; +} + /** 商品标签。 */ export interface ProductLabelDto { color: string; @@ -696,6 +705,16 @@ export async function changeProductAddonGroupStatusApi( return requestClient.post('/product/addon/group/status', data); } +/** 绑定加料组商品。 */ +export async function bindProductAddonGroupProductsApi( + data: BindProductAddonGroupProductsDto, +) { + return requestClient.post( + '/product/addon/group/products/bind', + data, + ); +} + /** 获取标签列表。 */ export async function getProductLabelListApi(params: ProductLabelQuery) { return requestClient.get('/product/label/list', { diff --git a/apps/web-antd/src/mock/product-extensions.ts b/apps/web-antd/src/mock/product-extensions.ts index e638b4c..ba89b01 100644 --- a/apps/web-antd/src/mock/product-extensions.ts +++ b/apps/web-antd/src/mock/product-extensions.ts @@ -58,6 +58,7 @@ interface AddonItemRecord { id: string; name: string; price: number; + stock: number; sort: number; status: ProductSwitchStatus; } @@ -378,6 +379,7 @@ function toAddonGroupItem( id: addon.id, name: addon.name, price: addon.price, + stock: addon.stock, sort: addon.sort, status: addon.status, })), @@ -517,6 +519,7 @@ function createDefaultState(storeId: string): ProductExtensionStoreState { id: createId('addon-item', storeId), name: '加鸡蛋', price: 2, + stock: 160, sort: 1, status: 'enabled', }, @@ -524,6 +527,7 @@ function createDefaultState(storeId: string): ProductExtensionStoreState { id: createId('addon-item', storeId), name: '加饭', price: 3, + stock: 80, sort: 2, status: 'enabled', }, @@ -1067,6 +1071,7 @@ Mock.mock( id: normalizeText(current.id, createId('addon-item', storeId)), name: itemName, price: Number(normalizeNumber(current.price, 0, 0).toFixed(2)), + stock: normalizeInt(current.stock, 999, 0), sort: normalizeInt(current.sort, index + 1, 1), status: normalizeSwitchStatus(current.status, 'enabled'), }; @@ -1177,6 +1182,29 @@ Mock.mock( }, ); +Mock.mock( + /\/product\/addon\/group\/products\/bind/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const groupId = normalizeText(body.groupId); + if (!storeId || !groupId) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const target = state.addonGroups.find((item) => item.id === groupId); + if (!target) return { code: 404, data: null, message: '加料组不存在' }; + + target.productIds = normalizeIdList(body.productIds).filter((productId) => + state.products.some((item) => item.id === productId), + ); + target.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toAddonGroupItem(state, target) }; + }, +); + Mock.mock( /\/product\/label\/list(?:\?|$)/, 'get', diff --git a/apps/web-antd/src/views/product/addons/components/AddonEditorDrawer.vue b/apps/web-antd/src/views/product/addons/components/AddonEditorDrawer.vue new file mode 100644 index 0000000..0ce064c --- /dev/null +++ b/apps/web-antd/src/views/product/addons/components/AddonEditorDrawer.vue @@ -0,0 +1,257 @@ + + + diff --git a/apps/web-antd/src/views/product/addons/components/AddonGroupCard.vue b/apps/web-antd/src/views/product/addons/components/AddonGroupCard.vue new file mode 100644 index 0000000..90efdee --- /dev/null +++ b/apps/web-antd/src/views/product/addons/components/AddonGroupCard.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/web-antd/src/views/product/addons/components/AddonProductPickerModal.vue b/apps/web-antd/src/views/product/addons/components/AddonProductPickerModal.vue new file mode 100644 index 0000000..d0eaf32 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/components/AddonProductPickerModal.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts b/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts new file mode 100644 index 0000000..b9a7ca6 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/composables/useProductAddonsPage.ts @@ -0,0 +1,610 @@ +import type { + AddonEditorForm, + AddonGroupCardViewModel, + AddonItemForm, +} from '../types'; + +import type { ProductAddonGroupDto, ProductPickerItemDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; + +/** + * 文件职责:加料管理页面状态与行为编排。 + * 1. 管理门店、加料组卡片、统计与筛选状态。 + * 2. 封装加料组新增编辑、选项改名移除、关联商品流程。 + */ +import { computed, h, onMounted, reactive, ref, watch } from 'vue'; + +import { Input, message, Modal } from 'ant-design-vue'; + +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', +}; + +export function useProductAddonsPage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const rows = ref([]); + const isLoading = ref(false); + const keyword = ref(''); + + const isDrawerOpen = ref(false); + const isDrawerSubmitting = ref(false); + const drawerMode = ref<'create' | 'edit'>('create'); + const editingGroupId = ref(''); + const editingGroupName = ref(''); + const editingProductIds = ref([]); + + const form = reactive({ + name: '', + description: '', + required: false, + minSelect: 0, + maxSelect: 1, + sort: 1, + status: 'enabled', + items: [{ ...DEFAULT_ITEM }], + }); + + const isPickerOpen = ref(false); + const isPickerLoading = ref(false); + const isPickerSubmitting = ref(false); + const pickerTitle = ref('关联商品'); + const pickerKeyword = ref(''); + const pickerProducts = ref([]); + const pickerSelectedIds = ref([]); + const bindingGroupId = ref(''); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const filteredRows = computed(() => { + const normalized = keyword.value.trim().toLowerCase(); + if (!normalized) return rows.value; + return rows.value.filter((item) => + item.name.toLowerCase().includes(normalized), + ); + }); + + const groupCount = computed(() => rows.value.length); + + const optionCount = computed(() => + rows.value.reduce((sum, item) => sum + item.items.length, 0), + ); + + const relatedProductCount = computed(() => + rows.value.reduce((sum, item) => sum + item.productCount, 0), + ); + + const drawerTitle = computed(() => + drawerMode.value === 'create' + ? '添加加料组' + : `编辑加料组 - ${editingGroupName.value}`, + ); + + const drawerSubmitText = computed(() => + drawerMode.value === 'create' ? '确认保存' : '保存修改', + ); + + async function loadStores() { + isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + stores.value = result.items ?? []; + if (stores.value.length === 0) { + selectedStoreId.value = ''; + rows.value = []; + return; + } + + const hasSelected = stores.value.some( + (item) => item.id === selectedStoreId.value, + ); + if (!hasSelected) { + selectedStoreId.value = stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + isStoreLoading.value = false; + } + } + + async function 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; + } + } + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setKeyword(value: string) { + keyword.value = value; + } + + function setDrawerOpen(value: boolean) { + isDrawerOpen.value = value; + } + + 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', + }); + } + + 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; + } + } + + watch(selectedStoreId, () => { + keyword.value = ''; + void loadAddonGroups(); + }); + + onMounted(loadStores); + + return { + addItem, + drawerSubmitText, + drawerTitle, + enableGroup, + filteredRows, + form, + groupCount, + isDrawerOpen, + isDrawerSubmitting, + isLoading, + isPickerLoading, + isPickerOpen, + isPickerSubmitting, + isStoreLoading, + keyword, + loadPickerProducts, + openBindProducts, + openCreateDrawer, + openEditDrawer, + optionCount, + pickerKeyword, + pickerProducts, + pickerSelectedIds, + pickerTitle, + relatedProductCount, + removeCardItem, + removeGroup, + removeItem, + renameItem, + selectedStoreId, + setDrawerOpen, + setFormDescription, + setFormMaxSelect, + setFormMinSelect, + setFormName, + setFormRequired, + setItemName, + setItemPrice, + setItemStock, + setKeyword, + setPickerKeyword, + setPickerOpen, + setSelectedStoreId, + storeOptions, + submitDrawer, + submitPicker, + togglePickerProduct, + }; +} diff --git a/apps/web-antd/src/views/product/addons/index.vue b/apps/web-antd/src/views/product/addons/index.vue index b3a5db1..79334f3 100644 --- a/apps/web-antd/src/views/product/addons/index.vue +++ b/apps/web-antd/src/views/product/addons/index.vue @@ -1,564 +1,164 @@ - diff --git a/apps/web-antd/src/views/product/addons/styles/index.less b/apps/web-antd/src/views/product/addons/styles/index.less new file mode 100644 index 0000000..1f74c20 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/styles/index.less @@ -0,0 +1,620 @@ +/** + * 文件职责:加料管理页面样式。 + * 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 { + .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; + border: 1px solid #e5e7eb; + border-radius: 8px; + outline: none; + 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; + color: #4b5563; + font-weight: 500; + } + + .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; + } + + @media (width <= 1200px) { + .pad-toolbar { + flex-wrap: wrap; + } + + .pad-spacer { + display: none; + } + } +} diff --git a/apps/web-antd/src/views/product/addons/types.ts b/apps/web-antd/src/views/product/addons/types.ts new file mode 100644 index 0000000..6d95e28 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/types.ts @@ -0,0 +1,30 @@ +import type { ProductAddonGroupDto, ProductSwitchStatus } from '#/api/product'; + +/** + * 文件职责:加料管理页面类型定义。 + */ + +/** 加料项编辑表单。 */ +export interface AddonItemForm { + id: string; + name: string; + price: number; + stock: number; + sort: number; + status: ProductSwitchStatus; +} + +/** 加料组编辑表单。 */ +export interface AddonEditorForm { + description: string; + items: AddonItemForm[]; + maxSelect: number; + minSelect: number; + name: string; + required: boolean; + sort: number; + status: ProductSwitchStatus; +} + +/** 加料组卡片视图模型。 */ +export type AddonGroupCardViewModel = ProductAddonGroupDto;