需求:图片上传

37 阅读2分钟

效果:

image.png

advertise image支持图片上传,最多只能上传一张,上传的图片支持删除

image.png

完整代码:

<template>
  <div class="ad-edit-dialog">
    <el-dialog
      :model-value="modelValue"
      title="Advertisement"
      width="580px"
      @close="onClose"
    >
      <el-form ref="formRef" :model="form" :rules="rules" label-width="150px">
        <el-form-item label="advertising position" prop="positionKey">
          <el-select
            v-model="form.positionKey"
            placeholder="Select position"
            style="width: 300px"
          >
            <el-option
              v-for="p in positions"
              :key="p.key"
              :label="p.label"
              :value="p.key"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="Link Type" prop="linkType">
          <el-select
            v-model="form.linkType"
            placeholder="Select type"
            style="width: 300px"
          >
            <el-option v-for="t in linkTypes" :key="t" :label="t" :value="t" />
          </el-select>
        </el-form-item>

        <el-form-item label="Link" prop="link">
          <el-input
            v-model="form.link"
            placeholder="Please input"
            style="width: 300px"
          />
        </el-form-item>

        <el-form-item label="advertise Image">
          <div class="upload-row">
            <el-upload
              class="upload-one"
              :auto-upload="false"
              :limit="1"
              :file-list="fileList"
              :on-change="onFileChange"
              :on-remove="onRemove"
              :on-exceed="onExceed"
              list-type="picture-card"
            >
              <el-icon><Plus /></el-icon>
            </el-upload>
            <div class="sample">
              <div>示例尺寸:{{ sampleSize }}</div>
              <div v-if="actualSize">实际尺寸:{{ actualSize }}</div>
            </div>
          </div>
        </el-form-item>

        <el-form-item label="Promote Plus" prop="promotePlus">
          <el-select v-model="form.promotePlus" style="width: 300px">
            <el-option label="Yes" value="Yes" />
            <el-option label="No" value="No" />
          </el-select>
        </el-form-item>
      </el-form>

      <template #footer>
        <span class="dialog-footer">
          <el-button @click="onClose">Cancel</el-button>
          <el-button type="primary" @click="onConfirmClick">Confirm</el-button>
        </span>
      </template>

      <ConfirmDialog v-model="confirmVisible" @confirm="submit" />
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import ConfirmDialog from "./ConfirmDialog.vue";

export interface PositionItem {
  key: string;
  label: string;
  sampleSize: string;
}

export interface AdFormModel {
  id?: number;
  positionKey: string;
  linkType: "Internal" | "External";
  link: string;
  imageUrl?: string;
  promotePlus: "Yes" | "No";
}

const props = defineProps<{
  modelValue: boolean;
  model?: Partial<AdFormModel>;
  positions: PositionItem[];
  linkTypes: Array<"Internal" | "External">;
}>();

const emits = defineEmits<{
  (e: "update:modelValue", v: boolean): void;
  (e: "submit", v: AdFormModel): void;
  (e: "cancel"): void;
}>();

const formRef = ref();
const form = ref<AdFormModel>({
  positionKey: "",
  linkType: "Internal",
  link: "",
  imageUrl: "",
  promotePlus: "Yes",
});

const fileList = ref<any[]>([]);
const actualSize = ref("");

watch(
  () => props.model,
  (v) => {
    form.value = {
      id: v?.id,
      positionKey: v?.positionKey || "",
      linkType: (v?.linkType as any) || "Internal",
      link: v?.link || "",
      imageUrl: v?.imageUrl,
      promotePlus: (v?.promotePlus as any) || "Yes",
    };
    fileList.value = form.value.imageUrl
      ? [{ name: "image", url: form.value.imageUrl } as any]
      : [];
    actualSize.value = "";
  },
  { immediate: true }
);

const rules = {
  positionKey: [{ required: true, message: "Required", trigger: "change" }],
  linkType: [{ required: true, message: "Required", trigger: "change" }],
  link: [
    { required: true, message: "Required", trigger: "blur" },
    { validator: validateLink, trigger: "blur" },
  ],
  promotePlus: [{ required: true, message: "Required", trigger: "change" }],
} as const;

function validateLink(_: any, value: string, cb: any) {
  if (!value) return cb();
  if (form.value.linkType === "External") {
    const ok = /^https?:\/\//i.test(value);
    return ok ? cb() : cb(new Error("Must start with http(s)://"));
  }
  cb();
}

const sampleSize = computed(() => {
  const p = props.positions.find((p) => p.key === form.value.positionKey);
  return p?.sampleSize || "-";
});

function onFileChange(file: any, files: any[]) {
  if (files.length > 1) files.splice(0, files.length - 1);
  const raw = file.raw;
  if (raw) {
    const url = URL.createObjectURL(raw);
    form.value.imageUrl = url;
    fileList.value = [{ name: raw.name, url } as any];
    getImageSize(url).then((size) => {
      actualSize.value = `${size.width}×${size.height}`;
    });
  }
}

function onRemove() {
  form.value.imageUrl = "";
  fileList.value = [];
  actualSize.value = "";
}

function onExceed(files: any) {
  onRemove();
  onFileChange({ raw: files[0] }, [files[0]]);
}

function getImageSize(src: string): Promise<{ width: number; height: number }> {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () =>
      resolve({ width: img.naturalWidth, height: img.naturalHeight });
    img.src = src;
  });
}

const confirmVisible = ref(false);
function onConfirmClick() {
  formRef.value.validate((valid: boolean) => {
    if (!valid) return;
    confirmVisible.value = true;
  });
}

function submit() {
  emits("submit", { ...form.value });
  emits("update:modelValue", false);
}

function onClose() {
  emits("update:modelValue", false);
  emits("cancel");
}
</script>

<style scoped lang="scss">
.upload-row {
  display: flex;
  flex-direction: column;
  gap: 16px;

  .upload-one {
    display: flex;
  }
}
.sample {
  display: flex;
  gap: 15px;
  color: #6b7280;
  font-size: 12px;
}

// 弹窗标题样式
.ad-edit-dialog :deep(.el-dialog__header) {
  background: #f9fafb;
  margin: 0;
  padding: 16px;
  border-bottom: 1px solid #e5e7eb;
}

.ad-edit-dialog :deep(.el-dialog__body) {
  padding: 24px 24px 0 24px;
}
</style>

组件封装: SingleImageUpload.vue

<template>
  <div class="single-image-upload">
    <el-upload
      class="upload-one"
      :auto-upload="false"
      list-type="picture-card"
      :limit="1"
      :file-list="internalList"
      :on-change="onChange"
      :on-remove="onRemove"
      :on-exceed="onExceed"
      v-bind="$attrs"
    >
      <slot>
        <el-icon><Plus /></el-icon>
      </slot>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { Plus } from '@element-plus/icons-vue'

const props = defineProps<{ modelValue?: string }>()
const emits = defineEmits<{
  (e: 'update:modelValue', v: string): void
  (e: 'size', size: { width: number; height: number } | null): void
}>()

const internalList = ref<any[]>([])

watch(() => props.modelValue, (v) => {
  internalList.value = v ? [{ name: 'image', url: v } as any] : []
}, { immediate: true })

function onChange(file: any, files: any[]) {
  if (files.length > 1) files.splice(0, files.length - 1)
  const raw = file.raw
  if (raw) {
    const url = URL.createObjectURL(raw)
    internalList.value = [{ name: raw.name, url } as any]
    emits('update:modelValue', url)
    getImageSize(url).then(size => emits('size', size))
  }
}

function onRemove() {
  internalList.value = []
  emits('update:modelValue', '')
  emits('size', null)
}

function onExceed(files: any[]) {
  onRemove()
  onChange({ raw: files[0] }, [files[0]])
}

function getImageSize(src: string): Promise<{ width: number; height: number }> {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
    img.src = src
  })
}
</script>

<style scoped lang="scss">
.upload-one {
  display: inline-flex;
}
</style>



父组件:

<template>
  <div class="ad-edit-dialog">
    <el-dialog
      :model-value="modelValue"
      title="Advertisement"
      width="580px"
      @close="onClose"
    >
      <el-form ref="formRef" :model="form" :rules="rules" label-width="150px">
        <el-form-item label="advertising position" prop="positionKey">
          <el-select
            v-model="form.positionKey"
            placeholder="Select position"
            style="width: 300px"
          >
            <el-option
              v-for="p in positions"
              :key="p.key"
              :label="p.label"
              :value="p.key"
            />
          </el-select>
        </el-form-item>

        <el-form-item label="Link Type" prop="linkType">
          <el-select
            v-model="form.linkType"
            placeholder="Select type"
            style="width: 300px"
          >
            <el-option v-for="t in linkTypes" :key="t" :label="t" :value="t" />
          </el-select>
        </el-form-item>

        <el-form-item label="Link" prop="link">
          <el-input
            v-model="form.link"
            placeholder="Please input"
            style="width: 300px"
          />
        </el-form-item>

        <el-form-item label="advertise Image">
          <div class="upload-row">
            <SingleImageUpload v-model="form.imageUrl" @size="onSize" />
            <div class="sample">
              <div>示例尺寸:{{ sampleSize }}</div>
              <div v-if="actualSize">实际尺寸:{{ actualSize }}</div>
            </div>
          </div>
        </el-form-item>

        <el-form-item label="Promote Plus" prop="promotePlus">
          <el-select v-model="form.promotePlus" style="width: 300px">
            <el-option label="Yes" value="Yes" />
            <el-option label="No" value="No" />
          </el-select>
        </el-form-item>
      </el-form>

      <template #footer>
        <span class="dialog-footer">
          <el-button @click="onClose">Cancel</el-button>
          <el-button type="primary" @click="onConfirmClick">Confirm</el-button>
        </span>
      </template>

      <ConfirmDialog v-model="confirmVisible" @confirm="submit" />
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from "vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import SingleImageUpload from "./SingleImageUpload.vue";

export interface PositionItem {
  key: string;
  label: string;
  sampleSize: string;
}

export interface AdFormModel {
  id?: number;
  positionKey: string;
  linkType: "Internal" | "External";
  link: string;
  imageUrl?: string;
  promotePlus: "Yes" | "No";
}

const props = defineProps<{
  modelValue: boolean;
  model?: Partial<AdFormModel>;
  positions: PositionItem[];
  linkTypes: Array<"Internal" | "External">;
}>();

const emits = defineEmits<{
  (e: "update:modelValue", v: boolean): void;
  (e: "submit", v: AdFormModel): void;
  (e: "cancel"): void;
}>();

const formRef = ref();
const form = ref<AdFormModel>({
  positionKey: "",
  linkType: "Internal",
  link: "",
  imageUrl: "",
  promotePlus: "Yes",
});

const actualSize = ref("");

watch(
  () => props.model,
  (v) => {
    form.value = {
      id: v?.id,
      positionKey: v?.positionKey || "",
      linkType: (v?.linkType as any) || "Internal",
      link: v?.link || "",
      imageUrl: v?.imageUrl,
      promotePlus: (v?.promotePlus as any) || "Yes",
    };
    actualSize.value = "";
  },
  { immediate: true }
);

const rules = {
  positionKey: [{ required: true, message: "Required", trigger: "change" }],
  linkType: [{ required: true, message: "Required", trigger: "change" }],
  link: [
    { required: true, message: "Required", trigger: "blur" },
    { validator: validateLink, trigger: "blur" },
  ],
  promotePlus: [{ required: true, message: "Required", trigger: "change" }],
} as const;

function validateLink(_: any, value: string, cb: any) {
  if (!value) return cb();
  if (form.value.linkType === "External") {
    const ok = /^https?:\/\//i.test(value);
    return ok ? cb() : cb(new Error("Must start with http(s)://"));
  }
  cb();
}

const sampleSize = computed(() => {
  const p = props.positions.find((p) => p.key === form.value.positionKey);
  return p?.sampleSize || "-";
});

function onSize(size: { width: number; height: number } | null) {
  actualSize.value = size ? `${size.width}×${size.height}` : "";
}

const confirmVisible = ref(false);
function onConfirmClick() {
  formRef.value.validate((valid: boolean) => {
    if (!valid) return;
    confirmVisible.value = true;
  });
}

function submit() {
  emits("submit", { ...form.value });
  emits("update:modelValue", false);
}

function onClose() {
  emits("update:modelValue", false);
  emits("cancel");
}
</script>

<style scoped lang="scss">
.upload-row {
  display: flex;
  flex-direction: column;
  gap: 16px;

  .upload-one {
    display: flex;
  }
}
.sample {
  display: flex;
  gap: 15px;
  color: #6b7280;
  font-size: 12px;
}

// 弹窗标题样式
.ad-edit-dialog :deep(.el-dialog__header) {
  background: #f9fafb;
  margin: 0;
  padding: 16px;
  border-bottom: 1px solid #e5e7eb;
}

.ad-edit-dialog :deep(.el-dialog__body) {
  padding: 24px 24px 0 24px;
}
</style>

============================支持上传多张图片=============================== 父组件:

<MultipleImageUpload
 v-model="formData.image"
 :limit="5"
 :multiple="true"
 accept="image/jpeg,image/png"
/>
// formData.image 是个数组

子组件:

<template>
  <div class="multiple-image-upload">
    <el-upload
      class="upload-multiple"
      :auto-upload="false"
      list-type="picture-card"
      :limit="limit"
      :file-list="internalList"
      :on-change="onChange"
      :on-remove="onRemove"
      :on-exceed="onExceed"
      v-bind="$attrs"
    >
      <slot>
        <el-icon><Plus /></el-icon>
      </slot>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'

const props = defineProps<{ 
  modelValue?: string | string[]
  limit?: number
  multiple?: boolean
}>()

const emits = defineEmits<{
  (e: 'update:modelValue', v: string | string[]): void
  (e: 'size', size: { width: number; height: number }[]): void
}>()

const internalList = ref<any[]>([])
const limit = props.limit || 10

watch(() => props.modelValue, (v) => {
  if (Array.isArray(v)) {
    internalList.value = v.map((url, index) => ({ name: `image-${index}`, url } as any))
  } else if (v) {
    internalList.value = [{ name: 'image', url: v } as any]
  } else {
    internalList.value = []
  }
}, { immediate: true })

function onChange(_file: any, files: any[]) {
  const urls: string[] = []
  
  files.forEach((f: any) => {
    const url = f.url || (f.raw ? URL.createObjectURL(f.raw) : '')
    if (url) {
      urls.push(url)
    }
  })
  
  internalList.value = files.map((f: any, index: number) => {
    const url = f.url || (f.raw ? URL.createObjectURL(f.raw) : '')
    return { name: `image-${index}`, url } as any
  })
  
  // 根据multiple属性决定返回格式
  const result = props.multiple ? urls : (urls.length === 1 ? urls[0] : urls)
  emits('update:modelValue', result)
  
  // 延迟触发size事件,确保所有图片都加载完成
  Promise.all(files.map(f => {
    const src = f.url || (f.raw ? URL.createObjectURL(f.raw) : '')
    return src ? getImageSize(src) : Promise.resolve({ width: 0, height: 0 })
  }))
    .then(allSizes => emits('size', allSizes))
}

function onRemove(_file: any, files: any[]) {
  const urls: string[] = []
  
  files.forEach((f: any) => {
    const url = f.url || (f.raw ? URL.createObjectURL(f.raw) : '')
    if (url) {
      urls.push(url)
    }
  })
  
  internalList.value = files.map((f: any, index: number) => {
    const url = f.url || (f.raw ? URL.createObjectURL(f.raw) : '')
    return { name: `image-${index}`, url } as any
  })
  
  // 根据multiple属性决定返回格式
  const result = props.multiple ? urls : (urls.length === 0 ? '' : (urls.length === 1 ? urls[0] : urls))
  emits('update:modelValue', result)
}

function onExceed(_files: any[]) {
  ElMessage.warning(`最多只能上传 ${limit} 张图片`)
}

function getImageSize(src: string): Promise<{ width: number; height: number }> {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
    img.src = src
  })
}
</script>

<style scoped lang="scss">
.upload-multiple {
  display: inline-flex;
}
</style>