SingleImageUpload.vue 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. <!-- 单图上传组件 -->
  2. <template>
  3. <div class="single-image-upload">
  4. <el-upload
  5. v-model:file-list="internalFileList"
  6. class="single-upload"
  7. list-type="picture-card"
  8. :show-file-list="false"
  9. :accept="props.accept"
  10. :before-upload="handleBeforeUpload"
  11. :http-request="handleUpload"
  12. :on-success="onSuccess"
  13. :on-error="onError"
  14. :on-remove="handleDelete"
  15. :disabled="props.disabled"
  16. >
  17. <template #default>
  18. <template v-if="internalFileList && internalFileList.length > 0 && internalFileList[0].url">
  19. <el-image
  20. :key="internalFileList[0].url"
  21. class="single-upload__image"
  22. :src="internalFileList[0].url"
  23. fit="cover"
  24. :preview-src-list="props.enablePreview ? [internalFileList[0].url] : []"
  25. :preview-teleported="true"
  26. @click.stop="handleImageClick"
  27. />
  28. <el-icon
  29. v-if="!props.disabled"
  30. class="single-upload__delete-btn"
  31. @click.stop="handleDelete"
  32. >
  33. <CircleCloseFilled />
  34. </el-icon>
  35. </template>
  36. <template v-else>
  37. <el-icon class="single-upload__add-btn">
  38. <Plus />
  39. </el-icon>
  40. </template>
  41. </template>
  42. </el-upload>
  43. <div v-if="props.showTip" class="el-upload__tip">
  44. {{ props.tipText || `支持 ${props.accept} 格式,文件大小不超过 ${props.maxFileSize}MB` }}
  45. </div>
  46. </div>
  47. </template>
  48. <script setup lang="ts">
  49. import { ref, watch } from "vue";
  50. import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
  51. import ParamsAPI from "@/api/module_system/params";
  52. const props = defineProps({
  53. /**
  54. * 请求携带的额外参数
  55. */
  56. data: {
  57. type: Object,
  58. default: () => {
  59. return {};
  60. },
  61. },
  62. /**
  63. * 上传文件的参数名
  64. */
  65. name: {
  66. type: String,
  67. default: "file",
  68. },
  69. /**
  70. * 最大文件大小(单位:M)
  71. */
  72. maxFileSize: {
  73. type: Number,
  74. default: 10,
  75. },
  76. /**
  77. * 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
  78. */
  79. accept: {
  80. type: String,
  81. default: "image/*",
  82. },
  83. /**
  84. * 自定义样式,用于设置组件的宽度和高度等其他样式
  85. */
  86. style: {
  87. type: Object,
  88. default: () => {
  89. return {
  90. width: "150px",
  91. height: "150px",
  92. };
  93. },
  94. },
  95. /**
  96. * 是否禁用
  97. */
  98. disabled: {
  99. type: Boolean,
  100. default: false,
  101. },
  102. /**
  103. * 是否显示提示信息
  104. */
  105. showTip: {
  106. type: Boolean,
  107. default: false,
  108. },
  109. /**
  110. * 提示文本
  111. */
  112. tipText: {
  113. type: String,
  114. default: "",
  115. },
  116. /**
  117. * 是否启用图片预览功能
  118. */
  119. enablePreview: {
  120. type: Boolean,
  121. default: true,
  122. },
  123. /**
  124. * 自定义上传实现(**可选**):给了值就走它、**直接拿到图片地址字符串**;
  125. * 不给值时走既有的平台上传(打 `ParamsAPI.uploadFile`、从 `UploadFilePath` 取 `file_url`)——
  126. * 两种走法的护栏(`handleBeforeUpload`)与成功/失败回调(`onSuccess` / `onError`)
  127. * **完全相同**,故默认路径**逐字不变**,既有调用点一个字都不用改。
  128. *
  129. * 入参是已经建好的 `FormData`(部件名 = `name`,默认 `file`),实现只需返回地址字符串 ——
  130. * 用于那些**应答形状不是 `UploadFilePath`** 的上传端点(例如回裸字符串的)。
  131. * 实现抛异常会落进 `handleUpload` 既有的 `catch`(`onError` + 重抛)
  132. * ⇒ 实现里**不要**再写一层错误处理。
  133. */
  134. uploader: {
  135. type: Function as PropType<(fd: FormData) => Promise<string>>,
  136. default: null,
  137. },
  138. });
  139. // 接收字符串类型的modelValue,保持与现有代码的兼容性
  140. const modelValue = defineModel<string>({
  141. default: "",
  142. });
  143. // 内部使用的文件列表
  144. const internalFileList = ref<UploadUserFile[]>([]);
  145. // 监听modelValue变化,同步到internalFileList
  146. watch(
  147. () => modelValue.value,
  148. (newVal) => {
  149. if (newVal) {
  150. internalFileList.value = [
  151. {
  152. name: newVal.split("/").pop() || "image",
  153. url: newVal,
  154. },
  155. ];
  156. } else {
  157. internalFileList.value = [];
  158. }
  159. },
  160. { immediate: true }
  161. );
  162. // 监听internalFileList变化,同步到modelValue
  163. watch(
  164. () => internalFileList.value,
  165. (newVal) => {
  166. if (newVal && newVal.length > 0 && newVal[0].url) {
  167. modelValue.value = newVal[0].url;
  168. } else {
  169. modelValue.value = "";
  170. }
  171. },
  172. { deep: true }
  173. );
  174. /**
  175. * 定义组件触发的事件
  176. */
  177. const emit = defineEmits<{
  178. (e: "success", fileInfo: UploadFilePath): void;
  179. (e: "error", error: any): void;
  180. (e: "input", value: string): void;
  181. (e: "update:modelValue", value: string): void;
  182. }>();
  183. /**
  184. * 限制用户上传文件的格式和大小
  185. */
  186. function handleBeforeUpload(file: UploadRawFile) {
  187. // 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
  188. const acceptTypes = props.accept.split(",").map((type) => type.trim());
  189. // 检查文件格式是否符合 accept
  190. const isValidType = acceptTypes.some((type) => {
  191. if (type === "image/*") {
  192. // 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
  193. return file.type.startsWith("image/");
  194. } else if (type.startsWith(".")) {
  195. // 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
  196. return file.name.toLowerCase().endsWith(type);
  197. } else {
  198. // 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
  199. return file.type === type;
  200. }
  201. });
  202. if (!isValidType) {
  203. ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
  204. return false;
  205. }
  206. // 限制文件大小
  207. if (file.size > props.maxFileSize * 1024 * 1024) {
  208. ElMessage.warning(`上传图片不能大于 ${props.maxFileSize}MB`);
  209. return false;
  210. }
  211. return true;
  212. }
  213. /*
  214. * 上传图片
  215. */
  216. async function handleUpload(options: UploadRequestOptions) {
  217. try {
  218. const file = options.file;
  219. const formData = new FormData();
  220. formData.append(props.name, file);
  221. // 处理附加参数
  222. for (const [key, value] of Object.entries(props.data)) {
  223. formData.append(key, String(value));
  224. }
  225. // ★ 自定义上传实现(可选):给了 `uploader` 就走它,直接拿地址字符串。
  226. // 放在这里而不是别处:此时的 `formData` 已按上面的规则建好(含 `props.data`),
  227. // 下面那段默认路径(`ParamsAPI.uploadFile` + `UploadFilePath`)**逐字不变**。
  228. // 抛出的异常不在这里接 —— 外层既有的 `catch` 会走 `onError` 并重抛。
  229. if (props.uploader) {
  230. const url = await props.uploader(formData);
  231. onSuccess({ file_name: file.name, file_url: url } as UploadFilePath);
  232. return;
  233. }
  234. const response = await ParamsAPI.uploadFile(formData);
  235. if (response.data.code === 0 && response.data) {
  236. const fileInfo: UploadFilePath = response.data.data;
  237. // 调用成功回调
  238. onSuccess(fileInfo);
  239. return fileInfo;
  240. } else {
  241. const errorMsg = response.data.msg || "上传失败";
  242. ElMessage.error(errorMsg);
  243. throw new Error(errorMsg);
  244. }
  245. } catch (error) {
  246. onError(error instanceof Error ? error : new Error(String(error)));
  247. throw error;
  248. }
  249. }
  250. /**
  251. * 删除图片
  252. */
  253. function handleDelete() {
  254. // 清空模型值并通知父组件
  255. internalFileList.value = [];
  256. }
  257. /**
  258. * 图片点击处理
  259. * 阻止事件冒泡到上传组件,避免触发上传
  260. */
  261. function handleImageClick(event: Event) {
  262. // 阻止事件冒泡
  263. event.stopPropagation();
  264. // 如果启用了预览功能,则触发预览
  265. if (
  266. props.enablePreview &&
  267. internalFileList.value &&
  268. internalFileList.value.length > 0 &&
  269. internalFileList.value[0].url
  270. ) {
  271. // Element Plus的el-image组件会自动处理preview-src-list的预览功能
  272. // 这里只需要阻止冒泡即可
  273. }
  274. }
  275. /**
  276. * 上传成功回调
  277. *
  278. * @param fileInfo 上传成功后的文件信息
  279. */
  280. const onSuccess = (fileInfo: UploadFilePath) => {
  281. // 更新绑定的值为文件URL
  282. const newFileList = [
  283. {
  284. name: fileInfo.file_name,
  285. url: fileInfo.file_url,
  286. },
  287. ];
  288. internalFileList.value = newFileList;
  289. // 触发事件
  290. emit("success", fileInfo);
  291. emit("input", fileInfo.file_url);
  292. emit("update:modelValue", fileInfo.file_url);
  293. };
  294. /**
  295. * 上传失败回调
  296. */
  297. const onError = (error: any) => {
  298. console.error("图片上传失败:", error);
  299. ElMessage.error("图片上传失败,请重试");
  300. emit("error", error);
  301. };
  302. </script>
  303. <style scoped lang="scss">
  304. .single-image-upload {
  305. display: flex;
  306. flex-direction: column;
  307. align-items: flex-start;
  308. }
  309. .single-upload {
  310. &__image {
  311. cursor: pointer;
  312. border-radius: 6px;
  313. }
  314. &__delete-btn {
  315. position: absolute;
  316. top: 1px;
  317. right: 1px;
  318. display: none;
  319. font-size: 16px;
  320. color: var(--el-color-warning);
  321. cursor: pointer;
  322. background: var(--el-bg-color-overlay);
  323. border-radius: 100%;
  324. &:hover {
  325. color: var(--el-color-danger);
  326. }
  327. }
  328. &__add-btn {
  329. font-size: 28px;
  330. color: var(--el-text-color-placeholder);
  331. }
  332. }
  333. :deep(.el-upload--picture-card) {
  334. position: relative;
  335. width: v-bind("props.style.width ?? '150px'");
  336. height: v-bind("props.style.height ?? '150px'");
  337. &:hover {
  338. .single-upload__delete-btn {
  339. display: block;
  340. }
  341. }
  342. }
  343. .el-upload__tip {
  344. margin-top: 7px;
  345. font-size: 12px;
  346. color: var(--el-text-color-regular);
  347. }
  348. </style>