591 lines
18 KiB
Vue
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>
|