<template>
|
<div class="photo-capture">
|
<div v-if="modelValue" class="photo-box">
|
<el-image :src="modelValue" :preview-src-list="[modelValue]" fit="cover" class="photo-img" />
|
<div class="photo-remove" @click.stop="remove">
|
<el-icon><Close /></el-icon>
|
</div>
|
</div>
|
<div v-else class="photo-box photo-empty" @click="pick">
|
<el-icon class="photo-icon"><Camera /></el-icon>
|
<span class="photo-text">{{ placeholder }}</span>
|
</div>
|
<input
|
ref="inputRef"
|
type="file"
|
accept="image/*"
|
capture="environment"
|
class="photo-input"
|
@change="onChange"
|
/>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref } from "vue";
|
import axios from "axios";
|
import { ElMessage } from "element-plus";
|
import { Camera, Close } from "@element-plus/icons-vue";
|
import { getToken } from "@/utils/auth";
|
|
defineOptions({ name: "PhotoCapture" });
|
|
const props = defineProps({
|
modelValue: { type: String, default: "" },
|
placeholder: { type: String, default: "拍照 / 上传" },
|
});
|
|
const emit = defineEmits(["update:modelValue"]);
|
|
const inputRef = ref();
|
const loading = ref(false);
|
const MAX_SIZE = 10 * 1024 * 1024;
|
|
const pick = () => {
|
if (loading.value) return;
|
inputRef.value && inputRef.value.click();
|
};
|
|
const remove = () => {
|
emit("update:modelValue", "");
|
};
|
|
/**
|
* 上传前压缩图片:按最大宽度等比缩放 + JPEG 质量压缩
|
* 手机原图 2-5MB → 通常 200-500KB,避免被 nginx 的 client_max_body_size 拦截
|
*/
|
const compressImage = (file, maxWidth = 1280, quality = 0.75) =>
|
new Promise((resolve) => {
|
// 非图片或环境不支持时直接返回原文件
|
if (!/^image\//.test(file.type) || typeof document === "undefined") {
|
resolve(file);
|
return;
|
}
|
const objectUrl = URL.createObjectURL(file);
|
const img = new Image();
|
img.onload = () => {
|
URL.revokeObjectURL(objectUrl);
|
let width = img.naturalWidth || img.width;
|
let height = img.naturalHeight || img.height;
|
if (!width || !height) {
|
resolve(file);
|
return;
|
}
|
if (width > maxWidth) {
|
height = Math.round((height * maxWidth) / width);
|
width = maxWidth;
|
}
|
const canvas = document.createElement("canvas");
|
canvas.width = width;
|
canvas.height = height;
|
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
|
canvas.toBlob(
|
(blob) => resolve(blob || file),
|
"image/jpeg",
|
quality
|
);
|
};
|
img.onerror = () => {
|
URL.revokeObjectURL(objectUrl);
|
resolve(file);
|
};
|
img.src = objectUrl;
|
});
|
|
const onChange = async (e) => {
|
const file = e.target.files && e.target.files[0];
|
e.target.value = "";
|
if (!file) return;
|
if (!/^image\//.test(file.type)) {
|
ElMessage.warning("请选择图片文件");
|
return;
|
}
|
if (file.size > MAX_SIZE) {
|
ElMessage.warning("图片不能超过 10MB,请压缩后重试");
|
return;
|
}
|
loading.value = true;
|
try {
|
// 上传前先压缩:手机原图通常 2-5MB,压到 1280px 宽、质量 0.75 后一般 200-500KB
|
// 既省手机流量,也避免被 nginx 的 client_max_body_size 拦住
|
const compressed = await compressImage(file);
|
const fd = new FormData();
|
fd.append("file", compressed, file.name || "photo.jpg");
|
const res = await axios.post(import.meta.env.VITE_APP_BASE_API + "/common/upload", fd, {
|
headers: {
|
Authorization: "Bearer " + getToken(),
|
"Content-Type": "multipart/form-data",
|
},
|
});
|
const body = res && res.data;
|
if (body && body.code === 200) {
|
// 只保留相对路径:后端返回的 url 带 host(如 http://10.136.58.73:7003/profile/...),
|
// 手机经端口转发或外网访问时该 host 不通,必须用 /profile/... 相对路径
|
let path = body.fileName || body.url || "";
|
if (/^https?:\/\//i.test(path)) {
|
try {
|
path = new URL(path).pathname;
|
} catch (e) {
|
// 解析失败就保持原值
|
}
|
}
|
emit("update:modelValue", path);
|
ElMessage.success("上传成功");
|
} else {
|
ElMessage.error((body && body.msg) || "上传失败");
|
}
|
} catch (err) {
|
const status = err && err.response ? err.response.status : 0;
|
if (status === 413) {
|
ElMessage.error("照片太大,上传被服务器拒绝,请压缩后重试");
|
} else if (status === 401) {
|
ElMessage.error("登录已过期,请重新登录后再上传");
|
} else if (status === 415) {
|
ElMessage.error("文件格式不支持,请选择图片文件");
|
} else if (status) {
|
ElMessage.error("上传失败(HTTP " + status + "),请重试");
|
} else {
|
ElMessage.error("上传失败,请检查网络后重试");
|
}
|
} finally {
|
loading.value = false;
|
}
|
};
|
</script>
|
|
<style scoped lang="scss">
|
.photo-capture {
|
width: 100%;
|
}
|
.photo-box {
|
position: relative;
|
width: 100%;
|
height: 140px;
|
border-radius: 6px;
|
overflow: hidden;
|
border: 1px solid #dcdfe6;
|
background: #fafafa;
|
box-sizing: border-box;
|
}
|
.photo-empty {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
border-style: dashed;
|
cursor: pointer;
|
color: #909399;
|
|
&:active {
|
background: #f0f2f5;
|
}
|
}
|
.photo-icon {
|
font-size: 30px;
|
margin-bottom: 6px;
|
}
|
.photo-text {
|
font-size: 13px;
|
}
|
.photo-img {
|
width: 100%;
|
height: 140px;
|
display: block;
|
}
|
.photo-remove {
|
position: absolute;
|
top: 4px;
|
right: 4px;
|
width: 24px;
|
height: 24px;
|
border-radius: 50%;
|
background: rgba(0, 0, 0, 0.55);
|
color: #fff;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
font-size: 13px;
|
}
|
.photo-input {
|
display: none;
|
}
|
</style>
|