feat(project): restore product list drawer and detail prototype
This commit is contained in:
@@ -140,15 +140,60 @@ export interface ProductListItemDto {
|
||||
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 {
|
||||
addonGroupIds: string[];
|
||||
comboGroups: ProductComboGroupDto[];
|
||||
description: string;
|
||||
imageUrls?: string[];
|
||||
imageUrls: string[];
|
||||
labelIds: string[];
|
||||
notifyManager: boolean;
|
||||
packingFee: null | number;
|
||||
recoverAt: null | string;
|
||||
remainStock: number;
|
||||
skus: ProductSkuDto[];
|
||||
soldoutReason: string;
|
||||
sortWeight: number;
|
||||
specTemplateIds: string[];
|
||||
syncToPlatform: boolean;
|
||||
timedOnShelfAt: null | string;
|
||||
warningStock: null | number;
|
||||
}
|
||||
|
||||
/** 商品列表查询参数。 */
|
||||
@@ -170,22 +215,51 @@ export interface ProductDetailQuery {
|
||||
|
||||
/** 保存商品参数。 */
|
||||
export interface SaveProductDto {
|
||||
addonGroupIds?: string[];
|
||||
categoryId: string;
|
||||
comboGroups?: Array<{
|
||||
items: Array<{
|
||||
productId: string;
|
||||
quantity: number;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
maxSelect: number;
|
||||
minSelect: number;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
description: string;
|
||||
id?: string;
|
||||
imageUrls?: string[];
|
||||
labelIds?: string[];
|
||||
kind: ProductKind;
|
||||
name: string;
|
||||
originalPrice: null | number;
|
||||
packingFee?: null | 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';
|
||||
sortWeight?: number;
|
||||
specTemplateIds?: string[];
|
||||
spuCode?: string;
|
||||
status: ProductStatus;
|
||||
stock: number;
|
||||
storeId: string;
|
||||
subtitle: string;
|
||||
tags: string[];
|
||||
tags?: string[];
|
||||
timedOnShelfAt?: string;
|
||||
warningStock?: null | number;
|
||||
}
|
||||
|
||||
/** 删除商品参数。 */
|
||||
|
||||
@@ -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 { useRoute, useRouter } from 'vue-router';
|
||||
@@ -10,14 +25,14 @@ import { message } from 'ant-design-vue';
|
||||
import { uploadTenantFileApi } from '#/api/files';
|
||||
import {
|
||||
deleteProductApi,
|
||||
getProductAddonGroupListApi,
|
||||
getProductCategoryListApi,
|
||||
getProductDetailApi,
|
||||
getProductLabelListApi,
|
||||
getProductSpecListApi,
|
||||
saveProductApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
import {
|
||||
tagsToText,
|
||||
textToTags,
|
||||
} from '#/views/product/list/composables/product-list-page/helpers';
|
||||
|
||||
const DEFAULT_FORM: ProductDetailFormState = {
|
||||
id: '',
|
||||
@@ -26,16 +41,28 @@ const DEFAULT_FORM: ProductDetailFormState = {
|
||||
categoryId: '',
|
||||
kind: 'single',
|
||||
description: '',
|
||||
sortWeight: 0,
|
||||
imageUrls: [],
|
||||
price: 0,
|
||||
originalPrice: null,
|
||||
stock: 0,
|
||||
warningStock: null,
|
||||
packingFee: null,
|
||||
specTemplateIds: [],
|
||||
addonGroupIds: [],
|
||||
labelIds: [],
|
||||
skus: [],
|
||||
comboGroups: [],
|
||||
status: 'off_shelf',
|
||||
tagsText: '',
|
||||
imageUrls: [],
|
||||
shelfMode: 'draft',
|
||||
timedOnShelfAt: '',
|
||||
};
|
||||
|
||||
interface CategoryOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useProductDetailPage() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -44,7 +71,24 @@ export function useProductDetailPage() {
|
||||
const isSubmitting = ref(false);
|
||||
const isUploadingImage = ref(false);
|
||||
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 storeId = computed(() => String(route.query.storeId || ''));
|
||||
@@ -62,7 +106,30 @@ export function useProductDetailPage() {
|
||||
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) {
|
||||
form.id = data.id;
|
||||
form.name = data.name;
|
||||
@@ -70,54 +137,222 @@ export function useProductDetailPage() {
|
||||
form.categoryId = data.categoryId;
|
||||
form.kind = data.kind;
|
||||
form.description = data.description;
|
||||
form.price = data.price;
|
||||
form.originalPrice = data.originalPrice;
|
||||
form.stock = data.stock;
|
||||
form.sortWeight = Math.max(0, Number(data.sortWeight || 0));
|
||||
|
||||
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.tagsText = tagsToText(data.tags || []);
|
||||
form.imageUrls = [...(data.imageUrls || []), data.imageUrl]
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, source) => source.indexOf(item) === index)
|
||||
.slice(0, 5);
|
||||
form.shelfMode = data.status === 'on_sale' ? 'now' : 'draft';
|
||||
form.timedOnShelfAt = '';
|
||||
if (data.status === 'on_sale') {
|
||||
form.shelfMode = 'now';
|
||||
} else if (data.timedOnShelfAt) {
|
||||
form.shelfMode = 'scheduled';
|
||||
} else {
|
||||
form.shelfMode = 'draft';
|
||||
}
|
||||
form.timedOnShelfAt = data.timedOnShelfAt || '';
|
||||
}
|
||||
|
||||
/** 解析门店+商品并拉取详情。 */
|
||||
async function loadDetail() {
|
||||
if (!storeId.value || !productId.value) {
|
||||
detail.value = null;
|
||||
categoryOptions.value = [];
|
||||
function getTemplateName(templateId: string) {
|
||||
return (
|
||||
specTemplateOptions.value.find((item) => item.id === templateId)?.name ||
|
||||
templateId
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const [detailData, categories] = await Promise.all([
|
||||
getProductDetailApi({
|
||||
storeId: storeId.value,
|
||||
productId: productId.value,
|
||||
}),
|
||||
getProductCategoryListApi(storeId.value),
|
||||
]);
|
||||
|
||||
detail.value = detailData;
|
||||
categoryOptions.value = categories.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
patchForm(detailData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
detail.value = null;
|
||||
categoryOptions.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
form.skus = combos.map((attrs, index) => {
|
||||
const key = buildSkuKey(attrs);
|
||||
const cached = previousMap.get(key);
|
||||
const skuIndex = index + 1;
|
||||
return {
|
||||
id: cached?.id || '',
|
||||
skuCode: cached?.skuCode || buildLocalSkuCode(skuIndex),
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSpecTemplate(templateId: string) {
|
||||
const selected = new Set(form.specTemplateIds);
|
||||
if (selected.has(templateId)) {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 上传图片并追加到图集。 */
|
||||
async function uploadImage(file: File) {
|
||||
isUploadingImage.value = true;
|
||||
try {
|
||||
@@ -127,9 +362,7 @@ export function useProductDetailPage() {
|
||||
message.error('图片上传失败');
|
||||
return;
|
||||
}
|
||||
form.imageUrls = [...form.imageUrls, url]
|
||||
.filter((item, index, source) => source.indexOf(item) === index)
|
||||
.slice(0, 5);
|
||||
form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5);
|
||||
message.success('图片上传成功');
|
||||
} catch (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) {
|
||||
if (index <= 0 || index >= form.imageUrls.length) return;
|
||||
const current = [...form.imageUrls];
|
||||
const [selected] = current.splice(index, 1);
|
||||
if (!selected) return;
|
||||
form.imageUrls = [selected, ...current];
|
||||
const next = [...form.imageUrls];
|
||||
const [picked] = next.splice(index, 1);
|
||||
if (!picked) return;
|
||||
form.imageUrls = [picked, ...next];
|
||||
}
|
||||
|
||||
/** 删除图片。 */
|
||||
function removeImage(index: number) {
|
||||
form.imageUrls = form.imageUrls.filter((_, i) => i !== index);
|
||||
function addComboGroup() {
|
||||
form.comboGroups.push({
|
||||
name: '',
|
||||
minSelect: 1,
|
||||
maxSelect: 1,
|
||||
sortOrder: form.comboGroups.length + 1,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存详情。 */
|
||||
async function saveDetail() {
|
||||
function removeComboGroup(groupIndex: number) {
|
||||
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 (!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()) {
|
||||
message.warning('请输入商品名称');
|
||||
return;
|
||||
@@ -169,6 +591,35 @@ export function useProductDetailPage() {
|
||||
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;
|
||||
try {
|
||||
const saved = await saveProductApi({
|
||||
@@ -179,21 +630,85 @@ export function useProductDetailPage() {
|
||||
name: form.name.trim(),
|
||||
subtitle: form.subtitle.trim(),
|
||||
description: form.description.trim(),
|
||||
price: Number(form.price || 0),
|
||||
price: Number(Number(form.price || 0).toFixed(2)),
|
||||
originalPrice:
|
||||
form.originalPrice && Number(form.originalPrice) > 0
|
||||
? Number(form.originalPrice)
|
||||
form.originalPrice !== null &&
|
||||
form.originalPrice !== undefined &&
|
||||
Number(form.originalPrice) > 0
|
||||
? Number(Number(form.originalPrice).toFixed(2))
|
||||
: null,
|
||||
stock: Math.max(0, Math.floor(Number(form.stock || 0))),
|
||||
tags: textToTags(form.tagsText),
|
||||
status: form.status,
|
||||
shelfMode: form.shelfMode,
|
||||
timedOnShelfAt:
|
||||
form.shelfMode === 'scheduled' ? form.timedOnShelfAt : undefined,
|
||||
form.shelfMode === 'scheduled' && form.timedOnShelfAt
|
||||
? form.timedOnShelfAt
|
||||
: undefined,
|
||||
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;
|
||||
patchForm(saved);
|
||||
buildSkuRows();
|
||||
message.success('商品详情已保存');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -202,7 +717,6 @@ export function useProductDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换在售/下架。 */
|
||||
async function toggleSaleStatus(next: ProductStatus) {
|
||||
if (next !== 'on_sale' && next !== 'off_shelf') return;
|
||||
form.status = next;
|
||||
@@ -210,7 +724,11 @@ export function useProductDetailPage() {
|
||||
await saveDetail();
|
||||
}
|
||||
|
||||
/** 删除当前商品并返回列表。 */
|
||||
function setShelfMode(mode: 'draft' | 'now' | 'scheduled') {
|
||||
form.shelfMode = mode;
|
||||
form.status = mode === 'now' ? 'on_sale' : 'off_shelf';
|
||||
}
|
||||
|
||||
async function deleteCurrentProduct() {
|
||||
if (!storeId.value || !form.id) return;
|
||||
await deleteProductApi({
|
||||
@@ -221,30 +739,177 @@ export function useProductDetailPage() {
|
||||
router.push('/product/list');
|
||||
}
|
||||
|
||||
/** 返回列表页。 */
|
||||
function goBack() {
|
||||
router.push('/product/list');
|
||||
}
|
||||
|
||||
watch([storeId, productId], loadDetail, { immediate: true });
|
||||
watch(
|
||||
[storeId, productId],
|
||||
() => {
|
||||
void loadDetail();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
addonGroupOptions,
|
||||
addComboGroup,
|
||||
applySkuBatchPrice,
|
||||
applySkuBatchStock,
|
||||
categoryOptions,
|
||||
comboPickerCurrentGroupIndex,
|
||||
comboPickerKeyword,
|
||||
comboPickerLoading,
|
||||
comboPickerOpen,
|
||||
comboPickerProducts,
|
||||
comboPickerSelectedIds,
|
||||
comboPickerSelectedProducts,
|
||||
deleteCurrentProduct,
|
||||
detail,
|
||||
form,
|
||||
getOptionName,
|
||||
getSkuAttrOptionId,
|
||||
getTemplateName,
|
||||
goBack,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
isUploadingImage,
|
||||
labelOptions,
|
||||
loadDetail,
|
||||
openComboPicker,
|
||||
removeComboGroup,
|
||||
removeComboItem,
|
||||
removeImage,
|
||||
saveDetail,
|
||||
searchComboPicker,
|
||||
setComboGroupMaxSelect,
|
||||
setComboGroupMinSelect,
|
||||
setComboGroupName,
|
||||
setComboItemQuantity,
|
||||
setComboPickerOpen,
|
||||
setPrimaryImage,
|
||||
setShelfMode,
|
||||
setSkuEnabled,
|
||||
setSkuOriginalPrice,
|
||||
setSkuPrice,
|
||||
setSkuStock,
|
||||
skuBatch,
|
||||
skuTemplateColumns,
|
||||
specTemplateOptions,
|
||||
statusColor,
|
||||
statusText,
|
||||
storeId,
|
||||
submitComboPicker,
|
||||
toggleAddonGroup,
|
||||
toggleComboPickerProduct,
|
||||
toggleLabel,
|
||||
toggleSaleStatus,
|
||||
toggleSpecTemplate,
|
||||
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
@@ -1,9 +1,12 @@
|
||||
.page-product-detail {
|
||||
font-size: 13px;
|
||||
|
||||
.pd-header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pd-back-btn {
|
||||
@@ -22,12 +25,19 @@
|
||||
text-overflow: ellipsis;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
color: #1a1a2e;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pd-head-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.pd-head-spu {
|
||||
margin-top: 2px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
@@ -36,9 +46,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
padding: 2px 10px;
|
||||
min-width: 56px;
|
||||
padding: 2px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -49,7 +60,7 @@
|
||||
|
||||
.pd-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -63,7 +74,7 @@
|
||||
|
||||
.pd-nav-item {
|
||||
display: block;
|
||||
padding: 8px 14px;
|
||||
padding: 9px 18px;
|
||||
margin: 2px 0;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
@@ -74,7 +85,7 @@
|
||||
|
||||
.pd-nav-item:hover {
|
||||
color: #1677ff;
|
||||
background: #f7faff;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.pd-nav-item.active {
|
||||
@@ -88,85 +99,503 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pd-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
.pd-section-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pd-three-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
.pd-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pd-image-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
.pd-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pd-image-item {
|
||||
.pd-thumb {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pd-image-item.primary {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 1px rgb(22 119 255 / 25%);
|
||||
}
|
||||
|
||||
.pd-image-item img {
|
||||
.pd-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
height: 100%;
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pd-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
height: 30px;
|
||||
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 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
.pd-pill:hover {
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.pd-sku-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) 120px 120px;
|
||||
.pd-pill.checked {
|
||||
font-weight: 600;
|
||||
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;
|
||||
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;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.pd-sku-price,
|
||||
.pd-sku-stock {
|
||||
width: 100%;
|
||||
.pd-combo-item .actions {
|
||||
display: inline-flex;
|
||||
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 {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 12;
|
||||
z-index: 11;
|
||||
padding: 12px 20px;
|
||||
background: #fafbfc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -175,6 +604,61 @@
|
||||
gap: 10px;
|
||||
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) {
|
||||
@@ -188,9 +672,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pd-two-col,
|
||||
.pd-three-col {
|
||||
grid-template-columns: 1fr;
|
||||
.pd-label {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.pd-combo-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
import type { ProductKind, ProductStatus } from '#/api/product';
|
||||
|
||||
export interface ProductDetailFormState {
|
||||
categoryId: string;
|
||||
description: string;
|
||||
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;
|
||||
}
|
||||
import type {
|
||||
ProductKind,
|
||||
ProductStatus,
|
||||
ProductSwitchStatus,
|
||||
} from '#/api/product';
|
||||
|
||||
export interface ProductDetailSectionItem {
|
||||
id: 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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import type { ProductEditorFormState } from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:商品添加/编辑抽屉。
|
||||
* 1. 展示商品核心信息与上架方式表单。
|
||||
* 1. 展示商品基础字段、套餐分组与上架方式。
|
||||
* 2. 通过回调更新父级状态并触发提交。
|
||||
*/
|
||||
import type { ProductEditorFormState } from '../types';
|
||||
import type { ProductPickerItemDto } from '#/api/product';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
Drawer,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
} from 'ant-design-vue';
|
||||
@@ -26,9 +29,29 @@ interface CategoryOption {
|
||||
|
||||
interface Props {
|
||||
categoryOptions: CategoryOption[];
|
||||
comboPickerKeyword: string;
|
||||
comboPickerLoading: boolean;
|
||||
comboPickerOpen: boolean;
|
||||
comboPickerProducts: ProductPickerItemDto[];
|
||||
comboPickerSelectedIds: string[];
|
||||
form: ProductEditorFormState;
|
||||
isSaving: boolean;
|
||||
onAddComboGroup: () => void;
|
||||
onOpenComboGroupPicker: (groupIndex: number) => void;
|
||||
onRemoveComboGroup: (groupIndex: number) => void;
|
||||
onRemoveComboItem: (groupIndex: number, itemIndex: number) => void;
|
||||
onSearchComboPicker: () => 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;
|
||||
onSetKind: (value: 'combo' | 'single') => void;
|
||||
onSetName: (value: string) => void;
|
||||
@@ -37,8 +60,9 @@ interface Props {
|
||||
onSetShelfMode: (value: 'draft' | 'now' | 'scheduled') => void;
|
||||
onSetStock: (value: number) => void;
|
||||
onSetSubtitle: (value: string) => void;
|
||||
onSetTagsText: (value: string) => void;
|
||||
onSetTimedOnShelfAt: (value: string) => void;
|
||||
onSubmitComboPicker: () => void;
|
||||
onToggleComboPickerProduct: (productId: string) => void;
|
||||
open: boolean;
|
||||
showDetailLink: boolean;
|
||||
submitText: string;
|
||||
@@ -59,13 +83,6 @@ function toNumber(value: null | number | string, fallback = 0) {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 解析商品类型。 */
|
||||
function handleKindChange(value: unknown) {
|
||||
if (value === 'combo' || value === 'single') {
|
||||
props.onSetKind(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析分类选择。 */
|
||||
function handleCategoryChange(value: unknown) {
|
||||
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'));
|
||||
}
|
||||
|
||||
function setKind(value: 'combo' | 'single') {
|
||||
props.onSetKind(value);
|
||||
}
|
||||
|
||||
function setComboPickerOpen(value: boolean) {
|
||||
props.onSetComboPickerOpen(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -95,24 +120,37 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
class="product-editor-drawer"
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="560"
|
||||
width="40%"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品类型</div>
|
||||
<Radio.Group
|
||||
:value="props.form.kind"
|
||||
class="product-kind-radio-group"
|
||||
@update:value="(value) => handleKindChange(value)"
|
||||
>
|
||||
<Radio.Button value="single">单品</Radio.Button>
|
||||
<Radio.Button value="combo">套餐</Radio.Button>
|
||||
</Radio.Group>
|
||||
<div class="product-kind-pill-group">
|
||||
<button
|
||||
type="button"
|
||||
class="product-kind-pill"
|
||||
:class="{ active: props.form.kind === 'single' }"
|
||||
@click="setKind('single')"
|
||||
>
|
||||
单品
|
||||
</button>
|
||||
<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 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-item full">
|
||||
<label class="drawer-form-label required">商品名称</label>
|
||||
@@ -123,25 +161,26 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<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">
|
||||
<div class="drawer-form-item full">
|
||||
<label class="drawer-form-label">副标题</label>
|
||||
<Input
|
||||
:value="props.form.subtitle"
|
||||
placeholder="选填"
|
||||
placeholder="一句话描述卖点(选填)"
|
||||
@update:value="(value) => props.onSetSubtitle(String(value || ''))"
|
||||
/>
|
||||
</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">
|
||||
<label class="drawer-form-label">商品简介</label>
|
||||
<Input.TextArea
|
||||
@@ -159,7 +198,7 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<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">
|
||||
<label class="drawer-form-label required">售价</label>
|
||||
<InputNumber
|
||||
@@ -176,7 +215,7 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">原价</label>
|
||||
<label class="drawer-form-label">划线价</label>
|
||||
<InputNumber
|
||||
:value="props.form.originalPrice ?? undefined"
|
||||
class="full-width"
|
||||
@@ -210,15 +249,129 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</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 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>
|
||||
|
||||
@@ -271,4 +424,55 @@ function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
@@ -83,6 +83,8 @@ export const DEFAULT_EDITOR_FORM: ProductEditorFormState = {
|
||||
name: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
comboGroups: [],
|
||||
imageUrls: [],
|
||||
categoryId: '',
|
||||
kind: 'single',
|
||||
price: 0,
|
||||
|
||||
@@ -58,6 +58,8 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
options.editorForm.name = '';
|
||||
options.editorForm.subtitle = '';
|
||||
options.editorForm.description = '';
|
||||
options.editorForm.comboGroups = [];
|
||||
options.editorForm.imageUrls = [];
|
||||
options.editorForm.categoryId = defaultCategoryId;
|
||||
options.editorForm.kind = 'single';
|
||||
options.editorForm.price = 0;
|
||||
@@ -107,6 +109,37 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
message.warning('请选择定时上架时间');
|
||||
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;
|
||||
try {
|
||||
@@ -141,14 +174,19 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
const current = options.currentQuickEditProduct.value;
|
||||
options.isQuickEditSubmitting.value = true;
|
||||
try {
|
||||
const detail = await getProductDetailApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
productId: current.id,
|
||||
});
|
||||
|
||||
await saveProductApi({
|
||||
id: current.id,
|
||||
storeId: options.selectedStoreId.value,
|
||||
categoryId: current.categoryId,
|
||||
kind: current.kind,
|
||||
name: current.name,
|
||||
subtitle: current.subtitle,
|
||||
description: current.subtitle,
|
||||
categoryId: detail.categoryId,
|
||||
kind: detail.kind,
|
||||
name: detail.name,
|
||||
subtitle: detail.subtitle,
|
||||
description: detail.description,
|
||||
price: Number(options.quickEditForm.price || 0),
|
||||
originalPrice:
|
||||
Number(options.quickEditForm.originalPrice || 0) > 0
|
||||
@@ -158,10 +196,22 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) {
|
||||
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',
|
||||
shelfMode: options.quickEditForm.isOnSale ? 'now' : 'draft',
|
||||
spuCode: current.spuCode,
|
||||
spuCode: detail.spuCode,
|
||||
});
|
||||
message.success('商品已更新');
|
||||
options.isQuickEditDrawerOpen.value = false;
|
||||
|
||||
@@ -49,6 +49,19 @@ export function cloneEditorForm(
|
||||
name: source.name,
|
||||
subtitle: source.subtitle,
|
||||
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,
|
||||
kind: source.kind,
|
||||
price: source.price,
|
||||
@@ -143,11 +156,14 @@ export function formatDateTime(value: Date | dayjs.Dayjs | string) {
|
||||
export function mapListItemToEditorForm(
|
||||
item: ProductListItemDto,
|
||||
): ProductEditorFormState {
|
||||
const imageUrls = item.imageUrl ? [item.imageUrl] : [];
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
subtitle: item.subtitle,
|
||||
description: item.subtitle,
|
||||
comboGroups: [],
|
||||
imageUrls,
|
||||
categoryId: item.categoryId,
|
||||
kind: item.kind,
|
||||
price: item.price,
|
||||
@@ -164,11 +180,30 @@ export function mapListItemToEditorForm(
|
||||
export function mapDetailToEditorForm(
|
||||
detail: ProductDetailDto,
|
||||
): 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 {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
subtitle: detail.subtitle,
|
||||
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,
|
||||
kind: detail.kind,
|
||||
price: detail.price,
|
||||
@@ -237,6 +272,31 @@ export function toSavePayload(
|
||||
: null,
|
||||
stock: Math.max(0, Math.floor(Number(form.stock || 0))),
|
||||
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,
|
||||
shelfMode: form.shelfMode,
|
||||
timedOnShelfAt:
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type {
|
||||
ProductCategoryDto,
|
||||
ProductListItemDto,
|
||||
ProductPickerItemDto,
|
||||
ProductStatus,
|
||||
} from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
@@ -18,6 +19,11 @@ import type {
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
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 {
|
||||
DEFAULT_EDITOR_FORM,
|
||||
@@ -49,6 +55,7 @@ export function useProductListPage() {
|
||||
const isCategoryLoading = ref(false);
|
||||
const isListLoading = ref(false);
|
||||
const isEditorSubmitting = ref(false);
|
||||
const isEditorImageUploading = ref(false);
|
||||
const isQuickEditSubmitting = ref(false);
|
||||
const isSoldoutSubmitting = ref(false);
|
||||
|
||||
@@ -78,6 +85,13 @@ export function useProductListPage() {
|
||||
const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM));
|
||||
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. 衍生状态。
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({ label: item.name, value: item.id })),
|
||||
@@ -243,8 +257,242 @@ export function useProductListPage() {
|
||||
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') {
|
||||
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;
|
||||
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) {
|
||||
@@ -409,6 +657,9 @@ export function useProductListPage() {
|
||||
categoryOptions,
|
||||
categorySidebarItems,
|
||||
clearSelection,
|
||||
comboPickerKeyword,
|
||||
comboPickerProducts,
|
||||
comboPickerSelectedIds,
|
||||
currentPageIds,
|
||||
deleteProduct: handleDeleteProduct,
|
||||
editorDrawerMode,
|
||||
@@ -425,7 +676,10 @@ export function useProductListPage() {
|
||||
isAllCurrentPageChecked,
|
||||
isCategoryLoading,
|
||||
isCurrentPageIndeterminate,
|
||||
isComboPickerLoading,
|
||||
isComboPickerOpen,
|
||||
isEditorDrawerOpen,
|
||||
isEditorImageUploading,
|
||||
isEditorSubmitting,
|
||||
isListLoading,
|
||||
isQuickEditDrawerOpen,
|
||||
@@ -451,6 +705,19 @@ export function useProductListPage() {
|
||||
setEditorDescription,
|
||||
setEditorDrawerOpen,
|
||||
setEditorKind,
|
||||
addEditorComboGroup,
|
||||
removeEditorComboGroup,
|
||||
setEditorComboGroupName,
|
||||
setEditorComboGroupMinSelect,
|
||||
setEditorComboGroupMaxSelect,
|
||||
removeEditorComboItem,
|
||||
setEditorComboItemQuantity,
|
||||
openComboGroupPicker,
|
||||
setComboPickerOpen,
|
||||
setComboPickerKeyword,
|
||||
toggleComboPickerProduct,
|
||||
submitComboPickerSelection,
|
||||
loadComboPickerProducts,
|
||||
setEditorName,
|
||||
setEditorOriginalPrice,
|
||||
setEditorPrice,
|
||||
@@ -459,6 +726,9 @@ export function useProductListPage() {
|
||||
setEditorSubtitle,
|
||||
setEditorTagsText,
|
||||
setEditorTimedOnShelfAt,
|
||||
removeEditorImage,
|
||||
setEditorPrimaryImage,
|
||||
uploadEditorImage,
|
||||
setFilterKind,
|
||||
setFilterKeyword,
|
||||
setFilterStatus,
|
||||
|
||||
@@ -28,6 +28,9 @@ const {
|
||||
categoryOptions,
|
||||
categorySidebarItems,
|
||||
clearSelection,
|
||||
comboPickerKeyword,
|
||||
comboPickerProducts,
|
||||
comboPickerSelectedIds,
|
||||
deleteProduct,
|
||||
editorDrawerMode,
|
||||
editorDrawerTitle,
|
||||
@@ -41,6 +44,8 @@ const {
|
||||
handleToggleSelectAll,
|
||||
isAllCurrentPageChecked,
|
||||
isCategoryLoading,
|
||||
isComboPickerLoading,
|
||||
isComboPickerOpen,
|
||||
isCurrentPageIndeterminate,
|
||||
isEditorDrawerOpen,
|
||||
isEditorSubmitting,
|
||||
@@ -62,7 +67,18 @@ const {
|
||||
selectedCount,
|
||||
selectedProductIds,
|
||||
selectedStoreId,
|
||||
addEditorComboGroup,
|
||||
loadComboPickerProducts,
|
||||
openComboGroupPicker,
|
||||
removeEditorComboGroup,
|
||||
removeEditorComboItem,
|
||||
setComboPickerKeyword,
|
||||
setComboPickerOpen,
|
||||
setEditorCategoryId,
|
||||
setEditorComboGroupMaxSelect,
|
||||
setEditorComboGroupMinSelect,
|
||||
setEditorComboGroupName,
|
||||
setEditorComboItemQuantity,
|
||||
setEditorDescription,
|
||||
setEditorDrawerOpen,
|
||||
setEditorKind,
|
||||
@@ -72,7 +88,6 @@ const {
|
||||
setEditorShelfMode,
|
||||
setEditorStock,
|
||||
setEditorSubtitle,
|
||||
setEditorTagsText,
|
||||
setEditorTimedOnShelfAt,
|
||||
setFilterKind,
|
||||
setFilterKeyword,
|
||||
@@ -91,6 +106,7 @@ const {
|
||||
setSoldoutRecoverAt,
|
||||
setSoldoutRemainStock,
|
||||
setSoldoutSyncToPlatform,
|
||||
submitComboPickerSelection,
|
||||
setViewMode,
|
||||
soldoutForm,
|
||||
soldoutSummary,
|
||||
@@ -98,6 +114,7 @@ const {
|
||||
submitEditor,
|
||||
submitQuickEdit,
|
||||
submitSoldout,
|
||||
toggleComboPickerProduct,
|
||||
total,
|
||||
viewMode,
|
||||
} = useProductListPage();
|
||||
@@ -204,12 +221,29 @@ function onBatchAction(action: ProductBatchAction) {
|
||||
:on-set-category-id="setEditorCategoryId"
|
||||
:on-set-description="setEditorDescription"
|
||||
: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-original-price="setEditorOriginalPrice"
|
||||
:on-set-stock="setEditorStock"
|
||||
:on-set-tags-text="setEditorTagsText"
|
||||
:on-set-shelf-mode="setEditorShelfMode"
|
||||
: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"
|
||||
@submit="submitEditor"
|
||||
@detail="handleEditorDetail"
|
||||
|
||||
@@ -28,12 +28,26 @@
|
||||
}
|
||||
|
||||
.product-drawer-section-title {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -90,24 +104,55 @@
|
||||
}
|
||||
|
||||
.product-editor-drawer {
|
||||
.product-kind-radio-group {
|
||||
width: 100%;
|
||||
.drawer-field-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.product-kind-radio-group .ant-radio-button-wrapper {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
.price-stock-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shelf-radio-item {
|
||||
padding: 8px 10px;
|
||||
margin-inline-start: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
margin-inline: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
@@ -116,6 +161,187 @@
|
||||
.shelf-time-row {
|
||||
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,
|
||||
|
||||
@@ -33,11 +33,30 @@ export interface ProductCategorySidebarItem {
|
||||
/** 商品编辑抽屉模式。 */
|
||||
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 {
|
||||
categoryId: string;
|
||||
comboGroups: ProductEditorComboGroupState[];
|
||||
description: string;
|
||||
id: string;
|
||||
imageUrls: string[];
|
||||
kind: ProductKind;
|
||||
name: string;
|
||||
originalPrice: null | number;
|
||||
|
||||
Reference in New Issue
Block a user