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