| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- <!-- 单图上传组件 -->
- <template>
- <div class="single-image-upload">
- <el-upload
- v-model:file-list="internalFileList"
- class="single-upload"
- list-type="picture-card"
- :show-file-list="false"
- :accept="props.accept"
- :before-upload="handleBeforeUpload"
- :http-request="handleUpload"
- :on-success="onSuccess"
- :on-error="onError"
- :on-remove="handleDelete"
- :disabled="props.disabled"
- >
- <template #default>
- <template v-if="internalFileList && internalFileList.length > 0 && internalFileList[0].url">
- <el-image
- :key="internalFileList[0].url"
- class="single-upload__image"
- :src="internalFileList[0].url"
- fit="cover"
- :preview-src-list="props.enablePreview ? [internalFileList[0].url] : []"
- :preview-teleported="true"
- @click.stop="handleImageClick"
- />
- <el-icon
- v-if="!props.disabled"
- class="single-upload__delete-btn"
- @click.stop="handleDelete"
- >
- <CircleCloseFilled />
- </el-icon>
- </template>
- <template v-else>
- <el-icon class="single-upload__add-btn">
- <Plus />
- </el-icon>
- </template>
- </template>
- </el-upload>
- <div v-if="props.showTip" class="el-upload__tip">
- {{ props.tipText || `支持 ${props.accept} 格式,文件大小不超过 ${props.maxFileSize}MB` }}
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, watch } from "vue";
- import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
- import ParamsAPI from "@/api/module_system/params";
- const props = defineProps({
- /**
- * 请求携带的额外参数
- */
- data: {
- type: Object,
- default: () => {
- return {};
- },
- },
- /**
- * 上传文件的参数名
- */
- name: {
- type: String,
- default: "file",
- },
- /**
- * 最大文件大小(单位:M)
- */
- maxFileSize: {
- type: Number,
- default: 10,
- },
- /**
- * 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
- */
- accept: {
- type: String,
- default: "image/*",
- },
- /**
- * 自定义样式,用于设置组件的宽度和高度等其他样式
- */
- style: {
- type: Object,
- default: () => {
- return {
- width: "150px",
- height: "150px",
- };
- },
- },
- /**
- * 是否禁用
- */
- disabled: {
- type: Boolean,
- default: false,
- },
- /**
- * 是否显示提示信息
- */
- showTip: {
- type: Boolean,
- default: false,
- },
- /**
- * 提示文本
- */
- tipText: {
- type: String,
- default: "",
- },
- /**
- * 是否启用图片预览功能
- */
- enablePreview: {
- type: Boolean,
- default: true,
- },
- /**
- * 自定义上传实现(**可选**):给了值就走它、**直接拿到图片地址字符串**;
- * 不给值时走既有的平台上传(打 `ParamsAPI.uploadFile`、从 `UploadFilePath` 取 `file_url`)——
- * 两种走法的护栏(`handleBeforeUpload`)与成功/失败回调(`onSuccess` / `onError`)
- * **完全相同**,故默认路径**逐字不变**,既有调用点一个字都不用改。
- *
- * 入参是已经建好的 `FormData`(部件名 = `name`,默认 `file`),实现只需返回地址字符串 ——
- * 用于那些**应答形状不是 `UploadFilePath`** 的上传端点(例如回裸字符串的)。
- * 实现抛异常会落进 `handleUpload` 既有的 `catch`(`onError` + 重抛)
- * ⇒ 实现里**不要**再写一层错误处理。
- */
- uploader: {
- type: Function as PropType<(fd: FormData) => Promise<string>>,
- default: null,
- },
- });
- // 接收字符串类型的modelValue,保持与现有代码的兼容性
- const modelValue = defineModel<string>({
- default: "",
- });
- // 内部使用的文件列表
- const internalFileList = ref<UploadUserFile[]>([]);
- // 监听modelValue变化,同步到internalFileList
- watch(
- () => modelValue.value,
- (newVal) => {
- if (newVal) {
- internalFileList.value = [
- {
- name: newVal.split("/").pop() || "image",
- url: newVal,
- },
- ];
- } else {
- internalFileList.value = [];
- }
- },
- { immediate: true }
- );
- // 监听internalFileList变化,同步到modelValue
- watch(
- () => internalFileList.value,
- (newVal) => {
- if (newVal && newVal.length > 0 && newVal[0].url) {
- modelValue.value = newVal[0].url;
- } else {
- modelValue.value = "";
- }
- },
- { deep: true }
- );
- /**
- * 定义组件触发的事件
- */
- const emit = defineEmits<{
- (e: "success", fileInfo: UploadFilePath): void;
- (e: "error", error: any): void;
- (e: "input", value: string): void;
- (e: "update:modelValue", value: string): void;
- }>();
- /**
- * 限制用户上传文件的格式和大小
- */
- function handleBeforeUpload(file: UploadRawFile) {
- // 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
- const acceptTypes = props.accept.split(",").map((type) => type.trim());
- // 检查文件格式是否符合 accept
- const isValidType = acceptTypes.some((type) => {
- if (type === "image/*") {
- // 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
- return file.type.startsWith("image/");
- } else if (type.startsWith(".")) {
- // 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
- return file.name.toLowerCase().endsWith(type);
- } else {
- // 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
- return file.type === type;
- }
- });
- if (!isValidType) {
- ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
- return false;
- }
- // 限制文件大小
- if (file.size > props.maxFileSize * 1024 * 1024) {
- ElMessage.warning(`上传图片不能大于 ${props.maxFileSize}MB`);
- return false;
- }
- return true;
- }
- /*
- * 上传图片
- */
- async function handleUpload(options: UploadRequestOptions) {
- try {
- const file = options.file;
- const formData = new FormData();
- formData.append(props.name, file);
- // 处理附加参数
- for (const [key, value] of Object.entries(props.data)) {
- formData.append(key, String(value));
- }
- // ★ 自定义上传实现(可选):给了 `uploader` 就走它,直接拿地址字符串。
- // 放在这里而不是别处:此时的 `formData` 已按上面的规则建好(含 `props.data`),
- // 下面那段默认路径(`ParamsAPI.uploadFile` + `UploadFilePath`)**逐字不变**。
- // 抛出的异常不在这里接 —— 外层既有的 `catch` 会走 `onError` 并重抛。
- if (props.uploader) {
- const url = await props.uploader(formData);
- onSuccess({ file_name: file.name, file_url: url } as UploadFilePath);
- return;
- }
- const response = await ParamsAPI.uploadFile(formData);
- if (response.data.code === 0 && response.data) {
- const fileInfo: UploadFilePath = response.data.data;
- // 调用成功回调
- onSuccess(fileInfo);
- return fileInfo;
- } else {
- const errorMsg = response.data.msg || "上传失败";
- ElMessage.error(errorMsg);
- throw new Error(errorMsg);
- }
- } catch (error) {
- onError(error instanceof Error ? error : new Error(String(error)));
- throw error;
- }
- }
- /**
- * 删除图片
- */
- function handleDelete() {
- // 清空模型值并通知父组件
- internalFileList.value = [];
- }
- /**
- * 图片点击处理
- * 阻止事件冒泡到上传组件,避免触发上传
- */
- function handleImageClick(event: Event) {
- // 阻止事件冒泡
- event.stopPropagation();
- // 如果启用了预览功能,则触发预览
- if (
- props.enablePreview &&
- internalFileList.value &&
- internalFileList.value.length > 0 &&
- internalFileList.value[0].url
- ) {
- // Element Plus的el-image组件会自动处理preview-src-list的预览功能
- // 这里只需要阻止冒泡即可
- }
- }
- /**
- * 上传成功回调
- *
- * @param fileInfo 上传成功后的文件信息
- */
- const onSuccess = (fileInfo: UploadFilePath) => {
- // 更新绑定的值为文件URL
- const newFileList = [
- {
- name: fileInfo.file_name,
- url: fileInfo.file_url,
- },
- ];
- internalFileList.value = newFileList;
- // 触发事件
- emit("success", fileInfo);
- emit("input", fileInfo.file_url);
- emit("update:modelValue", fileInfo.file_url);
- };
- /**
- * 上传失败回调
- */
- const onError = (error: any) => {
- console.error("图片上传失败:", error);
- ElMessage.error("图片上传失败,请重试");
- emit("error", error);
- };
- </script>
- <style scoped lang="scss">
- .single-image-upload {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- }
- .single-upload {
- &__image {
- cursor: pointer;
- border-radius: 6px;
- }
- &__delete-btn {
- position: absolute;
- top: 1px;
- right: 1px;
- display: none;
- font-size: 16px;
- color: var(--el-color-warning);
- cursor: pointer;
- background: var(--el-bg-color-overlay);
- border-radius: 100%;
- &:hover {
- color: var(--el-color-danger);
- }
- }
- &__add-btn {
- font-size: 28px;
- color: var(--el-text-color-placeholder);
- }
- }
- :deep(.el-upload--picture-card) {
- position: relative;
- width: v-bind("props.style.width ?? '150px'");
- height: v-bind("props.style.height ?? '150px'");
- &:hover {
- .single-upload__delete-btn {
- display: block;
- }
- }
- }
- .el-upload__tip {
- margin-top: 7px;
- font-size: 12px;
- color: var(--el-text-color-regular);
- }
- </style>
|