优化组件开发的文件结构

This commit is contained in:
2025-08-28 09:49:54 +08:00
parent 0411816b2e
commit 08e79b0681
9 changed files with 18 additions and 21 deletions
@@ -0,0 +1,53 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
maxSizeMB?: number;
}>(),
{ maxSizeMB: 5 },
);
const image = defineModel<string>();
const updateImage = (file: File) => {
image.value = URL.createObjectURL(file);
};
const { open } = useUploadImage(updateImage, props.maxSizeMB);
</script>
<template>
<div class="h-full w-full relative group">
<img
class="h-full w-full object-contain image-bg rounded"
:src="image"
alt="show image"
/>
<div
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<div
class="flex items-center p-2 px-3 gap-3 invisible group-hover:visible rounded-lg bg-[rgba(var(--v-theme-background),0.4)] backdrop-blur-md cursor-pointer"
>
<a-tooltip :title="1">
<icon name="mage:image-upload" size="18" @click="open" />
</a-tooltip>
<a-tooltip :title="2">
<icon
name="material-symbols:delete-outline"
size="18"
@click.stop="image = undefined"
/>
</a-tooltip>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.image-bg {
@apply backdrop-blur-md bg-gradient-to-r
from-gray-300 via-gray-50 to-gray-300
dark:from-gray-600 dark:via-gray-300 dark:to-gray-600;
}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
maxSizeMB?: number;
}>(),
{ maxSizeMB: 5 },
);
const image = defineModel<string>();
const updateImage = (file: File) => {
image.value = URL.createObjectURL(file);
};
const { open, handleDrop, handlePaste } = useUploadImage(
updateImage,
props.maxSizeMB,
);
onMounted(() => {
window.addEventListener("paste", handlePaste);
});
onBeforeUnmount(() => {
window.removeEventListener("paste", handlePaste);
});
</script>
<template>
<div
class="aspect-[5/2] flex justify-center items-center cursor-pointer"
@click="open"
>
<div
class="flex flex-col gap-2 justify-center items-center rounded-lg"
@dragover.prevent
@drop.prevent="handleDrop"
>
<slot name="description" :max-size-m-b="maxSizeMB">
<icon name="mdi:image-plus-outline" size="24" />
<p class="text-md">点击/拖拽/粘贴</p>
<p class="text-xs text-gray-500">
请上传图片文件文件大小不超过{{ maxSizeMB }}MB
</p>
</slot>
</div>
</div>
</template>
<style scoped lang="scss"></style>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import {
GlobalInjectKeyConst,
type GlobalInjectMaterials,
} from "~/types/common";
interface IImageSource {
_id: string;
url: string;
tags: string[];
}
const { materials: materialList } =
inject<GlobalInjectMaterials>(GlobalInjectKeyConst.AllMaterials, {
materials: ref<IImageSource>([]),
}) || {};
const emit = defineEmits(["update-image"]);
const pageSize = defineModel<number>({ default: 10 });
const showRecommendedImage = computed(() => {
return materialList.value?.length > 0;
});
// 当前显示的列表
const visibleMaterials = ref<IImageSource[]>([]);
// 是否显示刷新按钮:总数大于 pageSize 才需要
const showRefreshButton = computed(() => {
return materialList.value?.length > pageSize.value;
});
// 初始化:随机抽取pageSize条
const initVisibleMaterials = () => {
if (!materialList.value?.length) return;
visibleMaterials.value = getRandomMaterials(
materialList.value,
pageSize.value,
);
};
// 随机取 count 条,如果不够就循环补足
const getRandomMaterials = (
list: IImageSource[],
count: number,
): IImageSource[] => {
if (!list.length) {
return [];
}
const shuffled = [...list].sort(() => Math.random() - 0.5);
if (shuffled.length >= count) {
return shuffled.slice(0, count);
} else {
// 不够就循环补足
const times = Math.ceil(count / shuffled.length);
const extended = Array(times).fill(shuffled).flat();
return extended.slice(0, count);
}
};
const isRotating = ref(false);
const refresh = () => {
if (isRotating.value) return; // 防止多次触发
isRotating.value = true;
// 调用原本的刷新逻辑
visibleMaterials.value = getRandomMaterials(
materialList.value,
pageSize.value,
);
// 动画持续 0.5s 后移除旋转(保证动画能再次触发)
setTimeout(() => {
isRotating.value = false;
}, 500);
};
const handleSetImage = (url: string) => {
emit("update-image", url);
};
const visibleMap = reactive<Record<string, boolean>>({});
const hoveringMap = reactive<Record<string, boolean>>({});
const timerMap: Record<string, ReturnType<typeof setTimeout>> = {};
const startDelay = (id: string) => {
hoveringMap[id] = true;
if (timerMap[id]) clearTimeout(timerMap[id]);
timerMap[id] = setTimeout(() => {
if (hoveringMap[id]) {
visibleMap[id] = true;
}
}, 500);
};
const cancelDelay = (id: string) => {
hoveringMap[id] = false;
if (timerMap[id]) clearTimeout(timerMap[id]);
visibleMap[id] = false;
};
onMounted(async () => {
initVisibleMaterials();
watch(pageSize, () => {
initVisibleMaterials();
});
});
</script>
<template>
<div
v-if="showRecommendedImage"
class="px-2 py-1 flex flex-row items-center gap-2 rounded"
>
<div class="text-sm mb-2 flex-shrink-0">推荐:</div>
<div
class="grid gap-2"
:style="`grid-template-columns: repeat(${pageSize}, minmax(0, 1fr))`"
>
<div
v-for="material in visibleMaterials"
:key="material._id"
class="cursor-pointer"
@click="handleSetImage(material.url)"
>
<div
class="inline-block"
@mouseenter="startDelay(material._id)"
@mouseleave="cancelDelay(material._id)"
@dragstart="cancelDelay(material._id)"
>
<a-popover :open="!!visibleMap[material._id]">
<img
:src="material.url"
alt="img"
class="object-cover w-full rounded aspect-[1/1]"
/>
<template #content>
<img
:src="material.url"
alt="img"
class="w-48 h-auto object-contain border cursor-pointer"
/>
</template>
</a-popover>
</div>
</div>
</div>
<div
v-if="showRefreshButton"
class="cursor-pointer text-sm flex-shrink-0"
@click="refresh"
>
<a-popover>
<icon
name="hugeicons:exchange-01"
size="20"
:class="{ 'animate-spin': isRotating }"
/>
<template #content>
<div class="text-xs">刷新</div>
</template>
</a-popover>
</div>
</div>
</template>
<style scoped lang="scss"></style>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import {GlobalInjectKeyConst, type GlobalInjectUploadFileToOSS} from "~/types";
import ImagePreviewWithUpload from "./ImagePreviewWithUpload.vue";
import RecommendedImages from "./RecommendedImages.vue";
interface Props {
title?: string; // 标题,显示在上传按钮上方
paramName?: string;
params?: { name: string; param?: string }[];
}
const props = withDefaults(defineProps<Props>(), {
title: "",
paramName: "upload_image_path",
});
const { useUtilsUploadFileToOSS } = inject<GlobalInjectUploadFileToOSS>(
GlobalInjectKeyConst.UploadFileToOSS,
{
useUtilsUploadFileToOSS: async (file: File | Blob, filename?: string) => "",
},
);
const image = ref<string | undefined>();
const handleSetImage = (url: string) => {
image.value = url;
};
const pageSize = ref<number>(8);
const handleUploadImg = async (image?: string) => {
if (!image) return;
if (image.startsWith("http")) {
return image;
}
const blob = await ImageUrlToBlob(image);
const fileName = `upload_image_${new Date().getTime()}.png`;
return await useUtilsUploadFileToOSS(blob, fileName);
};
const handleBindParams = async () => {
return {
[props.paramName]: await handleUploadImg(image.value),
};
};
defineExpose({
handleBindParams,
});
</script>
<template>
<div>
<div class="max-w-[460px] mt-1">
<div class="flex items-center py-1">
<icon name="icon-park:upload-picture" size="20" class="mr-1 text-blue-500 dark:text-white" />
{{ title || "图片上传" }}
</div>
<div class="flex flex-col items-center border rounded">
<!-- 图片上传预览区域 -->
<div class="h-48 w-full flex justify-center">
<!-- 显示选中图片 -->
<div v-if="image" class="p-2 w-full">
<ImagePreviewWithUpload v-model="image" />
</div>
<!-- 图片上传 -->
<ImageUploadDropPasteClick v-else v-model="image" />
</div>
<div>
<RecommendedImages
v-model="pageSize"
@update-image="handleSetImage"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>