1205 lines
40 KiB
Vue
1205 lines
40 KiB
Vue
<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>
|