Files
gao-customer-h5/src/pages/product/ProductEdit.vue
T
2026-09-03 16:09:56 +08:00

591 lines
18 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 size="small" v-model="form.stockEnabled" :disabled="form.productType === 'SERVICE'" />
</t-form-item>
<t-form-item label="允许小数" content-align="right">
<t-switch size="small" v-model="form.allowDecimalQuantity" />
</t-form-item>
<t-form-item v-if="form.stockEnabled" label="最低库存" help="基础单位数量,最多三位小数">
<t-input
v-model="minStockQuantityText"
type="number"
align="right"
borderless
/>
</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.trim="form.remark" borderless maxlength="255" autosize placeholder="可填写产品说明" />
</t-form-item>
<t-form-item label-width="0">
<photo-scroller
v-model="attachmentUrlList"
v-model:photo-items="attachmentItemList"
title="附件列表"
:edit-mode="true"
:loading="isAttachmentUploading"
:show-viewer-actions="false"
:add-actions="attachmentAddActions"
@add-photo="openAttachmentPicker('PHOTO')"
@add-other="openAttachmentPicker('OTHER')"
/>
</t-form-item>
</t-form>
<card-container
title="产品单位"
description="必须有且仅有一个基础单位;销售和库存业务只能选择允许的单位"
v-entrance-transition="70"
>
<div class="units">
<t-form
class="unit"
v-for="(unit, index) in form.unitList"
:key="unit.clientKey"
>
<t-form-item label="单位名称" required-mark>
<t-input v-model.trim="unit.name" borderless maxlength="32" placeholder="如 瓶、次" />
<!-- <t-button v-if="1 < form.unitList.length" theme="danger" variant="text" size="small" @click="removeUnit(index)">删除</t-button>-->
<t-button
theme="danger"
variant="text"
size="extra-small"
content="删除"
@click="removeUnit(index)"
/>
</t-form-item>
<t-form-item label="基础单位" content-align="right">
<t-switch
size="small"
v-model="unit.isBase"
:disabled="form.unitList.length === 1"
@change="setBaseUnit(index)"
/>
</t-form-item>
<t-form-item
label="基础单位数量"
help="每个单位对应的基础单位数量"
label-width="7rem"
required-mark
>
<t-input
v-model="unit.baseQuantityPerUnit"
type="number"
align="right"
borderless
:disabled="form.unitList.length === 1"
/>
</t-form-item>
<t-form-item label="建议售价">
<t-input
v-model="unit.salePriceText"
type="number"
align="right"
borderless
placeholder="金额(元)"
/>
</t-form-item>
<t-form-item label="允许采购" content-align="right">
<t-switch size="small" v-model="unit.allowPurchase" />
</t-form-item>
<t-form-item label="允许销售" content-align="right">
<t-switch size="small" v-model="unit.allowSale" />
</t-form-item>
</t-form>
<t-button
class="cell-button"
theme="primary"
content="添加业务单位"
@click="addUnit"
/>
</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>
<input ref="attachmentInputRef" class="file-input" type="file" accept="image/*" @change="handleAttachmentSelected" />
</div>
</template>
<script setup lang="ts">
import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { PhotoItem } from "@/types/Photo";
import type { Product, ProductAttachmentType, ProductCategory, ProductUnit } from "@/types/Product";
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;
type ProductUnitForm = ProductUnit & { clientKey: string; salePriceText: string };
type ProductAttachmentItem = PhotoItem & {
attachment?: Attachment;
attachType: ProductAttachmentType;
};
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<Product & { unitList: ProductUnitForm[] }>({
id: undefined,
code: "",
name: "",
productType: "GOODS",
stockEnabled: true,
allowDecimalQuantity: false,
status: "ACTIVE",
unitList: []
});
const minStockQuantityText = ref("0");
const categoryList = ref<ProductCategory[]>([]);
const typePickerVisible = ref(false);
const categoryPickerVisible = ref(false);
const statusPickerVisible = ref(false);
const attachmentInputRef = ref<HTMLInputElement>();
const attachmentPickerTarget = ref<ProductAttachmentType>("PHOTO");
const isAttachmentUploading = ref(false);
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 selectedCategoryName = computed(() => categoryList.value.find(item => item.id === form.categoryId)?.name || "不设置分类");
const attachmentAddActions = [
{
icon: "camera",
text: "产品图片",
emit: "add-photo",
theme: "primary"
},
{
icon: "file-attachment",
text: "其他文件",
emit: "add-other",
theme: "default"
}
] as const;
const attachmentItemList = ref<ProductAttachmentItem[]>([]);
const attachmentUrlList = computed<string[]>({
get() {
return attachmentItemList.value.map(item => item.url);
},
set(value) {
const urlSet = new Set(value);
attachmentItemList.value = attachmentItemList.value.filter(item => urlSet.has(item.url));
}
});
function createUnit(unit?: Partial<ProductUnit>): ProductUnitForm {
return {
...unit,
clientKey: window.crypto.randomUUID(),
name: unit?.name || "",
baseQuantityPerUnit: unit?.baseQuantityPerUnit ?? "1",
salePriceText: unit?.salePrice === null || unit?.salePrice === undefined ? "" : formatProductAmount(unit.salePrice),
isBase: unit?.isBase ?? false,
allowPurchase: unit?.allowPurchase ?? form.productType !== "SERVICE",
allowSale: unit?.allowSale ?? true,
sort: unit?.sort ?? 0
};
}
function addUnit(): void {
form.unitList.push(createUnit({
isBase: !form.unitList.length,
baseQuantityPerUnit: !form.unitList.length ? "1" : ""
}));
}
function removeUnit(index: number): void {
if (form.unitList.length < 2) {
Message.warning("产品至少需要一个单位");
return;
}
const wasBase = form.unitList[index]?.isBase;
form.unitList.splice(index, 1);
if (wasBase && form.unitList[0]) {
form.unitList[0].isBase = true;
form.unitList[0].baseQuantityPerUnit = "1";
}
}
function setBaseUnit(index: number): void {
form.unitList.forEach((unit, unitIndex) => {
unit.isBase = unitIndex === index;
});
const unit = form.unitList[index];
if (unit) {
unit.baseQuantityPerUnit = "1";
}
}
function openTypePicker(): void {
typeDraft.value = [form.productType];
typePickerVisible.value = true;
}
function confirmType(value: Array<string | number>): void {
const type = String(value[0] || "");
if (type === "SERVICE" || type === "GOODS" || type === "CONSUMABLE") {
form.productType = type;
if (type === "SERVICE") {
form.stockEnabled = false;
form.unitList.forEach(unit => {
unit.allowPurchase = false;
});
} else if (form.stockEnabled) {
form.unitList.forEach(unit => {
unit.allowPurchase = true;
});
}
}
typePickerVisible.value = 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();
}
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,
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.attachment) {
return [{
...item.attachment,
attachType: item.attachType || item.attachment.attachType || "PHOTO"
}];
}
if (item.tempFileId) {
return [{
attachType: item.attachType,
tempFileId: item.tempFileId
}];
}
return [];
});
}
function parseOptionalAmount(value: string): number | null | undefined {
if (!value.trim()) {
return null;
}
return parseProductAmount(value);
}
function validate(): boolean {
if (!form.code.trim() || !form.name.trim()) {
Message.warning("产品编码和名称不能为空");
return false;
}
if (form.productType === "SERVICE" && form.stockEnabled) {
Message.warning("服务产品不能管理库存");
return false;
}
if (form.stockEnabled && !normalizeProductQuantity(minStockQuantityText.value || "0")) {
Message.warning("最低库存必须是非负数量,最多三位小数");
return false;
}
if (!form.unitList.length) {
Message.warning("至少配置一个产品单位");
return false;
}
const unitNameSet = new Set<string>();
let baseCount = 0;
for (const unit of form.unitList) {
const name = unit.name.trim();
const baseQuantityPerUnit = String(unit.baseQuantityPerUnit).trim();
const salePrice = parseOptionalAmount(unit.salePriceText);
if (!name || unitNameSet.has(name)) {
Message.warning("单位名称不能为空且不能重复");
return false;
}
if (!/^\d+(?:\.\d{1,6})?$/.test(baseQuantityPerUnit) || !(0 < Number(baseQuantityPerUnit))) {
Message.warning("基础单位数量必须是大于 0 的数字,最多六位小数");
return false;
}
if (salePrice === undefined) {
Message.warning("单位售价必须是合法的非负元金额,最多两位小数");
return false;
}
unitNameSet.add(name);
if (unit.isBase) {
baseCount += 1;
if (baseQuantityPerUnit !== "1" && Number(baseQuantityPerUnit) !== 1) {
Message.warning("基础单位数量必须为 1");
return false;
}
}
}
if (baseCount !== 1) {
Message.warning("产品必须且只能有一个基础单位");
return false;
}
return true;
}
async function save(): Promise<void> {
if (isSaving.value || !userStore.hasPermissions(savePermissionCode.value) || !validate()) {
return;
}
isSaving.value = true;
try {
const minStockQuantity = form.stockEnabled ? normalizeProductQuantity(minStockQuantityText.value || "0") : undefined;
const unitList = form.unitList as ProductUnitForm[];
const payload: Product = {
id: form.id,
categoryId: form.categoryId,
code: form.code.trim(),
name: form.name.trim(),
productType: form.productType,
stockEnabled: form.stockEnabled,
allowDecimalQuantity: form.allowDecimalQuantity,
status: form.status,
minStockQuantity: form.stockEnabled ? minStockQuantity : null,
remark: form.remark?.trim() || null,
attachmentList: buildAttachmentList(),
unitList: unitList.map((unit, index) => ({
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: index
}))
};
if (isCreateMode.value) {
const id = await ProductAPI.create(payload);
Message.success("产品已创建");
await router.replace(`/management/products/${id}`);
} else {
await ProductAPI.update(payload);
Message.success("产品已保存");
await router.replace(`/management/products/${routeId.value}`);
}
} catch (error) {
Message.error(getErrorMessage(error, "产品保存失败"));
} 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) {
addUnit();
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,
allowDecimalQuantity: !!result.allowDecimalQuantity,
status: result.status || "ACTIVE",
remark: result.remark || "",
unitList: (result.unitList || []).map(createUnit)
});
attachmentItemList.value = (result.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType === "OTHER" ? "OTHER" : "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
}));
minStockQuantityText.value = result.minStockQuantity === null || result.minStockQuantity === undefined ? "" : String(result.minStockQuantity);
if (!form.unitList.length) {
addUnit();
}
}
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;
}
.units {
padding: 1rem 0;
.unit {
gap: .5rem;
border: 1px dashed var(--td-component-border);
padding: .5rem 0;
margin-bottom: .75rem;
border-radius: 1rem;
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
}
.unit-list {
padding: 1rem;
.unit-item {
padding: .75rem 0;
border-bottom: 1px dashed var(--td-component-border);
.unit-header {
display: flex;
align-items: center;
justify-content: space-between;
}
:deep(.t-form__item) {
padding: .25rem 0;
}
}
.add-unit {
margin-top: 1rem;
border-radius: .75rem;
}
}
.file-input {
display: none;
}
}
</style>