feat(project): connect category icon upload to files api
This commit is contained in:
45
apps/web-antd/src/api/files.ts
Normal file
45
apps/web-antd/src/api/files.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export type UploadFileType =
|
||||||
|
| 'business_license'
|
||||||
|
| 'dish_image'
|
||||||
|
| 'merchant_logo'
|
||||||
|
| 'other'
|
||||||
|
| 'review_image'
|
||||||
|
| 'user_avatar';
|
||||||
|
|
||||||
|
export interface FileUploadResponse {
|
||||||
|
fileName?: null | string;
|
||||||
|
fileSize: number;
|
||||||
|
url?: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TENANT_STORAGE_KEY = 'sys-tenant-id';
|
||||||
|
const DEV_TENANT_ID = import.meta.env.DEV ? import.meta.env.VITE_TENANT_ID : '';
|
||||||
|
|
||||||
|
function resolveTenantId() {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const hostnameParts = hostname.split('.').filter(Boolean);
|
||||||
|
const isIpAddress = /^\d+\.\d+\.\d+\.\d+$/.test(hostname);
|
||||||
|
const subdomainTenantId =
|
||||||
|
hostnameParts.length > 2 && !isIpAddress ? hostnameParts[0] : '';
|
||||||
|
const storageTenantId = localStorage.getItem(TENANT_STORAGE_KEY) || '';
|
||||||
|
return subdomainTenantId || storageTenantId || DEV_TENANT_ID || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadTenantFileApi(
|
||||||
|
file: File,
|
||||||
|
type: UploadFileType = 'other',
|
||||||
|
) {
|
||||||
|
const tenantId = resolveTenantId();
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new Error('缺少租户标识');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('File', file);
|
||||||
|
formData.append('TenantId', String(tenantId));
|
||||||
|
formData.append('Type', type);
|
||||||
|
|
||||||
|
return requestClient.post<FileUploadResponse>('/files/upload', formData);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user