support solid cover product

This commit is contained in:
Timi
2026-09-05 22:07:37 +08:00
parent 12285308dd
commit 2eb18a1474
+183 -10
View File
@@ -52,7 +52,7 @@
<photo-scroller <photo-scroller
v-model="attachmentUrlList" v-model="attachmentUrlList"
v-model:photo-items="attachmentItemList" v-model:photo-items="attachmentItemList"
title="附件列表" title="附件列表(取第一张图片作为封面)"
:edit-mode="true" :edit-mode="true"
:loading="isAttachmentUploading" :loading="isAttachmentUploading"
:show-viewer-actions="false" :show-viewer-actions="false"
@@ -61,6 +61,24 @@
@add-other="openAttachmentPicker('OTHER')" @add-other="openAttachmentPicker('OTHER')"
/> />
</t-form-item> </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> </t-form>
<card-container <card-container
title="产品单位" title="产品单位"
@@ -75,8 +93,8 @@
> >
<t-form-item label="单位名称" required-mark> <t-form-item label="单位名称" required-mark>
<t-input v-model.trim="unit.name" borderless maxlength="32" placeholder="如 瓶、次" /> <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 <t-button
v-if="1 < form.unitList.length"
theme="danger" theme="danger"
variant="text" variant="text"
size="extra-small" size="extra-small"
@@ -139,11 +157,29 @@
<t-popup v-model="statusPickerVisible" placement="bottom"> <t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker v-model="statusDraft" :columns="statusOptions" title="产品状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" /> <t-picker v-model="statusDraft" :columns="statusOptions" title="产品状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
</t-popup> </t-popup>
<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="image/*" @change="handleAttachmentSelected" /> <input ref="attachmentInputRef" class="file-input" type="file" accept="image/*" @change="handleAttachmentSelected" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type { ColorObject } from "tdesign-mobile-vue";
import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api"; import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue"; import PhotoScroller from "@/components/PhotoScroller.vue";
@@ -162,8 +198,13 @@ type ProductUnitForm = ProductUnit & { clientKey: string; salePriceText: string
type ProductAttachmentItem = PhotoItem & { type ProductAttachmentItem = PhotoItem & {
attachment?: Attachment; attachment?: Attachment;
attachType: ProductAttachmentType; attachType: ProductAttachmentType;
isSolidColorCover?: boolean;
solidColor?: string;
}; };
const SOLID_COLOR_COVER_PURPOSE = "PRODUCT_SOLID_COLOR_COVER";
const DEFAULT_COVER_COLOR = "#2F88FF";
defineOptions({ name: "ProductEdit" }); defineOptions({ name: "ProductEdit" });
const route = useRoute(); const route = useRoute();
@@ -192,6 +233,8 @@ const statusPickerVisible = ref(false);
const attachmentInputRef = ref<HTMLInputElement>(); const attachmentInputRef = ref<HTMLInputElement>();
const attachmentPickerTarget = ref<ProductAttachmentType>("PHOTO"); const attachmentPickerTarget = ref<ProductAttachmentType>("PHOTO");
const isAttachmentUploading = ref(false); const isAttachmentUploading = ref(false);
const coverColorPickerVisible = ref(false);
const coverColorDraft = ref(DEFAULT_COVER_COLOR);
const typeDraft = ref<PickerValue[]>([]); const typeDraft = ref<PickerValue[]>([]);
const categoryDraft = ref<PickerValue[]>([]); const categoryDraft = ref<PickerValue[]>([]);
const statusDraft = ref<PickerValue[]>([]); const statusDraft = ref<PickerValue[]>([]);
@@ -205,6 +248,18 @@ const statusOptions = [
{ label: "启用", value: "ACTIVE" }, { label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" } { 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 selectedCategoryName = computed(() => categoryList.value.find(item => item.id === form.categoryId)?.name || "不设置分类");
const attachmentAddActions = [ const attachmentAddActions = [
{ {
@@ -230,6 +285,7 @@ const attachmentUrlList = computed<string[]>({
attachmentItemList.value = attachmentItemList.value.filter(item => urlSet.has(item.url)); attachmentItemList.value = attachmentItemList.value.filter(item => urlSet.has(item.url));
} }
}); });
const solidColorCoverColor = computed(() => attachmentItemList.value.find(item => item.isSolidColorCover)?.solidColor || "");
function createUnit(unit?: Partial<ProductUnit>): ProductUnitForm { function createUnit(unit?: Partial<ProductUnit>): ProductUnitForm {
return { return {
@@ -326,6 +382,77 @@ function openAttachmentPicker(target: ProductAttachmentType): void {
attachmentInputRef.value?.click(); attachmentInputRef.value?.click();
} }
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("纯色封面上传失败");
}
attachmentItemList.value = [
{
attachType: "PHOTO",
isSolidColorCover: true,
solidColor: color,
tempFileId: tempFile.id,
url: AttachmentAPI.getTempReadURL(tempFile.id)
},
...attachmentItemList.value.filter(item => !item.isSolidColorCover)
];
} catch (error) {
Message.error(getErrorMessage(error, "纯色封面上传失败"));
} finally {
isAttachmentUploading.value = false;
}
}
async function handleAttachmentSelected(event: Event): Promise<void> { async function handleAttachmentSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
@@ -362,7 +489,11 @@ function buildAttachmentList(): Attachment[] {
if (item.tempFileId) { if (item.tempFileId) {
return [{ return [{
attachType: item.attachType, attachType: item.attachType,
tempFileId: item.tempFileId tempFileId: item.tempFileId,
metadata: item.isSolidColorCover && item.solidColor ? {
purpose: SOLID_COLOR_COVER_PURPOSE,
color: item.solidColor
} : undefined
}]; }];
} }
return []; return [];
@@ -428,7 +559,10 @@ function validate(): boolean {
} }
async function save(): Promise<void> { async function save(): Promise<void> {
if (isSaving.value || !userStore.hasPermissions(savePermissionCode.value) || !validate()) { if (isSaving.value || isAttachmentUploading.value) {
return;
}
if (!userStore.hasPermissions(savePermissionCode.value) || !validate()) {
return; return;
} }
isSaving.value = true; isSaving.value = true;
@@ -499,12 +633,18 @@ async function load(): Promise<void> {
}); });
attachmentItemList.value = (result.attachmentList || []) attachmentItemList.value = (result.attachmentList || [])
.filter(item => !!item.id) .filter(item => !!item.id)
.map(item => ({ .map(item => {
attachment: item, const metadata = item.metadata;
attachType: item.attachType === "OTHER" ? "OTHER" : "PHOTO", const solidColor = metadata?.purpose === SOLID_COLOR_COVER_PURPOSE && typeof metadata.color === "string" ? metadata.color : undefined;
previewUrl: getAttachmentReadURL(item.id || ""), return {
url: getAttachmentReadURL(item.id || "", 90) attachment: item,
})); attachType: item.attachType === "OTHER" ? "OTHER" : "PHOTO",
isSolidColorCover: !!solidColor,
solidColor,
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
};
});
minStockQuantityText.value = result.minStockQuantity === null || result.minStockQuantity === undefined ? "" : String(result.minStockQuantity); minStockQuantityText.value = result.minStockQuantity === null || result.minStockQuantity === undefined ? "" : String(result.minStockQuantity);
if (!form.unitList.length) { if (!form.unitList.length) {
addUnit(); addUnit();
@@ -541,6 +681,38 @@ onUnmounted(() => navBarStore.clearRight());
overflow: hidden; overflow: hidden;
} }
.color-trigger {
--td-button-extra-small-padding-horizontal: 2px;
gap: .75rem;
display: flex;
align-items: center;
padding-right: .875rem;
.content {
gap: .5rem;
display: flex;
align-items: center;
.swatch {
width: 1.5rem;
height: 1.5rem;
border: 1px solid var(--td-component-stroke);
display: block;
border-radius: .35rem;
&.empty {
background: var(--td-bg-color-secondarycontainer);
}
}
span {
color: var(--td-text-color-secondary);
font-size: .875rem;
}
}
}
.units { .units {
padding: 1rem 0; padding: 1rem 0;
@@ -587,4 +759,5 @@ onUnmounted(() => navBarStore.clearRight());
display: none; display: none;
} }
} }
</style> </style>