feat(project): restore product list drawer and detail prototype

This commit is contained in:
2026-02-22 09:41:32 +08:00
parent 3647bebf19
commit 99a947cad4
13 changed files with 3043 additions and 394 deletions

View File

@@ -140,15 +140,60 @@ export interface ProductListItemDto {
tags: string[]; tags: string[];
} }
/** 套餐分组商品。 */
export interface ProductComboGroupItemDto {
productId: string;
productName: string;
quantity: number;
sortOrder: number;
}
/** 套餐分组。 */
export interface ProductComboGroupDto {
id: string;
items: ProductComboGroupItemDto[];
maxSelect: number;
minSelect: number;
name: string;
sortOrder: number;
}
/** SKU 规格属性。 */
export interface ProductSkuAttributeDto {
optionId: string;
templateId: string;
}
/** SKU 详情。 */
export interface ProductSkuDto {
attributes: ProductSkuAttributeDto[];
id: string;
isEnabled: boolean;
originalPrice: null | number;
price: number;
skuCode: string;
sortOrder: number;
stock: number;
}
/** 商品详情。 */ /** 商品详情。 */
export interface ProductDetailDto extends ProductListItemDto { export interface ProductDetailDto extends ProductListItemDto {
addonGroupIds: string[];
comboGroups: ProductComboGroupDto[];
description: string; description: string;
imageUrls?: string[]; imageUrls: string[];
labelIds: string[];
notifyManager: boolean; notifyManager: boolean;
packingFee: null | number;
recoverAt: null | string; recoverAt: null | string;
remainStock: number; remainStock: number;
skus: ProductSkuDto[];
soldoutReason: string; soldoutReason: string;
sortWeight: number;
specTemplateIds: string[];
syncToPlatform: boolean; syncToPlatform: boolean;
timedOnShelfAt: null | string;
warningStock: null | number;
} }
/** 商品列表查询参数。 */ /** 商品列表查询参数。 */
@@ -170,22 +215,51 @@ export interface ProductDetailQuery {
/** 保存商品参数。 */ /** 保存商品参数。 */
export interface SaveProductDto { export interface SaveProductDto {
addonGroupIds?: string[];
categoryId: string; categoryId: string;
comboGroups?: Array<{
items: Array<{
productId: string;
quantity: number;
sortOrder: number;
}>;
maxSelect: number;
minSelect: number;
name: string;
sortOrder: number;
}>;
description: string; description: string;
id?: string; id?: string;
imageUrls?: string[]; imageUrls?: string[];
labelIds?: string[];
kind: ProductKind; kind: ProductKind;
name: string; name: string;
originalPrice: null | number; originalPrice: null | number;
packingFee?: null | number;
price: number; price: number;
skus?: Array<{
attributes: Array<{
optionId: string;
templateId: string;
}>;
isEnabled: boolean;
originalPrice: null | number;
price: number;
skuCode?: string;
sortOrder: number;
stock: number;
}>;
shelfMode: 'draft' | 'now' | 'scheduled'; shelfMode: 'draft' | 'now' | 'scheduled';
sortWeight?: number;
specTemplateIds?: string[];
spuCode?: string; spuCode?: string;
status: ProductStatus; status: ProductStatus;
stock: number; stock: number;
storeId: string; storeId: string;
subtitle: string; subtitle: string;
tags: string[]; tags?: string[];
timedOnShelfAt?: string; timedOnShelfAt?: string;
warningStock?: null | number;
} }
/** 删除商品参数。 */ /** 删除商品参数。 */

View File

@@ -1,6 +1,21 @@
import type { ProductDetailFormState } from '../types'; import type {
ProductDetailComboGroupItemState,
ProductDetailComboGroupState,
ProductDetailFormState,
ProductDetailSkuAttrState,
ProductDetailSkuBatchState,
ProductDetailSkuRowState,
} from '../types';
import type { ProductDetailDto, ProductStatus } from '#/api/product'; import type {
ProductAddonGroupDto,
ProductDetailDto,
ProductLabelDto,
ProductPickerItemDto,
ProductSkuDto,
ProductSpecDto,
ProductStatus,
} from '#/api/product';
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -10,14 +25,14 @@ import { message } from 'ant-design-vue';
import { uploadTenantFileApi } from '#/api/files'; import { uploadTenantFileApi } from '#/api/files';
import { import {
deleteProductApi, deleteProductApi,
getProductAddonGroupListApi,
getProductCategoryListApi, getProductCategoryListApi,
getProductDetailApi, getProductDetailApi,
getProductLabelListApi,
getProductSpecListApi,
saveProductApi, saveProductApi,
searchProductPickerApi,
} from '#/api/product'; } from '#/api/product';
import {
tagsToText,
textToTags,
} from '#/views/product/list/composables/product-list-page/helpers';
const DEFAULT_FORM: ProductDetailFormState = { const DEFAULT_FORM: ProductDetailFormState = {
id: '', id: '',
@@ -26,16 +41,28 @@ const DEFAULT_FORM: ProductDetailFormState = {
categoryId: '', categoryId: '',
kind: 'single', kind: 'single',
description: '', description: '',
sortWeight: 0,
imageUrls: [],
price: 0, price: 0,
originalPrice: null, originalPrice: null,
stock: 0, stock: 0,
warningStock: null,
packingFee: null,
specTemplateIds: [],
addonGroupIds: [],
labelIds: [],
skus: [],
comboGroups: [],
status: 'off_shelf', status: 'off_shelf',
tagsText: '',
imageUrls: [],
shelfMode: 'draft', shelfMode: 'draft',
timedOnShelfAt: '', timedOnShelfAt: '',
}; };
interface CategoryOption {
label: string;
value: string;
}
export function useProductDetailPage() { export function useProductDetailPage() {
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -44,7 +71,24 @@ export function useProductDetailPage() {
const isSubmitting = ref(false); const isSubmitting = ref(false);
const isUploadingImage = ref(false); const isUploadingImage = ref(false);
const detail = ref<null | ProductDetailDto>(null); const detail = ref<null | ProductDetailDto>(null);
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
const categoryOptions = ref<CategoryOption[]>([]);
const specTemplateOptions = ref<ProductSpecDto[]>([]);
const addonGroupOptions = ref<ProductAddonGroupDto[]>([]);
const labelOptions = ref<ProductLabelDto[]>([]);
const comboPickerOpen = ref(false);
const comboPickerLoading = ref(false);
const comboPickerKeyword = ref('');
const comboPickerProducts = ref<ProductPickerItemDto[]>([]);
const comboPickerSelectedIds = ref<string[]>([]);
const comboPickerCurrentGroupIndex = ref(-1);
const skuBatch = reactive<ProductDetailSkuBatchState>({
price: null,
stock: null,
});
const form = reactive<ProductDetailFormState>({ ...DEFAULT_FORM }); const form = reactive<ProductDetailFormState>({ ...DEFAULT_FORM });
const storeId = computed(() => String(route.query.storeId || '')); const storeId = computed(() => String(route.query.storeId || ''));
@@ -62,7 +106,30 @@ export function useProductDetailPage() {
return '#9ca3af'; return '#9ca3af';
}); });
/** 回填编辑表单。 */ const skuTemplateColumns = computed(() =>
form.specTemplateIds
.map((id) => specTemplateOptions.value.find((item) => item.id === id))
.filter(Boolean),
);
const comboPickerSelectedProducts = computed(() =>
comboPickerProducts.value.filter((item) =>
comboPickerSelectedIds.value.includes(item.id),
),
);
function resetForm() {
Object.assign(form, {
...DEFAULT_FORM,
imageUrls: [],
specTemplateIds: [],
addonGroupIds: [],
labelIds: [],
skus: [],
comboGroups: [],
});
}
function patchForm(data: ProductDetailDto) { function patchForm(data: ProductDetailDto) {
form.id = data.id; form.id = data.id;
form.name = data.name; form.name = data.name;
@@ -70,54 +137,222 @@ export function useProductDetailPage() {
form.categoryId = data.categoryId; form.categoryId = data.categoryId;
form.kind = data.kind; form.kind = data.kind;
form.description = data.description; form.description = data.description;
form.price = data.price; form.sortWeight = Math.max(0, Number(data.sortWeight || 0));
form.originalPrice = data.originalPrice;
form.stock = data.stock; form.imageUrls = dedupeTextList([
...(data.imageUrls || []),
data.imageUrl,
]).slice(0, 5);
form.price = Number(data.price || 0);
form.originalPrice =
data.originalPrice !== null && data.originalPrice !== undefined
? Number(data.originalPrice)
: null;
form.stock = Math.max(0, Math.floor(Number(data.stock || 0)));
form.warningStock =
data.warningStock !== null && data.warningStock !== undefined
? Math.max(0, Math.floor(Number(data.warningStock)))
: null;
form.packingFee =
data.packingFee !== null && data.packingFee !== undefined
? Number(data.packingFee)
: null;
form.specTemplateIds = dedupeTextList(data.specTemplateIds || []);
form.addonGroupIds = dedupeTextList(data.addonGroupIds || []);
form.labelIds = dedupeTextList(data.labelIds || []);
form.skus = normalizeSkuRows(data.skus || []);
form.comboGroups = normalizeComboGroups(data.comboGroups || []);
form.status = data.status; form.status = data.status;
form.tagsText = tagsToText(data.tags || []); if (data.status === 'on_sale') {
form.imageUrls = [...(data.imageUrls || []), data.imageUrl] form.shelfMode = 'now';
.map((item) => String(item || '').trim()) } else if (data.timedOnShelfAt) {
.filter(Boolean) form.shelfMode = 'scheduled';
.filter((item, index, source) => source.indexOf(item) === index) } else {
.slice(0, 5); form.shelfMode = 'draft';
form.shelfMode = data.status === 'on_sale' ? 'now' : 'draft'; }
form.timedOnShelfAt = ''; form.timedOnShelfAt = data.timedOnShelfAt || '';
} }
/** 解析门店+商品并拉取详情。 */ function getTemplateName(templateId: string) {
async function loadDetail() { return (
if (!storeId.value || !productId.value) { specTemplateOptions.value.find((item) => item.id === templateId)?.name ||
detail.value = null; templateId
categoryOptions.value = []; );
}
function getOptionName(templateId: string, optionId: string) {
const template = specTemplateOptions.value.find(
(item) => item.id === templateId,
);
if (!template) return optionId;
return (
template.values.find((item) => item.id === optionId)?.name || optionId
);
}
function getSkuAttrOptionId(
row: ProductDetailSkuRowState,
templateId: string,
) {
return row.attributes.find((item) => item.templateId === templateId)
?.optionId;
}
function buildSkuRows() {
const selectedTemplates = form.specTemplateIds
.map((id) => specTemplateOptions.value.find((item) => item.id === id))
.filter(Boolean)
.map((item) => ({
id: item.id,
name: item.name,
options: [...(item.values || [])]
.toSorted((a, b) => a.sort - b.sort)
.map((value) => ({
id: value.id,
name: value.name,
})),
}))
.filter((item) => item.options.length > 0);
const previousMap = new Map(
form.skus.map((item) => [buildSkuKey(item.attributes), item]),
);
const combos = buildSkuCombinations(selectedTemplates);
if (combos.length === 0) {
const fallback = previousMap.get('default');
form.skus = [
{
id: fallback?.id || '',
skuCode: fallback?.skuCode || buildLocalSkuCode(1),
price: fallback?.price ?? Number(form.price || 0),
originalPrice:
fallback?.originalPrice ??
(form.originalPrice && form.originalPrice > 0
? form.originalPrice
: null),
stock:
fallback?.stock ?? Math.max(0, Math.floor(Number(form.stock || 0))),
isEnabled: fallback?.isEnabled ?? true,
sortOrder: 1,
attributes: [],
},
];
return; return;
} }
isLoading.value = true; form.skus = combos.map((attrs, index) => {
try { const key = buildSkuKey(attrs);
const [detailData, categories] = await Promise.all([ const cached = previousMap.get(key);
getProductDetailApi({ const skuIndex = index + 1;
storeId: storeId.value, return {
productId: productId.value, id: cached?.id || '',
}), skuCode: cached?.skuCode || buildLocalSkuCode(skuIndex),
getProductCategoryListApi(storeId.value), price: cached?.price ?? Number(form.price || 0),
]); originalPrice:
cached?.originalPrice ??
(form.originalPrice && form.originalPrice > 0
? form.originalPrice
: null),
stock:
cached?.stock ?? Math.max(0, Math.floor(Number(form.stock || 0))),
isEnabled: cached?.isEnabled ?? true,
sortOrder: skuIndex,
attributes: attrs,
};
});
}
detail.value = detailData; function toggleSpecTemplate(templateId: string) {
categoryOptions.value = categories.map((item) => ({ const selected = new Set(form.specTemplateIds);
label: item.name, if (selected.has(templateId)) {
value: item.id, selected.delete(templateId);
} else {
selected.add(templateId);
}
form.specTemplateIds = [...selected];
buildSkuRows();
}
function toggleAddonGroup(groupId: string) {
const selected = new Set(form.addonGroupIds);
if (selected.has(groupId)) {
selected.delete(groupId);
} else {
selected.add(groupId);
}
form.addonGroupIds = [...selected];
}
function toggleLabel(labelId: string) {
const selected = new Set(form.labelIds);
if (selected.has(labelId)) {
selected.delete(labelId);
} else {
selected.add(labelId);
}
form.labelIds = [...selected];
}
function setSkuPrice(index: number, value: number) {
if (index < 0 || index >= form.skus.length) return;
form.skus[index] = {
...form.skus[index],
price: Number.isFinite(value)
? Math.max(0, value)
: form.skus[index].price,
};
}
function setSkuOriginalPrice(index: number, value: null | number) {
if (index < 0 || index >= form.skus.length) return;
form.skus[index] = {
...form.skus[index],
originalPrice:
value !== null && value !== undefined && Number(value) > 0
? Number(value)
: null,
};
}
function setSkuStock(index: number, value: number) {
if (index < 0 || index >= form.skus.length) return;
form.skus[index] = {
...form.skus[index],
stock: Math.max(0, Math.floor(Number(value || 0))),
};
}
function setSkuEnabled(index: number, checked: boolean) {
if (index < 0 || index >= form.skus.length) return;
form.skus[index] = {
...form.skus[index],
isEnabled: checked,
};
}
function applySkuBatchPrice() {
if (skuBatch.price === null || skuBatch.price === undefined) return;
const price = Math.max(0, Number(skuBatch.price));
form.skus = form.skus.map((item) => ({
...item,
price,
}));
}
function applySkuBatchStock() {
if (skuBatch.stock === null || skuBatch.stock === undefined) return;
const stock = Math.max(0, Math.floor(Number(skuBatch.stock)));
form.skus = form.skus.map((item) => ({
...item,
stock,
})); }));
patchForm(detailData);
} catch (error) {
console.error(error);
detail.value = null;
categoryOptions.value = [];
} finally {
isLoading.value = false;
}
} }
/** 上传图片并追加到图集。 */
async function uploadImage(file: File) { async function uploadImage(file: File) {
isUploadingImage.value = true; isUploadingImage.value = true;
try { try {
@@ -127,9 +362,7 @@ export function useProductDetailPage() {
message.error('图片上传失败'); message.error('图片上传失败');
return; return;
} }
form.imageUrls = [...form.imageUrls, url] form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5);
.filter((item, index, source) => source.indexOf(item) === index)
.slice(0, 5);
message.success('图片上传成功'); message.success('图片上传成功');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -138,24 +371,213 @@ export function useProductDetailPage() {
} }
} }
/** 设置主图。 */ function removeImage(index: number) {
if (index < 0 || index >= form.imageUrls.length) return;
form.imageUrls = form.imageUrls.filter(
(_, itemIndex) => itemIndex !== index,
);
}
function setPrimaryImage(index: number) { function setPrimaryImage(index: number) {
if (index <= 0 || index >= form.imageUrls.length) return; if (index <= 0 || index >= form.imageUrls.length) return;
const current = [...form.imageUrls]; const next = [...form.imageUrls];
const [selected] = current.splice(index, 1); const [picked] = next.splice(index, 1);
if (!selected) return; if (!picked) return;
form.imageUrls = [selected, ...current]; form.imageUrls = [picked, ...next];
} }
/** 删除图片。 */ function addComboGroup() {
function removeImage(index: number) { form.comboGroups.push({
form.imageUrls = form.imageUrls.filter((_, i) => i !== index); name: '',
minSelect: 1,
maxSelect: 1,
sortOrder: form.comboGroups.length + 1,
items: [],
});
} }
/** 保存详情。 */ function removeComboGroup(groupIndex: number) {
async function saveDetail() { if (groupIndex < 0 || groupIndex >= form.comboGroups.length) return;
form.comboGroups = form.comboGroups.filter(
(_, index) => index !== groupIndex,
);
form.comboGroups.forEach((group, index) => {
group.sortOrder = index + 1;
});
}
function removeComboItem(groupIndex: number, itemIndex: number) {
const group = form.comboGroups[groupIndex];
if (!group) return;
group.items = group.items.filter((_, index) => index !== itemIndex);
group.items.forEach((item, index) => {
item.sortOrder = index + 1;
});
}
function setComboGroupName(groupIndex: number, value: string) {
const group = form.comboGroups[groupIndex];
if (!group) return;
group.name = value;
}
function setComboGroupMinSelect(groupIndex: number, value: number) {
const group = form.comboGroups[groupIndex];
if (!group) return;
group.minSelect = Math.max(1, Math.floor(Number(value || 1)));
if (group.maxSelect < group.minSelect) {
group.maxSelect = group.minSelect;
}
}
function setComboGroupMaxSelect(groupIndex: number, value: number) {
const group = form.comboGroups[groupIndex];
if (!group) return;
group.maxSelect = Math.max(1, Math.floor(Number(value || 1)));
if (group.maxSelect < group.minSelect) {
group.minSelect = group.maxSelect;
}
}
function setComboItemQuantity(
groupIndex: number,
itemIndex: number,
value: number,
) {
const group = form.comboGroups[groupIndex];
if (!group) return;
const item = group.items[itemIndex];
if (!item) return;
item.quantity = Math.max(1, Math.floor(Number(value || 1)));
}
function setComboPickerOpen(value: boolean) {
comboPickerOpen.value = value;
if (!value) {
comboPickerCurrentGroupIndex.value = -1;
comboPickerSelectedIds.value = [];
comboPickerKeyword.value = '';
comboPickerProducts.value = [];
}
}
async function searchComboPicker() {
if (!storeId.value) return; if (!storeId.value) return;
if (!form.id) return; comboPickerLoading.value = true;
try {
const products = await searchProductPickerApi({
storeId: storeId.value,
keyword: comboPickerKeyword.value.trim() || undefined,
limit: 100,
});
comboPickerProducts.value = products.filter(
(item) => item.id !== form.id,
);
} catch (error) {
console.error(error);
comboPickerProducts.value = [];
} finally {
comboPickerLoading.value = false;
}
}
async function openComboPicker(groupIndex: number) {
const group = form.comboGroups[groupIndex];
if (!group) return;
comboPickerCurrentGroupIndex.value = groupIndex;
comboPickerSelectedIds.value = group.items.map((item) => item.productId);
setComboPickerOpen(true);
await searchComboPicker();
}
function toggleComboPickerProduct(productId: string) {
const selected = new Set(comboPickerSelectedIds.value);
if (selected.has(productId)) {
selected.delete(productId);
} else {
selected.add(productId);
}
comboPickerSelectedIds.value = [...selected];
}
function submitComboPicker() {
const groupIndex = comboPickerCurrentGroupIndex.value;
const group = form.comboGroups[groupIndex];
if (!group) {
setComboPickerOpen(false);
return;
}
const currentItemMap = new Map(
group.items.map((item) => [item.productId, item]),
);
const selectedProducts = comboPickerSelectedProducts.value;
group.items = selectedProducts.map((product, index) => {
const cached = currentItemMap.get(product.id);
return {
productId: product.id,
productName: product.name,
quantity: cached?.quantity ?? 1,
sortOrder: index + 1,
};
});
setComboPickerOpen(false);
}
async function loadDetail() {
if (!storeId.value || !productId.value) {
detail.value = null;
resetForm();
categoryOptions.value = [];
specTemplateOptions.value = [];
addonGroupOptions.value = [];
labelOptions.value = [];
return;
}
isLoading.value = true;
try {
const [detailData, categories, specs, addons, labels] = await Promise.all(
[
getProductDetailApi({
storeId: storeId.value,
productId: productId.value,
}),
getProductCategoryListApi(storeId.value),
getProductSpecListApi({ storeId: storeId.value }),
getProductAddonGroupListApi({ storeId: storeId.value }),
getProductLabelListApi({ storeId: storeId.value }),
],
);
detail.value = detailData;
categoryOptions.value = categories.map((item) => ({
label: item.name,
value: item.id,
}));
specTemplateOptions.value = specs;
addonGroupOptions.value = addons;
labelOptions.value = labels;
patchForm(detailData);
buildSkuRows();
} catch (error) {
console.error(error);
detail.value = null;
resetForm();
categoryOptions.value = [];
specTemplateOptions.value = [];
addonGroupOptions.value = [];
labelOptions.value = [];
} finally {
isLoading.value = false;
}
}
async function saveDetail() {
if (!storeId.value || !form.id) return;
if (!form.name.trim()) { if (!form.name.trim()) {
message.warning('请输入商品名称'); message.warning('请输入商品名称');
return; return;
@@ -169,6 +591,35 @@ export function useProductDetailPage() {
return; return;
} }
if (form.kind === 'combo') {
if (form.comboGroups.length === 0) {
message.warning('套餐至少需要一个分组');
return;
}
for (const group of form.comboGroups) {
if (!group.name.trim()) {
message.warning('请填写套餐分组名称');
return;
}
if (group.items.length === 0) {
message.warning(`分组「${group.name}」至少需要一个商品`);
return;
}
if (group.maxSelect < group.minSelect) {
message.warning(`分组「${group.name}」最大选择数不能小于最小选择数`);
return;
}
}
}
for (const sku of form.skus) {
if (sku.price < 0 || sku.stock < 0) {
message.warning('SKU 的售价和库存不能小于 0');
return;
}
}
isSubmitting.value = true; isSubmitting.value = true;
try { try {
const saved = await saveProductApi({ const saved = await saveProductApi({
@@ -179,21 +630,85 @@ export function useProductDetailPage() {
name: form.name.trim(), name: form.name.trim(),
subtitle: form.subtitle.trim(), subtitle: form.subtitle.trim(),
description: form.description.trim(), description: form.description.trim(),
price: Number(form.price || 0), price: Number(Number(form.price || 0).toFixed(2)),
originalPrice: originalPrice:
form.originalPrice && Number(form.originalPrice) > 0 form.originalPrice !== null &&
? Number(form.originalPrice) form.originalPrice !== undefined &&
Number(form.originalPrice) > 0
? Number(Number(form.originalPrice).toFixed(2))
: null, : null,
stock: Math.max(0, Math.floor(Number(form.stock || 0))), stock: Math.max(0, Math.floor(Number(form.stock || 0))),
tags: textToTags(form.tagsText),
status: form.status, status: form.status,
shelfMode: form.shelfMode, shelfMode: form.shelfMode,
timedOnShelfAt: timedOnShelfAt:
form.shelfMode === 'scheduled' ? form.timedOnShelfAt : undefined, form.shelfMode === 'scheduled' && form.timedOnShelfAt
? form.timedOnShelfAt
: undefined,
imageUrls: [...form.imageUrls], imageUrls: [...form.imageUrls],
sortWeight: Math.max(0, Math.floor(Number(form.sortWeight || 0))),
warningStock:
form.warningStock !== null && form.warningStock !== undefined
? Math.max(0, Math.floor(Number(form.warningStock)))
: null,
packingFee:
form.packingFee !== null && form.packingFee !== undefined
? Math.max(0, Number(Number(form.packingFee).toFixed(2)))
: null,
specTemplateIds: [...form.specTemplateIds],
addonGroupIds: [...form.addonGroupIds],
labelIds: [...form.labelIds],
skus: form.skus.map((item, index) => ({
skuCode: item.skuCode || buildLocalSkuCode(index + 1),
price: Math.max(0, Number(Number(item.price || 0).toFixed(2))),
originalPrice:
item.originalPrice !== null &&
item.originalPrice !== undefined &&
Number(item.originalPrice) > 0
? Number(Number(item.originalPrice).toFixed(2))
: null,
stock: Math.max(0, Math.floor(Number(item.stock || 0))),
isEnabled: item.isEnabled,
sortOrder: Math.max(
1,
Math.floor(Number(item.sortOrder || index + 1)),
),
attributes: item.attributes.map((attr) => ({
templateId: attr.templateId,
optionId: attr.optionId,
})),
})),
comboGroups:
form.kind === 'combo'
? form.comboGroups.map((group, groupIndex) => ({
name: group.name.trim(),
minSelect: Math.max(
1,
Math.floor(Number(group.minSelect || 1)),
),
maxSelect: Math.max(
1,
Math.floor(Number(group.maxSelect || 1)),
),
sortOrder: Math.max(
1,
Math.floor(Number(group.sortOrder || groupIndex + 1)),
),
items: group.items.map((item, itemIndex) => ({
productId: item.productId,
quantity: Math.max(1, Math.floor(Number(item.quantity || 1))),
sortOrder: Math.max(
1,
Math.floor(Number(item.sortOrder || itemIndex + 1)),
),
})),
}))
: [],
tags: [],
}); });
detail.value = saved; detail.value = saved;
patchForm(saved); patchForm(saved);
buildSkuRows();
message.success('商品详情已保存'); message.success('商品详情已保存');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -202,7 +717,6 @@ export function useProductDetailPage() {
} }
} }
/** 切换在售/下架。 */
async function toggleSaleStatus(next: ProductStatus) { async function toggleSaleStatus(next: ProductStatus) {
if (next !== 'on_sale' && next !== 'off_shelf') return; if (next !== 'on_sale' && next !== 'off_shelf') return;
form.status = next; form.status = next;
@@ -210,7 +724,11 @@ export function useProductDetailPage() {
await saveDetail(); await saveDetail();
} }
/** 删除当前商品并返回列表。 */ function setShelfMode(mode: 'draft' | 'now' | 'scheduled') {
form.shelfMode = mode;
form.status = mode === 'now' ? 'on_sale' : 'off_shelf';
}
async function deleteCurrentProduct() { async function deleteCurrentProduct() {
if (!storeId.value || !form.id) return; if (!storeId.value || !form.id) return;
await deleteProductApi({ await deleteProductApi({
@@ -221,30 +739,177 @@ export function useProductDetailPage() {
router.push('/product/list'); router.push('/product/list');
} }
/** 返回列表页。 */
function goBack() { function goBack() {
router.push('/product/list'); router.push('/product/list');
} }
watch([storeId, productId], loadDetail, { immediate: true }); watch(
[storeId, productId],
() => {
void loadDetail();
},
{ immediate: true },
);
return { return {
addonGroupOptions,
addComboGroup,
applySkuBatchPrice,
applySkuBatchStock,
categoryOptions, categoryOptions,
comboPickerCurrentGroupIndex,
comboPickerKeyword,
comboPickerLoading,
comboPickerOpen,
comboPickerProducts,
comboPickerSelectedIds,
comboPickerSelectedProducts,
deleteCurrentProduct, deleteCurrentProduct,
detail, detail,
form, form,
getOptionName,
getSkuAttrOptionId,
getTemplateName,
goBack, goBack,
isLoading, isLoading,
isSubmitting, isSubmitting,
isUploadingImage, isUploadingImage,
labelOptions,
loadDetail, loadDetail,
openComboPicker,
removeComboGroup,
removeComboItem,
removeImage, removeImage,
saveDetail, saveDetail,
searchComboPicker,
setComboGroupMaxSelect,
setComboGroupMinSelect,
setComboGroupName,
setComboItemQuantity,
setComboPickerOpen,
setPrimaryImage, setPrimaryImage,
setShelfMode,
setSkuEnabled,
setSkuOriginalPrice,
setSkuPrice,
setSkuStock,
skuBatch,
skuTemplateColumns,
specTemplateOptions,
statusColor, statusColor,
statusText, statusText,
storeId, storeId,
submitComboPicker,
toggleAddonGroup,
toggleComboPickerProduct,
toggleLabel,
toggleSaleStatus, toggleSaleStatus,
toggleSpecTemplate,
uploadImage, uploadImage,
}; };
} }
function dedupeTextList(source: string[]) {
return source
.map((item) => String(item || '').trim())
.filter(Boolean)
.filter((item, index, list) => list.indexOf(item) === index);
}
function buildSkuCombinations(
templates: Array<{
id: string;
name: string;
options: Array<{ id: string; name: string }>;
}>,
) {
if (templates.length === 0) return [];
const combos: ProductDetailSkuAttrState[][] = [];
const walk = (depth: number, chain: ProductDetailSkuAttrState[]) => {
if (depth >= templates.length) {
combos.push([...chain]);
return;
}
const current = templates[depth];
for (const option of current.options) {
walk(depth + 1, [
...chain,
{
templateId: current.id,
optionId: option.id,
},
]);
}
};
walk(0, []);
return combos;
}
function buildSkuKey(attrs: ProductDetailSkuAttrState[]) {
if (attrs.length === 0) return 'default';
return attrs
.toSorted((a, b) => {
if (a.templateId === b.templateId) {
return a.optionId.localeCompare(b.optionId);
}
return a.templateId.localeCompare(b.templateId);
})
.map((item) => `${item.templateId}:${item.optionId}`)
.join('|');
}
function normalizeSkuRows(source: ProductSkuDto[]) {
const rows = source.map(
(item, index): ProductDetailSkuRowState => ({
id: item.id || '',
skuCode: item.skuCode || '',
price: Number(item.price || 0),
originalPrice:
item.originalPrice !== null && item.originalPrice !== undefined
? Number(item.originalPrice)
: null,
stock: Math.max(0, Math.floor(Number(item.stock || 0))),
isEnabled: item.isEnabled !== false,
sortOrder: Math.max(1, Math.floor(Number(item.sortOrder || index + 1))),
attributes: (item.attributes || [])
.map((attr) => ({
templateId: String(attr.templateId || '').trim(),
optionId: String(attr.optionId || '').trim(),
}))
.filter((attr) => attr.templateId && attr.optionId),
}),
);
return rows.toSorted((a, b) => a.sortOrder - b.sortOrder);
}
function normalizeComboGroups(source: ProductDetailDto['comboGroups']) {
return source.map(
(group, groupIndex): ProductDetailComboGroupState => ({
name: String(group.name || '').trim(),
minSelect: Math.max(1, Math.floor(Number(group.minSelect || 1))),
maxSelect: Math.max(1, Math.floor(Number(group.maxSelect || 1))),
sortOrder: Math.max(
1,
Math.floor(Number(group.sortOrder || groupIndex + 1)),
),
items: (group.items || []).map(
(item, itemIndex): ProductDetailComboGroupItemState => ({
productId: String(item.productId || '').trim(),
productName: String(item.productName || '').trim(),
quantity: Math.max(1, Math.floor(Number(item.quantity || 1))),
sortOrder: Math.max(
1,
Math.floor(Number(item.sortOrder || itemIndex + 1)),
),
}),
),
}),
);
}
function buildLocalSkuCode(index: number) {
return `SKU-${String(index).padStart(2, '0')}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,12 @@
.page-product-detail { .page-product-detail {
font-size: 13px;
.pd-header { .pd-header {
display: flex; display: flex;
gap: 10px; flex-wrap: wrap;
gap: 12px;
align-items: center; align-items: center;
margin-bottom: 14px; margin-bottom: 18px;
} }
.pd-back-btn { .pd-back-btn {
@@ -22,12 +25,19 @@
text-overflow: ellipsis; text-overflow: ellipsis;
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: #111827; color: #1a1a2e;
white-space: nowrap; white-space: nowrap;
} }
.pd-head-subtitle {
margin-top: 2px;
font-size: 12px;
color: #6b7280;
}
.pd-head-spu { .pd-head-spu {
margin-top: 2px; margin-top: 2px;
font-family: monospace;
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
} }
@@ -36,9 +46,10 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 52px; min-width: 56px;
padding: 2px 10px; padding: 2px 12px;
font-size: 11px; font-size: 11px;
font-weight: 600;
color: #fff; color: #fff;
border-radius: 6px; border-radius: 6px;
} }
@@ -49,7 +60,7 @@
.pd-body { .pd-body {
display: flex; display: flex;
gap: 16px; gap: 18px;
align-items: flex-start; align-items: flex-start;
} }
@@ -63,7 +74,7 @@
.pd-nav-item { .pd-nav-item {
display: block; display: block;
padding: 8px 14px; padding: 9px 18px;
margin: 2px 0; margin: 2px 0;
font-size: 13px; font-size: 13px;
color: #4b5563; color: #4b5563;
@@ -74,7 +85,7 @@
.pd-nav-item:hover { .pd-nav-item:hover {
color: #1677ff; color: #1677ff;
background: #f7faff; background: #f8f9fb;
} }
.pd-nav-item.active { .pd-nav-item.active {
@@ -88,85 +99,503 @@
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 16px;
min-width: 0; min-width: 0;
} }
.pd-two-col { .pd-section-card {
display: grid; border-radius: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
} }
.pd-three-col { .pd-row {
display: grid; display: flex;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 12px;
align-items: flex-start;
margin-bottom: 14px;
} }
.pd-image-list { .pd-row:last-child {
display: grid; margin-bottom: 0;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); }
.pd-label {
flex-shrink: 0;
width: 84px;
font-size: 13px;
font-weight: 500;
line-height: 32px;
color: #4b5563;
text-align: right;
}
.pd-label.required::before {
margin-right: 2px;
color: #ef4444;
content: '*';
}
.pd-ctrl {
flex: 1;
min-width: 0;
}
.pd-inline {
display: flex;
gap: 8px;
align-items: center;
}
.pd-unit {
font-size: 12px;
color: #9ca3af;
white-space: nowrap;
}
.pd-input-fluid {
width: 100%;
}
.pd-hint {
margin-top: 8px;
font-size: 12px;
color: #9ca3af;
}
.pd-upload-row {
display: flex;
gap: 12px;
align-items: center;
}
.pd-upload-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 88px;
height: 88px;
font-size: 28px;
color: #9ca3af;
cursor: pointer;
background: #f8f9fb;
border: 1px dashed #d1d5db;
border-radius: 8px;
transition: all 0.2s ease;
}
.pd-upload-trigger:hover {
color: #1677ff;
background: #f3f8ff;
border-color: #1677ff;
}
.pd-upload-trigger.disabled {
color: #d1d5db;
cursor: not-allowed;
background: #f3f4f6;
border-color: #e5e7eb;
}
.pd-upload-hint {
font-size: 12px;
color: #9ca3af;
}
.pd-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
margin-top: 12px; margin-top: 12px;
} }
.pd-image-item { .pd-thumb {
position: relative;
width: 88px;
height: 88px;
overflow: hidden; overflow: hidden;
background: #fff; background: #f8f9fb;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
} }
.pd-image-item.primary { .pd-thumb img {
border-color: #1677ff;
box-shadow: 0 0 0 1px rgb(22 119 255 / 25%);
}
.pd-image-item img {
display: block; display: block;
width: 100%; width: 100%;
height: 130px; height: 100%;
object-fit: cover; object-fit: cover;
background: #f3f4f6;
} }
.pd-image-actions { .pd-thumb-main {
border-color: #1677ff;
box-shadow: 0 0 0 1px #1677ff;
}
.pd-thumb-badge {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 18px;
font-size: 11px;
line-height: 18px;
color: #fff;
text-align: center;
background: #1677ff;
}
.pd-thumb-del {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
font-size: 12px;
line-height: 1;
color: #fff;
cursor: pointer;
background: #ef4444;
border: none;
border-radius: 50%;
}
.pd-thumb-set-main {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 20px;
font-size: 11px;
color: #fff;
cursor: pointer;
background: rgb(0 0 0 / 45%);
border: none;
}
.pd-price-alert {
padding: 8px 12px;
margin-bottom: 12px;
font-size: 12px;
color: #1677ff;
background: rgb(22 119 255 / 6%);
border: 1px solid rgb(22 119 255 / 20%);
border-radius: 8px;
}
.pd-pills {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
}
.pd-pill {
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 8px; height: 30px;
border-top: 1px solid #f3f4f6; padding: 0 12px;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 0.2s ease;
} }
.pd-module-hint { .pd-pill:hover {
margin-bottom: 10px; color: #1677ff;
font-size: 13px; border-color: #1677ff;
color: #6b7280;
} }
.pd-sku-row { .pd-pill.checked {
display: grid; font-weight: 600;
grid-template-columns: minmax(120px, 1fr) 120px 120px; color: #1677ff;
background: rgb(22 119 255 / 8%);
border-color: #1677ff;
}
.pd-sku-hd {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.pd-sku-hd-left {
display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
.pd-sku-row .name { .pd-sku-title {
font-size: 16px;
font-weight: 600;
color: #1a1a2e;
}
.pd-sku-count {
font-size: 12px;
color: #9ca3af;
}
.pd-sku-hint {
font-size: 12px;
color: #9ca3af;
}
.pd-sku-batch {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.pd-sku-batch label {
font-size: 12px;
color: #6b7280;
white-space: nowrap;
}
.pd-sku-batch-input {
width: 110px;
}
.pd-sku-batch-gap {
width: 16px;
}
.pd-table-wrap {
overflow-x: auto;
}
.pd-sku-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
}
.pd-sku-table th {
padding: 9px 12px;
font-size: 12px;
font-weight: 500;
color: #6b7280;
text-align: left;
white-space: nowrap;
background: #f8f9fb;
border-bottom: 1px solid #f0f0f0;
}
.pd-sku-table td {
padding: 8px 12px;
vertical-align: top;
color: #1a1a2e;
border-bottom: 1px solid #f3f4f6;
}
.pd-sku-table tr:last-child td {
border-bottom: none;
}
.pd-sku-spec {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
color: #4b5563;
white-space: nowrap;
background: #f0f0f0;
border-radius: 4px;
}
.pd-sku-code {
font-family: monospace;
font-size: 11px;
color: #9ca3af;
}
.pd-sku-stock-warn {
margin-top: 2px;
font-size: 11px;
color: #fa8c16;
}
.pd-sku-stock-warn.soldout {
color: #ef4444;
}
.pd-sku-toggle {
display: flex;
align-items: center;
justify-content: center;
}
.pd-tag-wrap {
display: flex;
flex-wrap: wrap;
}
.pd-tag-sel {
display: inline-flex;
align-items: center;
padding: 4px 12px;
margin: 0 8px 8px 0;
font-size: 12px;
color: #4b5563;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 0.2s ease;
}
.pd-tag-sel:hover {
color: #1677ff;
border-color: #1677ff;
}
.pd-tag-sel.selected {
font-weight: 600;
color: #1677ff;
background: rgb(22 119 255 / 8%);
border-color: #1677ff;
}
.pd-combo-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pd-combo-card {
padding: 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.pd-combo-head {
display: flex;
gap: 10px;
align-items: center;
}
.pd-combo-range {
display: inline-flex;
gap: 8px;
align-items: center;
white-space: nowrap;
}
.pd-combo-range .ant-input-number {
width: 70px;
}
.pd-combo-items {
margin-top: 10px;
}
.pd-combo-item {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.pd-combo-item .name {
font-size: 13px; font-size: 13px;
color: #374151; color: #374151;
} }
.pd-sku-price, .pd-combo-item .actions {
.pd-sku-stock { display: inline-flex;
width: 100%; gap: 8px;
align-items: center;
}
.pd-combo-item .actions .ant-input-number {
width: 70px;
}
.pd-combo-empty {
font-size: 12px;
color: #9ca3af;
}
.pd-combo-add-item {
padding-left: 0;
}
.pd-combo-add-group {
height: 36px;
color: #1677ff;
cursor: pointer;
background: #fff;
border: 1px dashed #d1d5db;
border-radius: 8px;
}
.pd-shelf-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.pd-shelf-opt {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 12px;
cursor: pointer;
border: 1px solid #e5e7eb;
border-radius: 10px;
transition: all 0.2s ease;
}
.pd-shelf-opt:hover {
border-color: #1677ff;
}
.pd-shelf-opt.active {
background: rgb(22 119 255 / 5%);
border-color: #1677ff;
}
.pd-shelf-opt input[type='radio'] {
margin-top: 3px;
accent-color: #1677ff;
}
.pd-shelf-opt-body {
flex: 1;
}
.pd-shelf-opt-title {
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
}
.pd-shelf-opt-desc {
margin-top: 2px;
font-size: 12px;
color: #9ca3af;
}
.pd-shelf-time {
display: flex;
gap: 8px;
align-items: center;
margin-top: 8px;
} }
.pd-save-bar { .pd-save-bar {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 12; z-index: 11;
padding: 12px 20px;
background: #fafbfc;
border-radius: 10px; border-radius: 10px;
} }
@@ -175,6 +604,61 @@
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
} }
.pd-picker-search {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.pd-picker-list {
max-height: 320px;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.pd-picker-empty {
padding: 20px 12px;
color: #9ca3af;
text-align: center;
}
.pd-picker-item {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
}
.pd-picker-item:last-child {
border-bottom: none;
}
.pd-picker-item .name {
flex: 1;
min-width: 0;
}
.pd-picker-item .spu {
font-family: monospace;
font-size: 12px;
color: #9ca3af;
}
.pd-picker-item .price {
font-size: 12px;
color: #111827;
white-space: nowrap;
}
.pd-picker-selected {
margin-top: 10px;
font-size: 12px;
color: #1677ff;
}
} }
@media (width <= 1024px) { @media (width <= 1024px) {
@@ -188,9 +672,13 @@
width: 100%; width: 100%;
} }
.pd-two-col, .pd-label {
.pd-three-col { width: 72px;
grid-template-columns: 1fr; }
.pd-combo-head {
flex-direction: column;
align-items: stretch;
} }
} }
} }

View File

@@ -1,23 +1,77 @@
import type { ProductKind, ProductStatus } from '#/api/product'; import type {
ProductKind,
export interface ProductDetailFormState { ProductStatus,
categoryId: string; ProductSwitchStatus,
description: string; } from '#/api/product';
id: string;
imageUrls: string[];
kind: ProductKind;
name: string;
originalPrice: null | number;
price: number;
shelfMode: 'draft' | 'now' | 'scheduled';
status: ProductStatus;
stock: number;
subtitle: string;
tagsText: string;
timedOnShelfAt: string;
}
export interface ProductDetailSectionItem { export interface ProductDetailSectionItem {
id: string; id: string;
title: string; title: string;
} }
export interface ProductDetailPillOption {
count?: number;
id: string;
name: string;
status?: ProductSwitchStatus;
}
export interface ProductDetailSkuAttrState {
optionId: string;
templateId: string;
}
export interface ProductDetailSkuRowState {
attributes: ProductDetailSkuAttrState[];
id: string;
isEnabled: boolean;
originalPrice: null | number;
price: number;
skuCode: string;
sortOrder: number;
stock: number;
}
export interface ProductDetailComboGroupItemState {
productId: string;
productName: string;
quantity: number;
sortOrder: number;
}
export interface ProductDetailComboGroupState {
items: ProductDetailComboGroupItemState[];
maxSelect: number;
minSelect: number;
name: string;
sortOrder: number;
}
export interface ProductDetailFormState {
addonGroupIds: string[];
categoryId: string;
comboGroups: ProductDetailComboGroupState[];
description: string;
id: string;
imageUrls: string[];
kind: ProductKind;
labelIds: string[];
name: string;
originalPrice: null | number;
packingFee: null | number;
price: number;
shelfMode: 'draft' | 'now' | 'scheduled';
skus: ProductDetailSkuRowState[];
sortWeight: number;
specTemplateIds: string[];
status: ProductStatus;
stock: number;
subtitle: string;
timedOnShelfAt: string;
warningStock: null | number;
}
export interface ProductDetailSkuBatchState {
price: null | number;
stock: null | number;
}

View File

@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { ProductEditorFormState } from '../types';
/** /**
* 文件职责:商品添加/编辑抽屉。 * 文件职责:商品添加/编辑抽屉。
* 1. 展示商品核心信息与上架方式表单 * 1. 展示商品基础字段、套餐分组与上架方式。
* 2. 通过回调更新父级状态并触发提交。 * 2. 通过回调更新父级状态并触发提交。
*/ */
import type { ProductEditorFormState } from '../types'; import type { ProductPickerItemDto } from '#/api/product';
import { import {
Button, Button,
@@ -14,6 +16,7 @@ import {
Drawer, Drawer,
Input, Input,
InputNumber, InputNumber,
Modal,
Radio, Radio,
Select, Select,
} from 'ant-design-vue'; } from 'ant-design-vue';
@@ -26,9 +29,29 @@ interface CategoryOption {
interface Props { interface Props {
categoryOptions: CategoryOption[]; categoryOptions: CategoryOption[];
comboPickerKeyword: string;
comboPickerLoading: boolean;
comboPickerOpen: boolean;
comboPickerProducts: ProductPickerItemDto[];
comboPickerSelectedIds: string[];
form: ProductEditorFormState; form: ProductEditorFormState;
isSaving: boolean; isSaving: boolean;
onAddComboGroup: () => void;
onOpenComboGroupPicker: (groupIndex: number) => void;
onRemoveComboGroup: (groupIndex: number) => void;
onRemoveComboItem: (groupIndex: number, itemIndex: number) => void;
onSearchComboPicker: () => void;
onSetCategoryId: (value: string) => void; onSetCategoryId: (value: string) => void;
onSetComboGroupMaxSelect: (groupIndex: number, value: number) => void;
onSetComboGroupMinSelect: (groupIndex: number, value: number) => void;
onSetComboGroupName: (groupIndex: number, value: string) => void;
onSetComboItemQuantity: (
groupIndex: number,
itemIndex: number,
value: number,
) => void;
onSetComboPickerKeyword: (value: string) => void;
onSetComboPickerOpen: (value: boolean) => void;
onSetDescription: (value: string) => void; onSetDescription: (value: string) => void;
onSetKind: (value: 'combo' | 'single') => void; onSetKind: (value: 'combo' | 'single') => void;
onSetName: (value: string) => void; onSetName: (value: string) => void;
@@ -37,8 +60,9 @@ interface Props {
onSetShelfMode: (value: 'draft' | 'now' | 'scheduled') => void; onSetShelfMode: (value: 'draft' | 'now' | 'scheduled') => void;
onSetStock: (value: number) => void; onSetStock: (value: number) => void;
onSetSubtitle: (value: string) => void; onSetSubtitle: (value: string) => void;
onSetTagsText: (value: string) => void;
onSetTimedOnShelfAt: (value: string) => void; onSetTimedOnShelfAt: (value: string) => void;
onSubmitComboPicker: () => void;
onToggleComboPickerProduct: (productId: string) => void;
open: boolean; open: boolean;
showDetailLink: boolean; showDetailLink: boolean;
submitText: string; submitText: string;
@@ -59,13 +83,6 @@ function toNumber(value: null | number | string, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
/** 解析商品类型。 */
function handleKindChange(value: unknown) {
if (value === 'combo' || value === 'single') {
props.onSetKind(value);
}
}
/** 解析分类选择。 */ /** 解析分类选择。 */
function handleCategoryChange(value: unknown) { function handleCategoryChange(value: unknown) {
if (typeof value === 'number' || typeof value === 'string') { if (typeof value === 'number' || typeof value === 'string') {
@@ -88,6 +105,14 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
} }
props.onSetTimedOnShelfAt(dayjs(value).format('YYYY-MM-DD HH:mm:ss')); props.onSetTimedOnShelfAt(dayjs(value).format('YYYY-MM-DD HH:mm:ss'));
} }
function setKind(value: 'combo' | 'single') {
props.onSetKind(value);
}
function setComboPickerOpen(value: boolean) {
props.onSetComboPickerOpen(value);
}
</script> </script>
<template> <template>
@@ -95,24 +120,37 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
class="product-editor-drawer" class="product-editor-drawer"
:open="props.open" :open="props.open"
:title="props.title" :title="props.title"
:width="560" width="40%"
:mask-closable="true" :mask-closable="true"
@update:open="(value) => emit('update:open', value)" @update:open="(value) => emit('update:open', value)"
> >
<div class="product-drawer-section"> <div class="product-drawer-section">
<div class="product-drawer-section-title">商品类型</div> <div class="product-drawer-section-title">商品类型</div>
<Radio.Group <div class="product-kind-pill-group">
:value="props.form.kind" <button
class="product-kind-radio-group" type="button"
@update:value="(value) => handleKindChange(value)" class="product-kind-pill"
:class="{ active: props.form.kind === 'single' }"
@click="setKind('single')"
> >
<Radio.Button value="single">单品</Radio.Button> 单品
<Radio.Button value="combo">套餐</Radio.Button> </button>
</Radio.Group> <button
type="button"
class="product-kind-pill"
:class="{ active: props.form.kind === 'combo' }"
@click="setKind('combo')"
>
套餐
</button>
</div>
<div class="drawer-field-hint">
套餐由多个商品组合而成顾客按分组选择
</div>
</div> </div>
<div class="product-drawer-section"> <div class="product-drawer-section">
<div class="product-drawer-section-title">商品信息</div> <div class="product-drawer-section-title">基本信息</div>
<div class="drawer-form-grid"> <div class="drawer-form-grid">
<div class="drawer-form-item full"> <div class="drawer-form-item full">
<label class="drawer-form-label required">商品名称</label> <label class="drawer-form-label required">商品名称</label>
@@ -123,25 +161,26 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
/> />
</div> </div>
<div class="drawer-form-item"> <div class="drawer-form-item full">
<label class="drawer-form-label required">分类</label>
<Select
:value="props.form.categoryId"
:options="props.categoryOptions"
placeholder="请选择分类"
@update:value="(value) => handleCategoryChange(value)"
/>
</div>
<div class="drawer-form-item">
<label class="drawer-form-label">副标题</label> <label class="drawer-form-label">副标题</label>
<Input <Input
:value="props.form.subtitle" :value="props.form.subtitle"
placeholder="选填" placeholder="一句话描述卖点(选填"
@update:value="(value) => props.onSetSubtitle(String(value || ''))" @update:value="(value) => props.onSetSubtitle(String(value || ''))"
/> />
</div> </div>
<div class="drawer-form-item full">
<label class="drawer-form-label required">分类</label>
<Select
:value="props.form.categoryId"
class="full-width"
:options="props.categoryOptions"
placeholder="请选择分类"
@update:value="(value) => handleCategoryChange(value)"
/>
</div>
<div class="drawer-form-item full"> <div class="drawer-form-item full">
<label class="drawer-form-label">商品简介</label> <label class="drawer-form-label">商品简介</label>
<Input.TextArea <Input.TextArea
@@ -159,7 +198,7 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
<div class="product-drawer-section"> <div class="product-drawer-section">
<div class="product-drawer-section-title">价格与库存</div> <div class="product-drawer-section-title">价格与库存</div>
<div class="drawer-form-grid"> <div class="drawer-form-grid price-stock-grid">
<div class="drawer-form-item"> <div class="drawer-form-item">
<label class="drawer-form-label required">售价</label> <label class="drawer-form-label required">售价</label>
<InputNumber <InputNumber
@@ -176,7 +215,7 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
</div> </div>
<div class="drawer-form-item"> <div class="drawer-form-item">
<label class="drawer-form-label"></label> <label class="drawer-form-label">划线</label>
<InputNumber <InputNumber
:value="props.form.originalPrice ?? undefined" :value="props.form.originalPrice ?? undefined"
class="full-width" class="full-width"
@@ -210,16 +249,130 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
" "
/> />
</div> </div>
<div class="drawer-form-item">
<label class="drawer-form-label">标签</label>
<Input
:value="props.form.tagsText"
placeholder="多个标签用英文逗号分隔"
@update:value="(value) => props.onSetTagsText(String(value || ''))"
/>
</div> </div>
</div> </div>
<div v-if="props.form.kind === 'combo'" class="product-drawer-section">
<div class="product-drawer-section-title">套餐分组</div>
<div class="drawer-field-hint combo-hint">
每个分组可包含多个商品顾客按分组选择
</div>
<div class="combo-group-list">
<div
v-for="(group, groupIndex) in props.form.comboGroups"
:key="`combo-group-${groupIndex}`"
class="combo-group-card"
>
<div class="combo-group-head">
<Input
:value="group.name"
placeholder="请输入分组名称"
@update:value="
(value) =>
props.onSetComboGroupName(groupIndex, String(value || ''))
"
/>
<div class="combo-group-range">
<span>最少</span>
<InputNumber
:value="group.minSelect"
:min="1"
:precision="0"
:controls="false"
class="combo-range-input"
@update:value="
(value) =>
props.onSetComboGroupMinSelect(
groupIndex,
toNumber(value, group.minSelect),
)
"
/>
<span>最多</span>
<InputNumber
:value="group.maxSelect"
:min="1"
:precision="0"
:controls="false"
class="combo-range-input"
@update:value="
(value) =>
props.onSetComboGroupMaxSelect(
groupIndex,
toNumber(value, group.maxSelect),
)
"
/>
<span></span>
</div>
<Button
type="text"
danger
class="combo-remove-group"
@click="props.onRemoveComboGroup(groupIndex)"
>
删除
</Button>
</div>
<div class="combo-item-list">
<div
v-for="(item, itemIndex) in group.items"
:key="`combo-item-${groupIndex}-${item.productId}`"
class="combo-item-row"
>
<span class="combo-item-name">{{ item.productName }}</span>
<div class="combo-item-actions">
<span class="combo-item-multiplier">×</span>
<InputNumber
:value="item.quantity"
:min="1"
:precision="0"
:controls="false"
class="combo-quantity-input"
@update:value="
(value) =>
props.onSetComboItemQuantity(
groupIndex,
itemIndex,
toNumber(value, item.quantity),
)
"
/>
<Button
type="text"
class="combo-remove-item"
@click="props.onRemoveComboItem(groupIndex, itemIndex)"
>
移除
</Button>
</div>
</div>
<div v-if="group.items.length === 0" class="combo-item-empty">
暂无商品请点击下方添加商品
</div>
</div>
<Button
type="link"
class="combo-add-product"
@click="props.onOpenComboGroupPicker(groupIndex)"
>
+ 添加商品
</Button>
</div>
<button
type="button"
class="combo-add-group"
@click="props.onAddComboGroup"
>
+ 添加分组
</button>
</div>
</div> </div>
<div class="product-drawer-section"> <div class="product-drawer-section">
@@ -271,4 +424,55 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
</div> </div>
</template> </template>
</Drawer> </Drawer>
<Modal
title="添加商品到分组"
:open="props.comboPickerOpen"
ok-text="确认添加"
cancel-text="取消"
:confirm-loading="false"
:ok-button-props="{ disabled: props.comboPickerSelectedIds.length === 0 }"
@ok="props.onSubmitComboPicker"
@cancel="setComboPickerOpen(false)"
>
<div class="combo-picker-search">
<Input
:value="props.comboPickerKeyword"
allow-clear
placeholder="搜索商品名称/SPU"
@update:value="
(value) => props.onSetComboPickerKeyword(String(value || ''))
"
@press-enter="props.onSearchComboPicker"
/>
<Button @click="props.onSearchComboPicker">搜索</Button>
</div>
<div class="combo-picker-list">
<div v-if="props.comboPickerLoading" class="combo-picker-empty">
加载中...
</div>
<div
v-else-if="props.comboPickerProducts.length === 0"
class="combo-picker-empty"
>
暂无可选商品
</div>
<label
v-for="product in props.comboPickerProducts"
v-else
:key="`combo-picker-${product.id}`"
class="combo-picker-item"
>
<input
type="checkbox"
:checked="props.comboPickerSelectedIds.includes(product.id)"
@change="props.onToggleComboPickerProduct(product.id)"
/>
<span class="name">{{ product.name }}</span>
<span class="spu">{{ product.spuCode }}</span>
<span class="price">¥{{ product.price.toFixed(2) }}</span>
</label>
</div>
</Modal>
</template> </template>

View File

@@ -83,6 +83,8 @@ export const DEFAULT_EDITOR_FORM: ProductEditorFormState = {
name: '', name: '',
subtitle: '', subtitle: '',
description: '', description: '',
comboGroups: [],
imageUrls: [],
categoryId: '', categoryId: '',
kind: 'single', kind: 'single',
price: 0, price: 0,

View File

@@ -58,6 +58,8 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
options.editorForm.name = ''; options.editorForm.name = '';
options.editorForm.subtitle = ''; options.editorForm.subtitle = '';
options.editorForm.description = ''; options.editorForm.description = '';
options.editorForm.comboGroups = [];
options.editorForm.imageUrls = [];
options.editorForm.categoryId = defaultCategoryId; options.editorForm.categoryId = defaultCategoryId;
options.editorForm.kind = 'single'; options.editorForm.kind = 'single';
options.editorForm.price = 0; options.editorForm.price = 0;
@@ -107,6 +109,37 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
message.warning('请选择定时上架时间'); message.warning('请选择定时上架时间');
return; return;
} }
if (options.editorForm.kind === 'combo') {
if (options.editorForm.comboGroups.length === 0) {
message.warning('套餐至少需要一个分组');
return;
}
for (const group of options.editorForm.comboGroups) {
if (!group.name.trim()) {
message.warning('请填写套餐分组名称');
return;
}
if (group.minSelect <= 0 || group.maxSelect <= 0) {
message.warning(`分组「${group.name}」的最小/最大选择数必须大于 0`);
return;
}
if (group.maxSelect < group.minSelect) {
message.warning(
`分组「${group.name}」的最大选择数不能小于最小选择数`,
);
return;
}
if (group.items.length === 0) {
message.warning(`分组「${group.name}」至少需要一个商品`);
return;
}
if (group.items.some((item) => item.quantity <= 0)) {
message.warning(`分组「${group.name}」的商品数量必须大于 0`);
return;
}
}
}
options.isEditorSubmitting.value = true; options.isEditorSubmitting.value = true;
try { try {
@@ -141,14 +174,19 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
const current = options.currentQuickEditProduct.value; const current = options.currentQuickEditProduct.value;
options.isQuickEditSubmitting.value = true; options.isQuickEditSubmitting.value = true;
try { try {
const detail = await getProductDetailApi({
storeId: options.selectedStoreId.value,
productId: current.id,
});
await saveProductApi({ await saveProductApi({
id: current.id, id: current.id,
storeId: options.selectedStoreId.value, storeId: options.selectedStoreId.value,
categoryId: current.categoryId, categoryId: detail.categoryId,
kind: current.kind, kind: detail.kind,
name: current.name, name: detail.name,
subtitle: current.subtitle, subtitle: detail.subtitle,
description: current.subtitle, description: detail.description,
price: Number(options.quickEditForm.price || 0), price: Number(options.quickEditForm.price || 0),
originalPrice: originalPrice:
Number(options.quickEditForm.originalPrice || 0) > 0 Number(options.quickEditForm.originalPrice || 0) > 0
@@ -158,10 +196,22 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
0, 0,
Math.floor(Number(options.quickEditForm.stock || 0)), Math.floor(Number(options.quickEditForm.stock || 0)),
), ),
tags: [...current.tags], tags: [...detail.tags],
comboGroups: (detail.comboGroups ?? []).map((group) => ({
name: group.name,
minSelect: group.minSelect,
maxSelect: group.maxSelect,
sortOrder: group.sortOrder,
items: group.items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
sortOrder: item.sortOrder,
})),
})),
imageUrls: detail.imageUrls ?? [],
status: options.quickEditForm.isOnSale ? 'on_sale' : 'off_shelf', status: options.quickEditForm.isOnSale ? 'on_sale' : 'off_shelf',
shelfMode: options.quickEditForm.isOnSale ? 'now' : 'draft', shelfMode: options.quickEditForm.isOnSale ? 'now' : 'draft',
spuCode: current.spuCode, spuCode: detail.spuCode,
}); });
message.success('商品已更新'); message.success('商品已更新');
options.isQuickEditDrawerOpen.value = false; options.isQuickEditDrawerOpen.value = false;

View File

@@ -49,6 +49,19 @@ export function cloneEditorForm(
name: source.name, name: source.name,
subtitle: source.subtitle, subtitle: source.subtitle,
description: source.description, description: source.description,
comboGroups: source.comboGroups.map((group) => ({
name: group.name,
minSelect: group.minSelect,
maxSelect: group.maxSelect,
sortOrder: group.sortOrder,
items: group.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
sortOrder: item.sortOrder,
})),
})),
imageUrls: [...source.imageUrls],
categoryId: source.categoryId, categoryId: source.categoryId,
kind: source.kind, kind: source.kind,
price: source.price, price: source.price,
@@ -143,11 +156,14 @@ export function formatDateTime(value: Date | dayjs.Dayjs | string) {
export function mapListItemToEditorForm( export function mapListItemToEditorForm(
item: ProductListItemDto, item: ProductListItemDto,
): ProductEditorFormState { ): ProductEditorFormState {
const imageUrls = item.imageUrl ? [item.imageUrl] : [];
return { return {
id: item.id, id: item.id,
name: item.name, name: item.name,
subtitle: item.subtitle, subtitle: item.subtitle,
description: item.subtitle, description: item.subtitle,
comboGroups: [],
imageUrls,
categoryId: item.categoryId, categoryId: item.categoryId,
kind: item.kind, kind: item.kind,
price: item.price, price: item.price,
@@ -164,11 +180,30 @@ export function mapListItemToEditorForm(
export function mapDetailToEditorForm( export function mapDetailToEditorForm(
detail: ProductDetailDto, detail: ProductDetailDto,
): ProductEditorFormState { ): ProductEditorFormState {
const imageUrls = [...(detail.imageUrls ?? []), detail.imageUrl]
.map((item) => String(item || '').trim())
.filter(Boolean)
.filter((item, index, source) => source.indexOf(item) === index)
.slice(0, 5);
return { return {
id: detail.id, id: detail.id,
name: detail.name, name: detail.name,
subtitle: detail.subtitle, subtitle: detail.subtitle,
description: detail.description, description: detail.description,
comboGroups: (detail.comboGroups ?? []).map((group) => ({
name: group.name,
minSelect: group.minSelect,
maxSelect: group.maxSelect,
sortOrder: group.sortOrder,
items: group.items.map((item) => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
sortOrder: item.sortOrder,
})),
})),
imageUrls,
categoryId: detail.categoryId, categoryId: detail.categoryId,
kind: detail.kind, kind: detail.kind,
price: detail.price, price: detail.price,
@@ -237,6 +272,31 @@ export function toSavePayload(
: null, : null,
stock: Math.max(0, Math.floor(Number(form.stock || 0))), stock: Math.max(0, Math.floor(Number(form.stock || 0))),
tags: textToTags(form.tagsText), tags: textToTags(form.tagsText),
comboGroups:
form.kind === 'combo'
? form.comboGroups.map((group, groupIndex) => ({
name: group.name.trim(),
minSelect: Math.max(1, Math.floor(Number(group.minSelect || 1))),
maxSelect: Math.max(1, Math.floor(Number(group.maxSelect || 1))),
sortOrder: Math.max(
1,
Math.floor(Number(group.sortOrder || groupIndex + 1)),
),
items: group.items.map((item, itemIndex) => ({
productId: item.productId,
quantity: Math.max(1, Math.floor(Number(item.quantity || 1))),
sortOrder: Math.max(
1,
Math.floor(Number(item.sortOrder || itemIndex + 1)),
),
})),
}))
: [],
imageUrls: form.imageUrls
.map((item) => item.trim())
.filter(Boolean)
.filter((item, index, source) => source.indexOf(item) === index)
.slice(0, 5),
status: normalizedStatus, status: normalizedStatus,
shelfMode: form.shelfMode, shelfMode: form.shelfMode,
timedOnShelfAt: timedOnShelfAt:

View File

@@ -7,6 +7,7 @@
import type { import type {
ProductCategoryDto, ProductCategoryDto,
ProductListItemDto, ProductListItemDto,
ProductPickerItemDto,
ProductStatus, ProductStatus,
} from '#/api/product'; } from '#/api/product';
import type { StoreListItemDto } from '#/api/store'; import type { StoreListItemDto } from '#/api/store';
@@ -18,6 +19,11 @@ import type {
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { uploadTenantFileApi } from '#/api/files';
import { searchProductPickerApi } from '#/api/product';
import { createBatchActions } from './product-list-page/batch-actions'; import { createBatchActions } from './product-list-page/batch-actions';
import { import {
DEFAULT_EDITOR_FORM, DEFAULT_EDITOR_FORM,
@@ -49,6 +55,7 @@ export function useProductListPage() {
const isCategoryLoading = ref(false); const isCategoryLoading = ref(false);
const isListLoading = ref(false); const isListLoading = ref(false);
const isEditorSubmitting = ref(false); const isEditorSubmitting = ref(false);
const isEditorImageUploading = ref(false);
const isQuickEditSubmitting = ref(false); const isQuickEditSubmitting = ref(false);
const isSoldoutSubmitting = ref(false); const isSoldoutSubmitting = ref(false);
@@ -78,6 +85,13 @@ export function useProductListPage() {
const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM)); const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM));
const currentSoldoutProduct = ref<null | ProductListItemDto>(null); const currentSoldoutProduct = ref<null | ProductListItemDto>(null);
const isComboPickerOpen = ref(false);
const isComboPickerLoading = ref(false);
const comboPickerKeyword = ref('');
const comboPickerProducts = ref<ProductPickerItemDto[]>([]);
const comboPickerSelectedIds = ref<string[]>([]);
const comboPickerTargetGroupIndex = ref(-1);
// 4. 衍生状态。 // 4. 衍生状态。
const storeOptions = computed(() => const storeOptions = computed(() =>
stores.value.map((item) => ({ label: item.name, value: item.id })), stores.value.map((item) => ({ label: item.name, value: item.id })),
@@ -243,8 +257,242 @@ export function useProductListPage() {
editorForm.description = value; editorForm.description = value;
} }
function removeEditorImage(index: number) {
editorForm.imageUrls = editorForm.imageUrls.filter(
(_, idx) => idx !== index,
);
}
function setEditorPrimaryImage(index: number) {
if (index <= 0 || index >= editorForm.imageUrls.length) return;
const next = [...editorForm.imageUrls];
const [selected] = next.splice(index, 1);
if (!selected) return;
editorForm.imageUrls = [selected, ...next];
}
async function uploadEditorImage(file: File) {
if (editorForm.imageUrls.length >= 5) {
message.warning('最多上传 5 张图片');
return;
}
isEditorImageUploading.value = true;
try {
const uploaded = await uploadTenantFileApi(file, 'dish_image');
const url = String(uploaded.url || '').trim();
if (!url) {
message.error('图片上传失败');
return;
}
editorForm.imageUrls = [...editorForm.imageUrls, url]
.map((item) => String(item || '').trim())
.filter(Boolean)
.filter((item, index, source) => source.indexOf(item) === index)
.slice(0, 5);
message.success('图片上传成功');
} catch (error) {
console.error(error);
} finally {
isEditorImageUploading.value = false;
}
}
function normalizeComboGroups() {
editorForm.comboGroups = editorForm.comboGroups.map(
(group, groupIndex) => ({
...group,
sortOrder: groupIndex + 1,
items: group.items.map((item, itemIndex) => ({
...item,
sortOrder: itemIndex + 1,
})),
}),
);
}
function setEditorKind(value: 'combo' | 'single') { function setEditorKind(value: 'combo' | 'single') {
if (value === editorForm.kind) {
return;
}
if (editorForm.kind === 'combo' && value === 'single') {
const hasComboData = editorForm.comboGroups.some(
(group) => group.items.length > 0 || Boolean(group.name.trim()),
);
if (hasComboData) {
Modal.confirm({
title: '切换为单品后将清空套餐分组,确认继续吗?',
okText: '确认切换',
cancelText: '取消',
onOk() {
editorForm.kind = 'single';
editorForm.comboGroups = [];
},
});
return;
}
editorForm.comboGroups = [];
}
editorForm.kind = value; editorForm.kind = value;
if (value === 'combo' && editorForm.comboGroups.length === 0) {
editorForm.comboGroups = [
{
name: '',
minSelect: 1,
maxSelect: 1,
sortOrder: 1,
items: [],
},
];
}
}
function addEditorComboGroup() {
editorForm.comboGroups = [
...editorForm.comboGroups,
{
name: '',
minSelect: 1,
maxSelect: 1,
sortOrder: editorForm.comboGroups.length + 1,
items: [],
},
];
}
function removeEditorComboGroup(groupIndex: number) {
if (groupIndex < 0 || groupIndex >= editorForm.comboGroups.length) {
return;
}
editorForm.comboGroups = editorForm.comboGroups.filter(
(_, index) => index !== groupIndex,
);
if (comboPickerTargetGroupIndex.value === groupIndex) {
isComboPickerOpen.value = false;
comboPickerTargetGroupIndex.value = -1;
}
normalizeComboGroups();
}
function setEditorComboGroupName(groupIndex: number, value: string) {
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
group.name = value;
}
function setEditorComboGroupMinSelect(groupIndex: number, value: number) {
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
group.minSelect = Math.max(1, Math.floor(Number(value || 1)));
}
function setEditorComboGroupMaxSelect(groupIndex: number, value: number) {
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
group.maxSelect = Math.max(1, Math.floor(Number(value || 1)));
}
function removeEditorComboItem(groupIndex: number, itemIndex: number) {
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
group.items = group.items.filter((_, index) => index !== itemIndex);
normalizeComboGroups();
}
function setEditorComboItemQuantity(
groupIndex: number,
itemIndex: number,
value: number,
) {
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
const item = group.items[itemIndex];
if (!item) return;
item.quantity = Math.max(1, Math.floor(Number(value || 1)));
}
async function loadComboPickerProducts() {
if (!selectedStoreId.value) {
comboPickerProducts.value = [];
return;
}
isComboPickerLoading.value = true;
try {
comboPickerProducts.value = await searchProductPickerApi({
storeId: selectedStoreId.value,
keyword: comboPickerKeyword.value.trim() || undefined,
limit: 500,
});
} catch (error) {
console.error(error);
comboPickerProducts.value = [];
message.error('加载商品失败');
} finally {
isComboPickerLoading.value = false;
}
}
async function openComboGroupPicker(groupIndex: number) {
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
comboPickerTargetGroupIndex.value = groupIndex;
comboPickerKeyword.value = '';
comboPickerSelectedIds.value = group.items.map((item) => item.productId);
comboPickerProducts.value = [];
isComboPickerOpen.value = true;
await loadComboPickerProducts();
}
function setComboPickerOpen(value: boolean) {
isComboPickerOpen.value = value;
if (!value) {
comboPickerTargetGroupIndex.value = -1;
}
}
function setComboPickerKeyword(value: string) {
comboPickerKeyword.value = value;
}
function toggleComboPickerProduct(productId: string) {
if (comboPickerSelectedIds.value.includes(productId)) {
comboPickerSelectedIds.value = comboPickerSelectedIds.value.filter(
(id) => id !== productId,
);
return;
}
comboPickerSelectedIds.value = [...comboPickerSelectedIds.value, productId];
}
function submitComboPickerSelection() {
const groupIndex = comboPickerTargetGroupIndex.value;
const group = editorForm.comboGroups[groupIndex];
if (!group) return;
const selectedIds = comboPickerSelectedIds.value;
if (selectedIds.length === 0) {
message.warning('请至少选择一个商品');
return;
}
const sourceLookup = new Map(
comboPickerProducts.value.map((item) => [item.id, item]),
);
group.items = selectedIds.map((id, index) => {
const existing = group.items.find((item) => item.productId === id);
const source = sourceLookup.get(id);
return {
productId: id,
productName: existing?.productName ?? source?.name ?? id,
quantity: Math.max(1, Math.floor(Number(existing?.quantity ?? 1))),
sortOrder: index + 1,
};
});
normalizeComboGroups();
isComboPickerOpen.value = false;
comboPickerTargetGroupIndex.value = -1;
} }
function setEditorPrice(value: number) { function setEditorPrice(value: number) {
@@ -409,6 +657,9 @@ export function useProductListPage() {
categoryOptions, categoryOptions,
categorySidebarItems, categorySidebarItems,
clearSelection, clearSelection,
comboPickerKeyword,
comboPickerProducts,
comboPickerSelectedIds,
currentPageIds, currentPageIds,
deleteProduct: handleDeleteProduct, deleteProduct: handleDeleteProduct,
editorDrawerMode, editorDrawerMode,
@@ -425,7 +676,10 @@ export function useProductListPage() {
isAllCurrentPageChecked, isAllCurrentPageChecked,
isCategoryLoading, isCategoryLoading,
isCurrentPageIndeterminate, isCurrentPageIndeterminate,
isComboPickerLoading,
isComboPickerOpen,
isEditorDrawerOpen, isEditorDrawerOpen,
isEditorImageUploading,
isEditorSubmitting, isEditorSubmitting,
isListLoading, isListLoading,
isQuickEditDrawerOpen, isQuickEditDrawerOpen,
@@ -451,6 +705,19 @@ export function useProductListPage() {
setEditorDescription, setEditorDescription,
setEditorDrawerOpen, setEditorDrawerOpen,
setEditorKind, setEditorKind,
addEditorComboGroup,
removeEditorComboGroup,
setEditorComboGroupName,
setEditorComboGroupMinSelect,
setEditorComboGroupMaxSelect,
removeEditorComboItem,
setEditorComboItemQuantity,
openComboGroupPicker,
setComboPickerOpen,
setComboPickerKeyword,
toggleComboPickerProduct,
submitComboPickerSelection,
loadComboPickerProducts,
setEditorName, setEditorName,
setEditorOriginalPrice, setEditorOriginalPrice,
setEditorPrice, setEditorPrice,
@@ -459,6 +726,9 @@ export function useProductListPage() {
setEditorSubtitle, setEditorSubtitle,
setEditorTagsText, setEditorTagsText,
setEditorTimedOnShelfAt, setEditorTimedOnShelfAt,
removeEditorImage,
setEditorPrimaryImage,
uploadEditorImage,
setFilterKind, setFilterKind,
setFilterKeyword, setFilterKeyword,
setFilterStatus, setFilterStatus,

View File

@@ -28,6 +28,9 @@ const {
categoryOptions, categoryOptions,
categorySidebarItems, categorySidebarItems,
clearSelection, clearSelection,
comboPickerKeyword,
comboPickerProducts,
comboPickerSelectedIds,
deleteProduct, deleteProduct,
editorDrawerMode, editorDrawerMode,
editorDrawerTitle, editorDrawerTitle,
@@ -41,6 +44,8 @@ const {
handleToggleSelectAll, handleToggleSelectAll,
isAllCurrentPageChecked, isAllCurrentPageChecked,
isCategoryLoading, isCategoryLoading,
isComboPickerLoading,
isComboPickerOpen,
isCurrentPageIndeterminate, isCurrentPageIndeterminate,
isEditorDrawerOpen, isEditorDrawerOpen,
isEditorSubmitting, isEditorSubmitting,
@@ -62,7 +67,18 @@ const {
selectedCount, selectedCount,
selectedProductIds, selectedProductIds,
selectedStoreId, selectedStoreId,
addEditorComboGroup,
loadComboPickerProducts,
openComboGroupPicker,
removeEditorComboGroup,
removeEditorComboItem,
setComboPickerKeyword,
setComboPickerOpen,
setEditorCategoryId, setEditorCategoryId,
setEditorComboGroupMaxSelect,
setEditorComboGroupMinSelect,
setEditorComboGroupName,
setEditorComboItemQuantity,
setEditorDescription, setEditorDescription,
setEditorDrawerOpen, setEditorDrawerOpen,
setEditorKind, setEditorKind,
@@ -72,7 +88,6 @@ const {
setEditorShelfMode, setEditorShelfMode,
setEditorStock, setEditorStock,
setEditorSubtitle, setEditorSubtitle,
setEditorTagsText,
setEditorTimedOnShelfAt, setEditorTimedOnShelfAt,
setFilterKind, setFilterKind,
setFilterKeyword, setFilterKeyword,
@@ -91,6 +106,7 @@ const {
setSoldoutRecoverAt, setSoldoutRecoverAt,
setSoldoutRemainStock, setSoldoutRemainStock,
setSoldoutSyncToPlatform, setSoldoutSyncToPlatform,
submitComboPickerSelection,
setViewMode, setViewMode,
soldoutForm, soldoutForm,
soldoutSummary, soldoutSummary,
@@ -98,6 +114,7 @@ const {
submitEditor, submitEditor,
submitQuickEdit, submitQuickEdit,
submitSoldout, submitSoldout,
toggleComboPickerProduct,
total, total,
viewMode, viewMode,
} = useProductListPage(); } = useProductListPage();
@@ -204,12 +221,29 @@ function onBatchAction(action: ProductBatchAction) {
:on-set-category-id="setEditorCategoryId" :on-set-category-id="setEditorCategoryId"
:on-set-description="setEditorDescription" :on-set-description="setEditorDescription"
:on-set-kind="setEditorKind" :on-set-kind="setEditorKind"
:on-add-combo-group="addEditorComboGroup"
:on-remove-combo-group="removeEditorComboGroup"
:on-set-combo-group-name="setEditorComboGroupName"
:on-set-combo-group-min-select="setEditorComboGroupMinSelect"
:on-set-combo-group-max-select="setEditorComboGroupMaxSelect"
:on-remove-combo-item="removeEditorComboItem"
:on-set-combo-item-quantity="setEditorComboItemQuantity"
:on-open-combo-group-picker="openComboGroupPicker"
:on-set-price="setEditorPrice" :on-set-price="setEditorPrice"
:on-set-original-price="setEditorOriginalPrice" :on-set-original-price="setEditorOriginalPrice"
:on-set-stock="setEditorStock" :on-set-stock="setEditorStock"
:on-set-tags-text="setEditorTagsText"
:on-set-shelf-mode="setEditorShelfMode" :on-set-shelf-mode="setEditorShelfMode"
:on-set-timed-on-shelf-at="setEditorTimedOnShelfAt" :on-set-timed-on-shelf-at="setEditorTimedOnShelfAt"
:combo-picker-open="isComboPickerOpen"
:combo-picker-loading="isComboPickerLoading"
:combo-picker-keyword="comboPickerKeyword"
:combo-picker-products="comboPickerProducts"
:combo-picker-selected-ids="comboPickerSelectedIds"
:on-set-combo-picker-open="setComboPickerOpen"
:on-set-combo-picker-keyword="setComboPickerKeyword"
:on-search-combo-picker="loadComboPickerProducts"
:on-toggle-combo-picker-product="toggleComboPickerProduct"
:on-submit-combo-picker="submitComboPickerSelection"
@update:open="setEditorDrawerOpen" @update:open="setEditorDrawerOpen"
@submit="submitEditor" @submit="submitEditor"
@detail="handleEditorDetail" @detail="handleEditorDetail"

View File

@@ -28,12 +28,26 @@
} }
.product-drawer-section-title { .product-drawer-section-title {
position: relative;
padding-left: 14px;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
} }
.product-drawer-section-title::before {
position: absolute;
top: -2px;
bottom: -2px;
left: 0;
width: 4px;
content: '';
background: linear-gradient(180deg, #4da3ff 0%, #1677ff 100%);
border-radius: 6px;
box-shadow: 0 0 0 1px rgb(22 119 255 / 10%);
}
.drawer-form-grid { .drawer-form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -90,24 +104,55 @@
} }
.product-editor-drawer { .product-editor-drawer {
.product-kind-radio-group { .drawer-field-hint {
width: 100%; margin-top: 8px;
font-size: 12px;
color: #9ca3af;
} }
.product-kind-radio-group .ant-radio-button-wrapper { .price-stock-grid {
width: 96px; grid-template-columns: repeat(3, minmax(0, 1fr));
text-align: center; }
.product-kind-pill-group {
display: flex;
gap: 8px;
}
.product-kind-pill {
min-width: 90px;
height: 34px;
padding: 0 16px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 999px;
transition:
color 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
}
.product-kind-pill.active {
color: #1677ff;
background: #eff6ff;
border-color: #1677ff;
} }
.product-shelf-radio-group { .product-shelf-radio-group {
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px; gap: 10px;
width: 100%;
} }
.shelf-radio-item { .shelf-radio-item {
padding: 8px 10px; box-sizing: border-box;
margin-inline-start: 0; width: 100%;
padding: 10px 12px;
margin-inline: 0;
background: #f8fafc; background: #f8fafc;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
@@ -116,6 +161,187 @@
.shelf-time-row { .shelf-time-row {
margin-top: 10px; margin-top: 10px;
} }
.combo-hint {
margin-bottom: 10px;
}
.combo-group-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.combo-group-card {
padding: 12px;
background: #f8f9fb;
border: 1px solid #e5e7eb;
border-radius: 10px;
}
.combo-group-head {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.combo-group-range {
display: flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
font-size: 12px;
color: #6b7280;
}
.combo-range-input {
width: 48px;
}
.combo-remove-group {
flex-shrink: 0;
padding-inline: 8px;
}
.combo-item-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.combo-item-row {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: #fff;
border: 1px solid #edf0f3;
border-radius: 8px;
}
.combo-item-empty {
padding: 10px;
font-size: 12px;
color: #9ca3af;
text-align: center;
background: #fff;
border: 1px dashed #d1d5db;
border-radius: 8px;
}
.combo-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: #1f2937;
white-space: nowrap;
}
.combo-item-actions {
display: flex;
flex-shrink: 0;
gap: 6px;
align-items: center;
}
.combo-item-multiplier {
font-size: 12px;
color: #9ca3af;
}
.combo-quantity-input {
width: 50px;
}
.combo-remove-item {
padding-inline: 8px;
color: #6b7280;
}
.combo-remove-item:hover {
color: #ef4444;
}
.combo-add-product {
padding-left: 0;
font-size: 13px;
}
.combo-add-group {
width: 100%;
height: 36px;
font-size: 13px;
color: #9ca3af;
cursor: pointer;
background: #fff;
border: 1px solid #e5e7eb;
border-style: dashed;
border-radius: 8px;
transition:
color 0.2s ease,
border-color 0.2s ease;
}
.combo-add-group:hover {
color: #1677ff;
border-color: #1677ff;
}
}
.combo-picker-search {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.combo-picker-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.combo-picker-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #f5f5f5;
}
.combo-picker-item:last-child {
border-bottom: none;
}
.combo-picker-item .name {
overflow: hidden;
text-overflow: ellipsis;
color: #1f2937;
white-space: nowrap;
}
.combo-picker-item .spu {
font-size: 12px;
color: #9ca3af;
}
.combo-picker-item .price {
font-size: 12px;
color: #1677ff;
}
.combo-picker-empty {
padding: 24px 0;
font-size: 12px;
color: #9ca3af;
text-align: center;
} }
.product-quick-edit-drawer, .product-quick-edit-drawer,

View File

@@ -33,11 +33,30 @@ export interface ProductCategorySidebarItem {
/** 商品编辑抽屉模式。 */ /** 商品编辑抽屉模式。 */
export type ProductEditorDrawerMode = 'create' | 'edit'; export type ProductEditorDrawerMode = 'create' | 'edit';
/** 套餐分组商品编辑态。 */
export interface ProductEditorComboItemState {
productId: string;
productName: string;
quantity: number;
sortOrder: number;
}
/** 套餐分组编辑态。 */
export interface ProductEditorComboGroupState {
items: ProductEditorComboItemState[];
maxSelect: number;
minSelect: number;
name: string;
sortOrder: number;
}
/** 商品编辑表单。 */ /** 商品编辑表单。 */
export interface ProductEditorFormState { export interface ProductEditorFormState {
categoryId: string; categoryId: string;
comboGroups: ProductEditorComboGroupState[];
description: string; description: string;
id: string; id: string;
imageUrls: string[];
kind: ProductKind; kind: ProductKind;
name: string; name: string;
originalPrice: null | number; originalPrice: null | number;