feat(project): connect category icon upload to files api

This commit is contained in:
2026-02-20 16:24:00 +08:00
parent df31135940
commit d66cd70b65
2 changed files with 372 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
<script setup lang="ts">
import type { CategoryFormModel } from '../types';
/**
* 文件职责:分类新增/编辑抽屉。
*/
import type { ProductCategoryChannel } from '#/api/product';
import { computed, onBeforeUnmount, ref } from 'vue';
import { VCropper } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { message, Modal, Upload } from 'ant-design-vue';
import { uploadTenantFileApi } from '#/api/files';
import { CATEGORY_CHANNEL_META, CATEGORY_CHANNEL_ORDER } from '../types';
interface Props {
form: CategoryFormModel;
open: boolean;
submitText: string;
submitting: boolean;
title: string;
}
interface Emits {
(event: 'close'): void;
(event: 'setDescription', value: string): void;
(event: 'setIcon', value: string): void;
(event: 'setName', value: string): void;
(event: 'setSort', value: number): void;
(event: 'submit'): void;
(event: 'toggleChannel', channel: ProductCategoryChannel): void;
(event: 'toggleStatus'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const cropOpen = ref(false);
const cropConfirming = ref(false);
const cropImageUrl = ref('');
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
let cropObjectUrl = '';
const canPreviewIcon = computed(() => {
const icon = props.form.icon.trim();
if (!icon) return false;
return (
icon.startsWith('data:image/') ||
icon.startsWith('http://') ||
icon.startsWith('https://') ||
icon.startsWith('/')
);
});
function setName(event: Event) {
const target = event.target as HTMLInputElement | null;
emit('setName', target?.value ?? '');
}
function setDescription(event: Event) {
const target = event.target as HTMLTextAreaElement | null;
emit('setDescription', target?.value ?? '');
}
function setSort(event: Event) {
const target = event.target as HTMLInputElement | null;
const value = Number(target?.value ?? '0');
emit('setSort', value);
}
function getChannelIcon(channel: ProductCategoryChannel) {
if (channel === 'wm') return 'lucide:bike';
if (channel === 'pickup') return 'lucide:shopping-bag';
return 'lucide:utensils';
}
function isAllowedIconFile(file: File) {
if (file.type === 'image/png' || file.type === 'image/svg+xml') {
return true;
}
const lowerName = file.name.toLowerCase();
return lowerName.endsWith('.png') || lowerName.endsWith('.svg');
}
function resetCropState() {
cropOpen.value = false;
cropConfirming.value = false;
cropImageUrl.value = '';
cropperRef.value = null;
if (!cropObjectUrl) {
return;
}
URL.revokeObjectURL(cropObjectUrl);
cropObjectUrl = '';
}
function openCropper(file: File) {
if (cropObjectUrl) {
URL.revokeObjectURL(cropObjectUrl);
}
cropObjectUrl = URL.createObjectURL(file);
cropImageUrl.value = cropObjectUrl;
cropOpen.value = true;
}
async function confirmCrop() {
const cropper = cropperRef.value;
if (!cropper) {
message.error('裁剪组件未就绪,请重试');
return;
}
cropConfirming.value = true;
try {
const cropResult = await cropper.getCropImage(
'image/png',
0.92,
'base64',
120,
120,
);
if (
typeof cropResult !== 'string' ||
!cropResult.startsWith('data:image/')
) {
message.error('裁剪失败,请调整裁剪框后重试');
return;
}
const response = await fetch(cropResult);
const blob = await response.blob();
const file = new File([blob], `category-icon-${Date.now()}.png`, {
type: 'image/png',
});
const uploadResult = await uploadTenantFileApi(file, 'dish_image');
const uploadedUrl = String(uploadResult?.url ?? '').trim();
if (!uploadedUrl) {
message.error('上传失败,请重试');
return;
}
emit('setIcon', uploadedUrl);
resetCropState();
} catch {
message.error('裁剪或上传失败,请重试');
} finally {
cropConfirming.value = false;
}
}
function cancelCrop() {
resetCropState();
}
function beforeUpload(file: File) {
if (!isAllowedIconFile(file)) {
message.warning('仅支持 PNG/SVG 格式');
return false;
}
if (file.size > 2 * 1024 * 1024) {
message.warning('图标大小不能超过 2MB');
return false;
}
openCropper(file);
return false;
}
onBeforeUnmount(() => {
if (cropObjectUrl) {
URL.revokeObjectURL(cropObjectUrl);
cropObjectUrl = '';
}
});
</script>
<template>
<div
class="pcat-drawer-mask"
:class="{ open: props.open }"
@click="emit('close')"
></div>
<div class="pcat-drawer" :class="{ open: props.open }">
<div class="pcat-drawer-hd">
<span class="pcat-drawer-title">{{ props.title }}</span>
<button type="button" class="pcat-drawer-close" @click="emit('close')">
×
</button>
</div>
<div class="pcat-drawer-bd">
<div class="pcat-form-group">
<label class="pcat-form-label required">分类名称</label>
<input
:value="props.form.name"
type="text"
class="pcat-input"
maxlength="20"
placeholder="请输入分类名称,如:热销推荐"
@input="setName"
/>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">分类描述</label>
<textarea
:value="props.form.description"
class="pcat-textarea"
rows="2"
maxlength="100"
placeholder="请输入分类描述,帮助顾客理解分类内容"
@input="setDescription"
></textarea>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">分类图标</label>
<div class="pcat-icon-upload-wrap">
<Upload
:show-upload-list="false"
accept=".png,.svg,image/png,image/svg+xml"
:before-upload="beforeUpload"
>
<div class="pcat-icon-upload">
<img
v-if="canPreviewIcon"
:src="props.form.icon"
class="pcat-icon-preview"
alt="分类图标"
/>
<template v-else>
<span class="pcat-icon-upload-sign">+</span>
<span class="pcat-icon-upload-text">上传图标</span>
</template>
</div>
</Upload>
</div>
<div class="pcat-hint">建议尺寸 120x120px支持 PNG/SVG</div>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label required">销售渠道</label>
<div class="pcat-hint mb-8">选择该分类在哪些渠道展示至少选择一个</div>
<div class="pcat-drawer-channels">
<button
v-for="channel in CATEGORY_CHANNEL_ORDER"
:key="`drawer-channel-${channel}`"
type="button"
class="pcat-drawer-ch"
:class="{ checked: props.form.channels.includes(channel) }"
@click="emit('toggleChannel', channel)"
>
<span class="pcat-ch-check"></span>
<IconifyIcon
class="pcat-drawer-ch-icon"
:icon="getChannelIcon(channel)"
/>
{{ CATEGORY_CHANNEL_META[channel].label }}
</button>
</div>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">排序</label>
<input
:value="props.form.sort"
type="number"
class="pcat-input pcat-input-small"
min="1"
placeholder="数字越小越靠前1"
@input="setSort"
/>
</div>
<div class="pcat-form-group">
<label class="pcat-form-label">启用状态</label>
<div class="pcat-toggle-row">
<button
type="button"
class="pcat-toggle"
:class="{ on: props.form.status === 'enabled' }"
@click="emit('toggleStatus')"
></button>
<span class="pcat-toggle-label">
{{ props.form.status === 'enabled' ? '启用' : '停用' }}
</span>
</div>
</div>
</div>
<div class="pcat-drawer-ft">
<button type="button" class="pcat-btn" @click="emit('close')">
取消
</button>
<button
type="button"
class="pcat-btn pcat-btn-primary"
:disabled="props.submitting"
@click="emit('submit')"
>
{{ props.submitting ? '保存中...' : props.submitText }}
</button>
</div>
</div>
<Modal
:open="cropOpen"
title="裁剪分类图标"
:width="560"
:mask-closable="false"
:keyboard="false"
:get-container="false"
:confirm-loading="cropConfirming"
destroy-on-close
ok-text="确认"
cancel-text="取消"
class="pcat-crop-modal"
@ok="confirmCrop"
@cancel="cancelCrop"
>
<div class="pcat-cropper-wrap">
<VCropper ref="cropperRef" :img="cropImageUrl" aspect-ratio="1:1" />
</div>
<div class="pcat-crop-hint">
拖拽裁剪框选择展示区域确认后自动保存为 120x120 PNG
</div>
</Modal>
</template>