Files
gao-customer-h5/src/pages/product/ProductEdit.vue
T

1205 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="savePermissionCode" class="product-edit">
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
<t-form-item label="产品编码" required-mark help="使用英语、拼音或唯一数字">
<t-input v-model="form.code" borderless maxlength="64" placeholder="门店内唯一编码" />
</t-form-item>
<t-form-item label="产品名称" required-mark>
<t-input v-model="form.name" borderless maxlength="128" placeholder="请输入产品名称" />
</t-form-item>
<t-form-item label="产品类型" arrow content-align="right" @click="openTypePicker">
<span class="picker-text" v-text="getProductTypeText(form.productType)" />
</t-form-item>
<t-form-item label="产品分类" arrow content-align="right" @click="openCategoryPicker">
<span class="picker-text" v-text="selectedCategoryName" />
</t-form-item>
<t-form-item label="库存管理" content-align="right">
<t-switch v-model="form.stockEnabled" size="small" :disabled="form.productType === 'SERVICE'" />
</t-form-item>
<t-form-item label="状态" arrow content-align="right" @click="openStatusPicker">
<span class="picker-text" v-text="getProductStatusText(form.status)" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model="form.remark" borderless maxlength="255" autosize placeholder="可填写产品说明" />
</t-form-item>
<t-form-item label-width="0">
<media-selector
v-model="attachmentUrlList"
v-model:photo-items="attachmentItemList"
title="产品公共图片及附件"
:edit-mode="true"
:loading="isAttachmentUploading"
:show-viewer-actions="false"
:add-actions="attachmentAddActions"
@add="openAttachmentPicker('OTHER')"
@action="handleAttachmentAction"
@capture-photo="capturePhoto"
@capture-video="captureVideo"
/>
</t-form-item>
<t-form-item label="纯色封面" label-width="8rem" content-align="right">
<t-button
size="extra-small"
variant="outline"
class="color-trigger"
:disabled="isAttachmentUploading"
@click="openCoverColorPicker"
>
<div class="content">
<span
class="swatch"
:class="{ empty: !solidColorCoverColor }"
:style="{ background: solidColorCoverColor || undefined }"
/>
<span v-text="solidColorCoverColor || '选择颜色'" />
</div>
</t-button>
</t-form-item>
</t-form>
<card-container title="产品规格" description="无多规格产品无需配置规格;有规格时每条 SKU 明细必须覆盖全部规格项" v-entrance-transition="70">
<div class="spec-list">
<t-form v-for="(spec, specIndex) in form.specList" :key="spec.clientKey" class="form spec">
<t-form-item label="名称" label-width="2.5rem">
<t-input
class="name"
v-model="spec.name"
borderless
maxlength="32"
:readonly="!!spec.id"
placeholder="如 颜色、尺寸"
/>
<t-button
v-if="!spec.id"
theme="danger"
variant="text"
size="extra-small"
content="删除"
@click="removeSpec(specIndex)"
/>
</t-form-item>
<t-form-item v-if="getNamedSpecValues(spec).length" label-width="0">
<div class="tags">
<t-tag
v-for="value in getNamedSpecValues(spec)"
:key="value.clientKey"
class="tag"
variant="light-outline"
:content="value.name"
closable
@close="removeSpecValue(spec, spec.valueList.indexOf(value))"
/>
</div>
</t-form-item>
<t-form-item label="规格值" label-width="3.5rem">
<t-input
v-model.trim="specValueDraftMap[spec.clientKey]"
borderless
maxlength="32"
placeholder="输入规格值"
>
<template #suffix-icon>
<t-button
size="extra-small"
theme="light"
content="添加"
@click="addSpecValue(spec)"
/>
</template>
</t-input>
</t-form-item>
</t-form>
<t-button
class="add"
size="small"
theme="primary"
variant="dashed"
content="添加规格项"
@click="addSpec"
/>
</div>
</card-container>
<card-container
class="sku-wrap"
title="SKU 明细"
description="每条 SKU 明细都是独立的销售、库存和履约单元;生成后可移除不需要的组合,点列表项配置具体内容"
v-entrance-transition="140"
>
<div class="content-wrap">
<t-cell-group theme="card">
<t-cell title="销售单位">
<template #note>
<t-button
variant="outline"
size="extra-small"
content="统一配置销售单位"
:disabled="!form.skuList.length"
@click="openBatchUnitEditor"
/>
</template>
</t-cell>
<t-cell title="生成 SKU">
<template #note>
<t-button
theme="primary"
size="extra-small"
:content="generateSkuButtonText"
:disabled="!canGenerateSku"
@click="regenerateSkuList"
/>
</template>
</t-cell>
<t-cell title="当前 SKU" :note="`${form.skuList.length} 个`" />
</t-cell-group>
<t-cell-group v-if="form.skuList.length" theme="card" class="sku-list fix-last-cell-line">
<product-sku-editor
v-for="(sku, skuIndex) in form.skuList"
:key="sku.clientKey"
:product-type="form.productType"
v-model:sku="form.skuList[skuIndex]"
:sku-index="skuIndex"
:spec-list="form.specList"
@configure-units="openUnitEditor(sku)"
@remove="removeSku(skuIndex)"
/>
</t-cell-group>
<div v-else class="load-state" v-text="form.specList.length ? '请先添加规格值,再生成 SKU 组合' : '正在准备默认 SKU'" />
</div>
</card-container>
<t-popup v-model="typePickerVisible" placement="bottom">
<t-picker v-model="typeDraft" :columns="productTypeOptions" title="产品类型" @cancel="typePickerVisible = false" @confirm="confirmType" />
</t-popup>
<t-popup v-model="categoryPickerVisible" placement="bottom">
<t-picker v-model="categoryDraft" :columns="categoryOptions" title="产品分类" @cancel="categoryPickerVisible = false" @confirm="confirmCategory" />
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker v-model="statusDraft" :columns="statusOptions" title="产品状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
</t-popup>
<product-sku-unit-editor
v-model="unitEditorVisible"
:product-type="form.productType"
v-model:sku="editingSku"
:spec-list="form.specList"
@close="closeUnitEditor"
/>
<product-sku-unit-batch-editor
v-model="batchUnitEditorVisible"
v-model:unit-list="batchUnitList"
:product-type="form.productType"
:sku-count="form.skuList.length"
@apply="applyBatchUnitTemplate"
@close="closeBatchUnitEditor"
/>
<bottom-popup
v-model="coverColorPickerVisible"
title="颜色选择"
:right-button-text="isAttachmentUploading ? '上传中' : '确定'"
@left-button-click="cancelCoverColorPicker"
@right-button-click="confirmCoverColorPicker"
>
<t-color-picker v-if="coverColorPickerVisible" v-model="coverColorDraft" format="HEX" type="multiple" :swatch-colors="colorSwatchList" @palette-bar-change="updateCoverColorDraftFromPalette" />
</bottom-popup>
<input ref="attachmentInputRef" class="file-input" type="file" accept="*/*" @change="handleAttachmentSelected" />
<input ref="photoInputRef" class="file-input" type="file" accept="image/*" capture="environment" @change="handleAttachmentSelected" />
<input ref="videoInputRef" class="file-input" type="file" accept="video/*" capture="environment" @change="handleAttachmentSelected" />
</div>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { DialogPlugin, type ColorObject } from "tdesign-mobile-vue";
import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
import ProductSkuEditor from "@/pages/product/component/ProductSkuEditor.vue";
import ProductSkuUnitEditor from "@/pages/product/component/ProductSkuUnitEditor.vue";
import ProductSkuUnitBatchEditor from "@/pages/product/component/ProductSkuUnitBatchEditor.vue";
import type { MediaSelectorAddAction } from "@/components/mediaSelector/types";
import { useNavBarStore } from "@/store/navBarStore";
import type { PhotoItem } from "@/types/Photo";
import type { Product, ProductAttachmentType, ProductCategory, ProductSku, ProductSkuUnit, ProductSpec, ProductType } from "@/types/Product";
import type { ProductAttachmentItem, ProductForm, ProductSkuForm as SkuForm, ProductSkuUnitForm as UnitForm, ProductSpecForm as SpecForm, ProductSpecValueForm as SpecValueForm } from "@/types/ProductEditor";
import { compressUploadImage } from "@/utils/imageCompression";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { formatProductAmount, getProductStatusText, getProductTypeText, normalizeProductQuantity, parseProductAmount } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
const SOLID_COLOR_COVER_PURPOSE = "PRODUCT_SOLID_COLOR_COVER";
const DEFAULT_COVER_COLOR = "#2F88FF";
defineOptions({ name: "ProductEdit" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.product.create : permissionCodes.product.update);
const isSaving = ref(false);
const form = reactive<ProductForm>({
id: undefined,
code: "",
name: "",
productType: "GOODS",
stockEnabled: true,
status: "ACTIVE",
specList: [],
skuList: [],
remark: null
});
const categoryList = ref<ProductCategory[]>([]);
const typePickerVisible = ref(false);
const categoryPickerVisible = ref(false);
const statusPickerVisible = ref(false);
const attachmentInputRef = ref<HTMLInputElement>();
const photoInputRef = ref<HTMLInputElement>();
const videoInputRef = ref<HTMLInputElement>();
const attachmentPickerTarget = ref<ProductAttachmentType>("PHOTO");
const isAttachmentUploading = ref(false);
const coverColorPickerVisible = ref(false);
const coverColorDraft = ref(DEFAULT_COVER_COLOR);
const typeDraft = ref<PickerValue[]>([]);
const categoryDraft = ref<PickerValue[]>([]);
const statusDraft = ref<PickerValue[]>([]);
const productTypeOptions = [
{ label: "服务", value: "SERVICE" },
{ label: "实物", value: "GOODS" },
{ label: "消耗品", value: "CONSUMABLE" }
];
const categoryOptions = computed(() => [{ label: "不设置分类", value: "" }, ...categoryList.value.map(item => ({ label: item.name || "未命名分类", value: item.id || "" }))]);
const statusOptions = [{ label: "启用", value: "ACTIVE" }, { label: "停用", value: "INACTIVE" }];
const colorSwatchList = ["#2F88FF", "#0EA5E9", "#10B981", "#22C55E", "#F59E0B", "#F97316", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"];
const selectedCategoryName = computed(() => categoryList.value.find(item => item.id === form.categoryId)?.name || "不设置分类");
const attachmentAddActions = [
{ icon: "file-attachment", text: "其他文件", emit: "add-other", theme: "default" }
] as const;
const attachmentItemList = ref<ProductAttachmentItem[]>([]);
const attachmentUrlList = computed<string[]>({
get: () => attachmentItemList.value.map(item => item.url),
set: value => {
const urlSet = new Set(value);
attachmentItemList.value = attachmentItemList.value.filter(item => urlSet.has(item.url));
}
});
const solidColorCoverColor = computed(() => attachmentItemList.value.find(item => item.isSolidColorCover)?.solidColor || "");
const skuCombinationCount = computed(() => {
if (!form.specList.length) {
return 1;
}
const valueCountList = form.specList.map(spec => spec.valueList.filter(value => value.enabled !== false && !!value.name.trim()).length);
if (valueCountList.some(count => !(0 < count))) {
return 0;
}
return valueCountList.reduce((total, count) => total * count, 1);
});
const canGenerateSku = computed(() => 0 < skuCombinationCount.value);
const generateSkuButtonText = computed(() => `根据规格组合生成(${skuCombinationCount.value} 个)`);
const specValueDraftMap = reactive<Record<string, string>>({});
const unitEditorVisible = ref(false);
const editingSku = ref<SkuForm>();
const batchUnitEditorVisible = ref(false);
const batchUnitList = ref<UnitForm[]>([]);
const removedSkuKeySet = new Set<string>();
const removedSkuMap = new Map<string, SkuForm>();
function createUnit(unit?: Partial<ProductSkuUnit>, defaultName = "件"): UnitForm {
return {
...unit,
id: unit?.id,
clientKey: window.crypto.randomUUID(),
name: unit?.name || defaultName,
baseQuantityPerUnit: unit?.baseQuantityPerUnit ?? "1",
salePriceText: unit?.salePrice === null || unit?.salePrice === undefined ? "" : formatProductAmount(unit.salePrice),
isBase: unit?.isBase ?? true,
allowPurchase: unit?.allowPurchase ?? form.productType !== "SERVICE",
allowSale: unit?.allowSale ?? true,
sort: unit?.sort ?? 0
};
}
function createSku(sku?: Partial<ProductSku>): SkuForm {
const sourceUnitList = sku?.unitList || [];
const photoItemList = (sku?.attachmentList || []).filter(item => !!item.id).map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
}));
return {
...sku,
id: sku?.id,
clientKey: window.crypto.randomUUID(),
code: sku?.code || form.code,
name: sku?.name || form.name,
specSummary: sku?.specSummary || "",
stockEnabled: sku?.stockEnabled ?? form.stockEnabled,
allowDecimalQuantity: sku?.allowDecimalQuantity ?? false,
status: sku?.status || "ACTIVE",
minStockQuantityText: sku?.minStockQuantity === null || sku?.minStockQuantity === undefined ? "" : String(sku.minStockQuantity),
unitList: sourceUnitList.length ? sourceUnitList.map(unit => createUnit(unit)) : [createUnit({ isBase: true, baseQuantityPerUnit: "1" })],
photoItemList
};
}
function createSpec(spec?: Partial<ProductSpec>): SpecForm {
return {
...spec,
id: spec?.id,
clientKey: window.crypto.randomUUID(),
name: spec?.name || "",
valueList: (spec?.valueList || []).map(value => ({ ...value, id: value.id, clientKey: window.crypto.randomUUID(), name: value.name || "", enabled: value.enabled ?? true }))
};
}
function addSpec(): void {
const spec = createSpec();
form.specList.push(spec);
specValueDraftMap[spec.clientKey] = "";
}
function removeSpec(index: number): void {
if (form.specList[index]?.id) {
Message.warning("已保存的规格项不能删除,请先停用相关 SKU");
return;
}
const [spec] = form.specList.splice(index, 1);
if (spec) {
delete specValueDraftMap[spec.clientKey];
}
}
function addSpecValue(spec: SpecForm): void {
const name = specValueDraftMap[spec.clientKey]?.trim() || "";
if (!name) {
Message.warning("请输入规格值");
return;
}
if (spec.valueList.some(value => value.name.trim() === name)) {
Message.warning("同一规格项的规格值不能重复");
return;
}
spec.valueList.push({ clientKey: window.crypto.randomUUID(), name, enabled: true });
specValueDraftMap[spec.clientKey] = "";
}
function getNamedSpecValues(spec: SpecForm): SpecValueForm[] {
return spec.valueList.filter(value => !!value.name.trim());
}
function removeSpecValue(spec: SpecForm, index: number): void {
if (spec.valueList[index]?.id) {
Message.warning("已保存的规格值不能删除");
return;
}
spec.valueList.splice(index, 1);
}
function openUnitEditor(sku: SkuForm): void {
editingSku.value = sku;
unitEditorVisible.value = true;
}
function closeUnitEditor(): void {
unitEditorVisible.value = false;
editingSku.value = undefined;
}
function openBatchUnitEditor(): void {
const sourceSku = form.skuList[0];
if (!sourceSku) {
return;
}
batchUnitList.value = sourceSku.unitList.map(unit => ({ ...unit, id: undefined, skuId: undefined, clientKey: window.crypto.randomUUID() }));
batchUnitEditorVisible.value = true;
}
function closeBatchUnitEditor(): void {
batchUnitEditorVisible.value = false;
}
function applyBatchUnitTemplate(): void {
const templateList = batchUnitList.value;
for (const sku of form.skuList) {
const templateNameSet = new Set(templateList.map(unit => unit.name.trim()));
if (sku.unitList.some(unit => !!unit.id && !templateNameSet.has(unit.name.trim()))) {
Message.warning("统一配置不能删除或重命名已保存单位,请在 SKU 明细中停售");
return;
}
}
form.skuList.forEach(sku => {
const oldUnitList = sku.unitList;
const oldUnitMap = new Map(oldUnitList.filter(unit => !!unit.id).map(unit => [unit.name.trim(), unit]));
sku.unitList = templateList.map((unit, unitIndex) => ({
...unit,
id: oldUnitMap.get(unit.name.trim())?.id,
skuId: sku.id,
clientKey: window.crypto.randomUUID(),
sort: unitIndex
}));
});
Message.success(`已为 ${form.skuList.length} 个 SKU 统一配置销售单位`);
}
function removeSku(index: number): void {
const sku = form.skuList[index];
if (!sku) {
return;
}
if (form.skuList.length < 2) {
Message.warning("产品至少需要一个 SKU");
return;
}
if (form.specList.length && sku.specValueIdList?.length) {
const key = getCombinationKeyByIdList(sku.specValueIdList);
removedSkuKeySet.add(key);
removedSkuMap.set(key, sku);
}
form.skuList.splice(index, 1);
if (sku.id) {
Message.success("SKU 已移除,保存后将停用");
}
}
function openTypePicker(): void {
typeDraft.value = [form.productType];
typePickerVisible.value = true;
}
async function confirmType(value: Array<string | number>): Promise<void> {
const type = String(value[0] || "");
if (type !== "SERVICE" && type !== "GOODS" && type !== "CONSUMABLE") {
typePickerVisible.value = false;
return;
}
typePickerVisible.value = false;
if (type !== "SERVICE" || form.productType === "SERVICE") {
applyProductType(type);
return;
}
if (!await canChangeToService()) {
return;
}
const dialog = DialogPlugin.confirm({
title: "切换为服务产品",
content: "切换后将关闭产品及全部 SKU 的库存管理,并清空最低库存设置;已有库存流水会保留。确定继续吗?",
cancelBtn: "取消",
confirmBtn: "确定切换",
onCancel() {
dialog.destroy();
},
onConfirm() {
dialog.destroy();
applyProductType("SERVICE");
}
});
}
function applyProductType(type: ProductType): void {
form.productType = type;
if (type !== "SERVICE") {
return;
}
form.stockEnabled = false;
form.skuList.forEach(sku => {
sku.stockEnabled = false;
sku.minStockQuantityText = "";
sku.unitList.forEach(unit => { unit.allowPurchase = false; });
});
}
async function canChangeToService(): Promise<boolean> {
const skuIdList = form.skuList.map(sku => sku.id).filter((id): id is string => !!id);
if (!skuIdList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList });
if (stockList.some(stock => 0 < Number(stock.reservedQuantity || 0))) {
Message.warning("当前产品存在预占库存,不能切换为服务产品,请先完成或取消相关订单");
return false;
}
return true;
} catch (error) {
Message.error(getErrorMessage(error, "无法确认预占库存,暂不能切换为服务产品"));
return false;
}
}
function openCategoryPicker(): void {
categoryDraft.value = [form.categoryId || ""];
categoryPickerVisible.value = true;
}
function confirmCategory(value: Array<string | number>): void {
form.categoryId = String(value[0] || "") || undefined;
categoryPickerVisible.value = false;
}
function openStatusPicker(): void {
statusDraft.value = [form.status || "ACTIVE"];
statusPickerVisible.value = true;
}
function confirmStatus(value: Array<string | number>): void {
const status = String(value[0] || "");
if (status === "ACTIVE" || status === "INACTIVE") {
form.status = status;
}
statusPickerVisible.value = false;
}
function openAttachmentPicker(target: ProductAttachmentType): void {
attachmentPickerTarget.value = target;
attachmentInputRef.value?.click();
}
function capturePhoto(): void {
attachmentPickerTarget.value = "PHOTO";
photoInputRef.value?.click();
}
function captureVideo(): void {
attachmentPickerTarget.value = "OTHER";
videoInputRef.value?.click();
}
function handleAttachmentAction(action: MediaSelectorAddAction): void {
if (action.emit === "add-other") {
openAttachmentPicker("OTHER");
}
}
function openCoverColorPicker(): void {
coverColorDraft.value = solidColorCoverColor.value || DEFAULT_COVER_COLOR;
coverColorPickerVisible.value = true;
}
function cancelCoverColorPicker(): void {
coverColorPickerVisible.value = false;
coverColorDraft.value = solidColorCoverColor.value || DEFAULT_COVER_COLOR;
}
function updateCoverColorDraftFromPalette(context: { color: ColorObject }): void {
coverColorDraft.value = context.color.hex;
}
function createSolidColorPNG(color: string): Promise<File> {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d");
if (!context) {
return Promise.reject(new Error("纯色封面生成失败"));
}
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob) {
reject(new Error("纯色封面生成失败"));
return;
}
resolve(new File([blob], `product-cover-${Date.now()}.png`, { type: "image/png" }));
}, "image/png");
});
}
async function confirmCoverColorPicker(): Promise<void> {
if (isAttachmentUploading.value) {
return;
}
const color = coverColorDraft.value;
if (color === solidColorCoverColor.value) {
coverColorPickerVisible.value = false;
return;
}
coverColorPickerVisible.value = false;
isAttachmentUploading.value = true;
try {
const [tempFile] = await AttachmentAPI.upload(await createSolidColorPNG(color));
if (!tempFile?.id) {
throw new Error("纯色封面上传失败");
}
const coverItem: ProductAttachmentItem = {
attachType: "PHOTO",
isSolidColorCover: true,
solidColor: color,
tempFileId: tempFile.id,
url: AttachmentAPI.getTempReadURL(tempFile.id)
};
attachmentItemList.value = [coverItem, ...attachmentItemList.value.filter(item => !item.isSolidColorCover)];
} catch (error) {
Message.error(getErrorMessage(error, "纯色封面上传失败"));
} finally {
isAttachmentUploading.value = false;
}
}
async function handleAttachmentSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
isAttachmentUploading.value = true;
try {
const tempFile = (await AttachmentAPI.upload(await compressUploadImage(file)))[0];
if (tempFile?.id) {
attachmentItemList.value.push({ attachType: attachmentPickerTarget.value, mimeType: file.type, tempFileId: tempFile.id, url: AttachmentAPI.getTempReadURL(tempFile.id) });
}
} catch (error) {
Message.error(getErrorMessage(error, "产品附件上传失败"));
} finally {
isAttachmentUploading.value = false;
input.value = "";
}
}
function buildAttachmentList(): Attachment[] {
return attachmentItemList.value.flatMap(item => {
if (item.tempFileId) {
return [{
...item.attachment,
attachType: item.attachType || item.attachment?.attachType || "PHOTO",
tempFileId: item.tempFileId,
metadata: item.isSolidColorCover && item.solidColor ? { purpose: SOLID_COLOR_COVER_PURPOSE, color: item.solidColor } : undefined
} as Attachment];
}
if (item.attachment) {
return [{ ...item.attachment, attachType: item.attachType || item.attachment.attachType || "PHOTO" }];
}
return [];
});
}
function buildSkuAttachmentList(photoItemList: PhotoItem[]): Attachment[] {
return photoItemList.flatMap(item => {
if (item.tempFileId) {
return [{
...item.attachment,
attachType: item.attachType || item.attachment?.attachType || "PHOTO",
tempFileId: item.tempFileId
} as Attachment];
}
if (item.attachment) {
return [{ ...item.attachment, attachType: item.attachType || item.attachment.attachType || "PHOTO" } as Attachment];
}
return [];
});
}
function parseOptionalAmount(value: string): number | null | undefined {
return value.trim() ? parseProductAmount(value) : null;
}
function buildCombinationList(): SpecValueForm[][] {
let result: SpecValueForm[][] = [[]];
for (const spec of form.specList) {
const valueList: SpecValueForm[] = spec.valueList.filter(value => value.enabled !== false && !!value.name.trim() && (value.id || value.clientKey));
result = result.flatMap(combination => valueList.map(value => {
const nextCombination: SpecValueForm[] = [...combination, value];
return nextCombination;
}));
}
return result;
}
function getCombinationKey(valueList: SpecValueForm[]): string {
return valueList.map(value => value.id || value.clientKey).sort().join("\u0000");
}
function getCombinationSummary(valueList: SpecValueForm[]): string {
return valueList.map(value => value.name).join(";");
}
function regenerateSkuList(): void {
removedSkuKeySet.clear();
generateSkuList();
}
function generateSkuList(fallbackSkuList: ProductSku[] = [], preserveRemoved = false): void {
const combinationList = buildCombinationList();
if (!combinationList.length) {
Message.warning("请为每个规格项至少配置一个启用规格值");
return;
}
const currentList = [...form.skuList];
const fallback = fallbackSkuList.find(sku => !sku.specValueIdList?.length);
const hasExistingCombination = fallbackSkuList.some(sku => !!sku.specValueIdList?.length);
const reservedCodeSet = new Set([...fallbackSkuList, ...currentList].map(sku => sku.code.trim()).filter(Boolean));
const usedIdSet = new Set<string>();
form.skuList = combinationList.flatMap((valueList, index) => {
const key = getCombinationKey(valueList);
if (preserveRemoved && removedSkuKeySet.has(key)) {
return [];
}
const fallbackSku = fallbackSkuList.find(sku => !!sku.id && !usedIdSet.has(sku.id) && getCombinationKeyByIdList(sku.specValueIdList) === key);
const removedSku = preserveRemoved ? undefined : removedSkuMap.get(key);
const current = currentList.find(sku => getCombinationKeyByIdList(sku.specValueIdList) === key)
|| removedSku
|| (fallbackSku ? createSku(fallbackSku) : undefined)
|| (!index && !hasExistingCombination ? currentList.find(sku => !sku.specValueIdList?.length) : undefined)
|| (!index && !hasExistingCombination ? fallback && !usedIdSet.has(fallback.id || "") ? createSku(fallback) : undefined : undefined)
|| createSku({
code: getGeneratedSkuCode(index, reservedCodeSet),
name: form.name ? `${form.name} ${getCombinationSummary(valueList)}` : "",
specSummary: getCombinationSummary(valueList),
stockEnabled: form.stockEnabled,
allowDecimalQuantity: false,
unitList: currentList[0]?.unitList.map(unit => createUnit({
name: unit.name,
baseQuantityPerUnit: unit.baseQuantityPerUnit,
salePrice: unit.salePrice,
isBase: unit.isBase,
allowPurchase: unit.allowPurchase,
allowSale: unit.allowSale
}))
});
if (current) {
usedIdSet.add(current.id || "");
}
const summary = getCombinationSummary(valueList);
if (!current!.code.trim()) {
current!.code = getGeneratedSkuCode(index, reservedCodeSet);
}
if (!current!.name.trim()) {
current!.name = form.name ? `${form.name} ${summary}` : "";
}
if (current?.specSummary !== summary) {
current!.specSummary = summary;
}
current!.specValueIdList = valueList.map(value => value.id || value.clientKey);
return [current!];
});
}
function getGeneratedSkuCode(index: number, reservedCodeSet: Set<string>): string {
const baseCode = form.code ? `${form.code}-${index + 1}` : "";
if (!baseCode) {
return "";
}
let code = baseCode;
let suffix = 1;
while (reservedCodeSet.has(code)) {
code = `${baseCode}-${suffix}`;
suffix += 1;
}
reservedCodeSet.add(code);
return code;
}
function getCombinationKeyByIdList(idList?: string[]): string {
return (idList || []).slice().sort().join("\u0000");
}
function normalizeSkuSpecValueIds(): void {
const idMap = new Map<string, string>();
for (const spec of form.specList) {
for (const value of spec.valueList) {
if (value.id) {
idMap.set(value.clientKey, value.id);
}
}
}
for (const sku of form.skuList) {
sku.specValueIdList = (sku.specValueIdList || []).map(id => idMap.get(id) || id);
}
const normalizeKey = (key: string): string => key
.split("\u0000")
.map(id => idMap.get(id) || id)
.sort()
.join("\u0000");
const normalizedRemovedSkuKeySet = new Set([...removedSkuKeySet].map(normalizeKey));
removedSkuKeySet.clear();
normalizedRemovedSkuKeySet.forEach(key => removedSkuKeySet.add(key));
const normalizedRemovedSkuMap = new Map<string, SkuForm>();
removedSkuMap.forEach((sku, key) => {
sku.specValueIdList = (sku.specValueIdList || []).map(id => idMap.get(id) || id);
normalizedRemovedSkuMap.set(normalizeKey(key), sku);
});
removedSkuMap.clear();
normalizedRemovedSkuMap.forEach((sku, key) => removedSkuMap.set(key, sku));
}
function validate(): boolean {
if (!form.code.trim() || !form.name.trim()) {
Message.warning("产品编码和名称不能为空");
return false;
}
if (!form.specList.length && 1 === form.skuList.length) {
const defaultSku = form.skuList[0];
if (defaultSku && !defaultSku.code.trim()) {
defaultSku.code = form.code.trim();
}
if (defaultSku && !defaultSku.name.trim()) {
defaultSku.name = form.name.trim();
}
}
if (form.productType === "SERVICE" && form.stockEnabled) {
Message.warning("服务产品不能管理库存");
return false;
}
const specNameSet = new Set<string>();
for (const spec of form.specList) {
if (!spec.name.trim() || specNameSet.has(spec.name.trim())) {
Message.warning("规格名称不能为空且不能重复");
return false;
}
specNameSet.add(spec.name.trim());
const valueNameSet = new Set<string>();
for (const value of spec.valueList) {
if (!value.name.trim() || valueNameSet.has(value.name.trim())) {
Message.warning("同一规格项的规格值不能为空且不能重复");
return false;
}
valueNameSet.add(value.name.trim());
}
if (!spec.valueList.some(value => value.enabled !== false)) {
Message.warning("每个规格项至少需要一个规格值");
return false;
}
}
if (!form.skuList.length) {
Message.warning("至少需要一个 SKU");
return false;
}
const skuCodeSet = new Set<string>();
for (const sku of form.skuList) {
const pendingGeneratedSku = form.specList.length && 1 === form.skuList.length && !sku.specValueIdList?.length;
if ((!sku.code.trim() || !sku.name.trim()) && !pendingGeneratedSku) {
Message.warning("SKU 编码和名称不能为空");
return false;
}
if (skuCodeSet.has(sku.code.trim())) {
Message.warning("SKU 编码不能重复");
return false;
}
skuCodeSet.add(sku.code.trim());
if (form.productType === "SERVICE" && sku.stockEnabled) {
Message.warning("服务产品的 SKU 不能启用库存");
return false;
}
if (sku.stockEnabled && !normalizeProductQuantity(sku.minStockQuantityText || "0")) {
Message.warning("SKU 最低库存必须是非负数量,最多三位小数");
return false;
}
if (!sku.stockEnabled && sku.minStockQuantityText.trim()) {
Message.warning("未启用库存的 SKU 不能填写最低库存");
return false;
}
if (!sku.unitList.length) {
Message.warning("每个 SKU 至少需要一个单位");
return false;
}
const unitNameSet = new Set<string>();
let baseCount = 0;
for (const unit of sku.unitList) {
const baseQuantity = String(unit.baseQuantityPerUnit).trim();
const salePrice = parseOptionalAmount(unit.salePriceText);
if (!unit.name.trim() || unitNameSet.has(unit.name.trim())) {
Message.warning("同一 SKU 的单位名称不能为空且不能重复");
return false;
}
if (!/^\d+(?:\.\d{1,6})?$/.test(baseQuantity) || !(0 < Number(baseQuantity))) {
Message.warning("SKU 单位基础数量必须是大于 0 的数字,最多六位小数");
return false;
}
if (salePrice === undefined) {
Message.warning("SKU 单位售价必须是合法的非负元金额,最多两位小数");
return false;
}
unitNameSet.add(unit.name.trim());
if (unit.isBase) {
baseCount += 1;
if (baseQuantity !== "1" && Number(baseQuantity) !== 1) {
Message.warning("基础单位数量必须为 1");
return false;
}
}
}
if (baseCount !== 1) {
Message.warning("每个 SKU 必须且只能有一个基础单位");
return false;
}
}
return true;
}
function buildSkuPayload(sku: SkuForm, index: number): ProductSku {
return {
id: sku.id,
productId: form.id,
code: sku.code.trim(),
barcode: sku.barcode?.trim() || null,
name: sku.name.trim(),
specSummary: sku.specSummary?.trim() || null,
specValueIdList: sku.specValueIdList || [],
stockEnabled: !!sku.stockEnabled,
allowDecimalQuantity: !!sku.allowDecimalQuantity,
minStockQuantity: sku.stockEnabled ? normalizeProductQuantity(sku.minStockQuantityText || "0") : null,
status: sku.status || "ACTIVE",
remark: sku.remark?.trim() || null,
sort: index,
attachmentList: buildSkuAttachmentList(sku.photoItemList),
unitList: sku.unitList.map((unit, unitIndex) => ({
id: unit.id,
name: unit.name.trim(),
baseQuantityPerUnit: String(unit.baseQuantityPerUnit).trim(),
salePrice: parseOptionalAmount(unit.salePriceText)!,
isBase: !!unit.isBase,
allowPurchase: form.productType === "SERVICE" ? false : !!unit.allowPurchase,
allowSale: !!unit.allowSale,
sort: unitIndex
}))
};
}
function buildProductPayload(skuList?: ProductSku[], includeSpecList = false): Product {
return {
id: form.id,
categoryId: form.categoryId,
code: form.code.trim(),
name: form.name.trim(),
productType: form.productType,
stockEnabled: form.stockEnabled,
status: form.status,
remark: form.remark?.trim() || null,
attachmentList: buildAttachmentList(),
specList: includeSpecList ? form.specList.map(spec => ({
...spec,
productId: form.id,
name: spec.name.trim(),
valueList: spec.valueList.map(value => ({ ...value, name: value.name.trim() }))
})) : undefined,
skuList
};
}
async function syncSpecs(productId: string): Promise<void> {
for (const [specIndex, spec] of form.specList.entries()) {
if (!spec.id) {
spec.id = await ProductAPI.specCreate({ productId, name: spec.name.trim(), sort: specIndex });
}
for (const [valueIndex, value] of spec.valueList.entries()) {
if (!value.id) {
value.specId = spec.id;
value.id = await ProductAPI.specValueCreate({ specId: spec.id, name: value.name.trim(), enabled: value.enabled !== false, sort: valueIndex });
}
}
}
}
async function save(): Promise<void> {
if (isSaving.value || isAttachmentUploading.value || !userStore.hasPermissions(savePermissionCode.value) || !validate()) {
return;
}
isSaving.value = true;
try {
if (isCreateMode.value) {
if (form.specList.length) {
generateSkuList([], true);
}
const productId = await ProductAPI.create(buildProductPayload(form.skuList.map(buildSkuPayload), true));
Message.success("产品已创建");
await router.replace(`/management/products/${productId}`);
return;
}
const productId = form.id;
await syncSpecs(productId!);
normalizeSkuSpecValueIds();
const latest = await ProductAPI.detail(productId!);
const previousSkuIdSet = new Set((latest.skuList || []).map(sku => sku.id).filter((id): id is string => !!id));
if (form.specList.length) {
const defaultSku = latest.skuList?.find(sku => !sku.specValueIdList?.length);
if (defaultSku?.id && form.skuList[0] && !form.skuList[0].id) {
form.skuList[0].id = defaultSku.id;
}
generateSkuList(latest.skuList || [], true);
} else if (!form.skuList.length) {
form.skuList = [createSku(latest.skuList?.[0])];
} else if (!form.skuList[0]?.id && latest.skuList?.[0]) {
form.skuList[0].id = latest.skuList[0].id;
}
const payload = buildProductPayload(form.skuList.map(buildSkuPayload));
await ProductAPI.update(payload);
const retainedSkuIdSet = new Set(form.skuList.map(sku => sku.id).filter((id): id is string => !!id));
const disabledSkuIdList = [...previousSkuIdSet].filter(id => !retainedSkuIdSet.has(id));
await Promise.all(disabledSkuIdList.map(async skuId => await ProductAPI.skuDisable(skuId)));
Message.success(isCreateMode.value ? "产品已创建" : "产品已保存");
await router.replace(`/management/products/${productId}`);
} catch (error) {
Message.error(getErrorMessage(error, "产品保存失败,请检查 SKU 编码、规格组合和单位配置"));
} finally {
isSaving.value = false;
}
}
async function load(): Promise<void> {
const categoryResult = await ProductAPI.categoryList({ index: 0, size: 1000, status: "ACTIVE" });
categoryList.value = categoryResult.list || [];
if (isCreateMode.value) {
form.skuList.push(createSku());
return;
}
const result = await ProductAPI.detail(routeId.value);
if (result.category && !categoryList.value.some(item => item.id === result.category?.id)) {
categoryList.value.push(result.category);
}
Object.assign(form, {
id: result.id,
categoryId: result.categoryId,
code: result.code || "",
name: result.name || "",
productType: result.productType || "GOODS",
stockEnabled: !!result.stockEnabled,
status: result.status || "ACTIVE",
remark: result.remark || "",
specList: (result.specList || []).map(createSpec),
skuList: (result.skuList || []).map(createSku)
});
attachmentItemList.value = (result.attachmentList || []).filter(item => !!item.id).map(item => {
const metadata = item.metadata;
const solidColor = metadata?.purpose === SOLID_COLOR_COVER_PURPOSE && typeof metadata.color === "string" ? metadata.color : undefined;
return {
attachment: item,
attachType: item.attachType === "OTHER" ? "OTHER" : "PHOTO",
isSolidColorCover: !!solidColor,
solidColor,
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
};
});
if (!form.skuList.length) {
form.skuList.push(createSku());
}
}
onMounted(async () => {
navBarStore.clearRight();
if (userStore.hasPermissions(savePermissionCode.value)) {
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
}
try {
await load();
} catch (error) {
Message.error(getErrorMessage(error, "产品加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.product-edit {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
flex-direction: column;
.form {
margin: 0 1rem;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
overflow: hidden;
.color-trigger {
--td-button-extra-small-padding-horizontal: 2px;
gap: .75rem;
display: flex;
align-items: center;
padding-right: .5rem;
justify-content: flex-start;
.content {
gap: .5rem;
display: flex;
align-items: center;
.swatch {
width: 1.5rem;
height: 1.5rem;
display: block;
border-radius: .35rem;
&.empty {
background-color: #FFF;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%),
linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%);
background-size: 10px 10px;
background-position: 0 0, 5px 5px;
}
}
span {
color: #52607a;
font-size: .875rem;
}
}
}
}
.spec-list {
gap: .75rem;
padding: 1rem;
display: flex;
flex-direction: column;
.spec {
border: 1px dashed var(--td-component-border);
margin: 0;
border-radius: 1rem;
.name-wrap {
padding: 0 .5rem;
display: flex;
.name {
padding: 0 .5rem;
}
}
.tags {
gap: .5rem;
display: flex;
flex-wrap: wrap;
}
}
.add {
--td-button-border-radius: 1rem;
}
}
.sku-wrap {
:deep(.content) {
background: transparent;
}
.content-wrap {
gap: 1rem;
display: flex;
flex-direction: column;
}
}
.file-input {
display: none;
}
}
:global(html.theme-dark) .product-edit {
.spec-card {
border-color: rgba(148, 163, 184, .3);
}
}
</style>