<script setup lang="tsx">
|
import { onMounted, ref } from 'vue'
|
import {
|
NImage,
|
NSpin,
|
NUpload,
|
NUploadDragger,
|
useDialog,
|
useMessage,
|
} from 'naive-ui'
|
import type { ImageInst, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
|
import { uploadFile } from '@/service/api/common'
|
import {
|
checkVehicleMaterialReference,
|
deleteVehicleMaterial,
|
getVehicleMaterialList,
|
uploadVehicleMaterial,
|
} from '@/service/api/vehicle'
|
import { useBoolean } from '@/hooks'
|
import { downloadFile } from '@/utils'
|
import iconPreview from '@/assets/element-img/icon-perview.png'
|
import iconAdd from '@/assets/element-img/icon-add.png'
|
import iconDelete from '@/assets/element-img/icon-delete.png'
|
import iconDownload from '@/assets/element-img/icon-download.png'
|
|
const message = useMessage()
|
const dialog = useDialog()
|
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
|
|
interface MaterialItem {
|
id: string | number
|
fileId: string | number
|
fileUrl: string
|
fileName?: string
|
}
|
|
const listData = ref<MaterialItem[]>([])
|
|
function getImageSize(file: File) {
|
return new Promise<{ width: number, height: number }>((resolve, reject) => {
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
const image = new Image()
|
image.onload = () => {
|
resolve({
|
width: image.width,
|
height: image.height,
|
})
|
}
|
image.onerror = () => reject(new Error('图片尺寸读取失败'))
|
image.src = String(reader.result || '')
|
}
|
|
reader.onerror = () => reject(new Error('图片文件读取失败'))
|
reader.readAsDataURL(file)
|
})
|
}
|
|
// 获取列表
|
async function getList() {
|
startLoading()
|
try {
|
const res = await getVehicleMaterialList()
|
if (res.code === 200 || res.code === 0) {
|
listData.value = Array.isArray(res.data) ? res.data : []
|
}
|
}
|
catch (error) {
|
console.error('获取车辆素材失败:', error)
|
listData.value = []
|
}
|
finally {
|
endLoading()
|
}
|
}
|
|
onMounted(() => {
|
getList()
|
})
|
|
// 上传校验
|
async function beforeUpload(data: { file: UploadFileInfo, fileList: UploadFileInfo[] }) {
|
const file = data.file.file
|
if (!file)
|
return false
|
|
// 格式校验
|
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']
|
if (!validTypes.includes(file.type)) {
|
message.error('只能上传 JPG/PNG/WebP 格式的图片')
|
return false
|
}
|
|
// 大小校验
|
const maxSize = 2 * 1024 * 1024 // 2MB
|
if (file.size > maxSize) {
|
message.error('图片大小不能超过 2MB')
|
return false
|
}
|
|
try {
|
// 上传前校验图片实际宽高,只允许 450 * 210 的素材图。
|
const { width, height } = await getImageSize(file)
|
if (width !== 450 || height !== 210) {
|
message.error('请上传尺寸为 450*210,比例为 15:7 的图片')
|
return false
|
}
|
}
|
catch (error) {
|
console.error('图片尺寸校验失败:', error)
|
message.error('图片尺寸校验失败,请重新选择图片')
|
return false
|
}
|
|
return true
|
}
|
|
// 正在上传的文件 ID 列表,用于显示 loading 状态
|
const uploadingIds = ref<Set<string>>(new Set())
|
|
// 自定义上传
|
async function handleUpload({ file, onFinish, onError }: UploadCustomRequestOptions) {
|
if (!file.file)
|
return
|
const fileId = file.id
|
uploadingIds.value.add(fileId)
|
|
const formData = new FormData()
|
formData.append('file', file.file)
|
|
try {
|
// 先上传到 OSS,拿到文件基础信息
|
const uploadRes = await uploadFile(formData)
|
if ((uploadRes.code !== 200 && uploadRes.code !== 0) || !uploadRes.data) {
|
onError()
|
return
|
}
|
|
// OSS 上传成功后,再调用业务接口保存车辆素材
|
const saveRes = await uploadVehicleMaterial([{
|
id: uploadRes.data.id,
|
name: uploadRes.data.name,
|
url: uploadRes.data.url,
|
}])
|
|
if (saveRes.code === 200 || saveRes.code === 0) {
|
message.success('上传成功')
|
onFinish()
|
return
|
}
|
onError()
|
}
|
catch (error) {
|
console.error('上传车辆素材失败:', error)
|
onError()
|
}
|
finally {
|
uploadingIds.value.delete(fileId)
|
// 支持多选上传,所有文件处理完成后统一刷新列表
|
if (uploadingIds.value.size === 0) {
|
getList()
|
}
|
}
|
}
|
|
// 图片操作
|
const imageRef = ref<ImageInst>()
|
// 预览图片
|
function handlePreview(index: number) {
|
if (Array.isArray(imageRef.value) && imageRef.value[index]) {
|
imageRef.value[index].showPreview()
|
}
|
}
|
|
// 下载
|
async function handleDownload(item: MaterialItem) {
|
// 获取图片 blob 并下载
|
const response = await fetch(item.fileUrl)
|
const blob = await response.blob()
|
const filename = item.fileName || `vehicle_cover_${new Date().getTime()}.png`
|
downloadFile(blob, filename)
|
}
|
|
// 删除
|
async function handleDelete(item: MaterialItem) {
|
try {
|
// 检查是否被引用
|
const checkRes = await checkVehicleMaterialReference(item.id)
|
const refCount = checkRes.data || 0
|
|
let content = '确认删除该图片?此操作不可恢复。'
|
if (refCount > 0) {
|
content = `该图片已被 ${refCount} 辆车辆使用,删除后将影响车辆展示,是否确认删除?`
|
}
|
|
dialog.warning({
|
title: '删除确认',
|
content,
|
positiveText: '确认',
|
negativeText: '取消',
|
onPositiveClick: async () => {
|
const delRes = await deleteVehicleMaterial(item.id)
|
if (delRes.code === 200 || delRes.code === 0) {
|
message.success('删除成功')
|
getList()
|
}
|
},
|
})
|
}
|
catch (error) {
|
console.error('删除图片校验失败', error)
|
// fallback directly to delete if check fails
|
dialog.warning({
|
title: '删除确认',
|
content: '确认删除该图片?此操作不可恢复。',
|
positiveText: '确认',
|
negativeText: '取消',
|
onPositiveClick: async () => {
|
const delRes = await deleteVehicleMaterial(item.id)
|
if (delRes.code === 200 || delRes.code === 0) {
|
message.success('删除成功')
|
getList()
|
}
|
},
|
})
|
}
|
}
|
</script>
|
|
<template>
|
<div class="roboTaxi w-full h-full flex flex-col">
|
<div class="box">
|
<div class="head">
|
车辆封面图
|
</div>
|
|
<div class="material-container flex-1 mt-16px">
|
<NSpin :show="loading">
|
<div class="grid-layout">
|
<!-- 上传按钮 -->
|
<div class="grid-item upload-item">
|
<NUpload
|
multiple accept=".jpg,.jpeg,.png,.webp" :custom-request="handleUpload"
|
:before-upload="beforeUpload" :show-file-list="false"
|
>
|
<NUploadDragger class="upload-dragger">
|
<div class="flex flex-col items-center justify-center w-full h-full">
|
<img :src="iconAdd" class="w-24px h-24px">
|
<div class="mt-12px text-12px" style="color: rgba(23, 31, 38, 0.6);">
|
点击上传图片
|
</div>
|
<div class="mt-4px text-12px" style="color: rgba(23, 31, 38, 0.4);">
|
尺寸需为 450*210
|
</div>
|
</div>
|
</NUploadDragger>
|
</NUpload>
|
</div>
|
|
<!-- 图片列表 -->
|
<div v-for="(item, index) in listData" :key="item.id" class="grid-item image-item">
|
<NImage
|
ref="imageRef" :src="item.fileUrl" object-fit="cover" class="w-full h-full"
|
fallback-src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png"
|
/>
|
|
<!-- 悬浮操作层 -->
|
<div class="overlay">
|
<div class="action-btn" title="预览" style="position: relative;" @click="handlePreview(index)">
|
<img :src="iconPreview" class="w-32px h-32px">
|
</div>
|
<div class="action-btn" title="下载" @click="handleDownload(item)">
|
<img :src="iconDownload" class="w-32px h-32px">
|
</div>
|
<div class="action-btn" title="删除" @click="handleDelete(item)">
|
<img :src="iconDelete" class="w-32px h-32px">
|
</div>
|
</div>
|
</div>
|
</div>
|
</NSpin>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style scoped lang="scss">
|
.box {
|
display: flex;
|
flex: 1;
|
flex-direction: column;
|
min-height: 0;
|
overflow: hidden;
|
padding: 24px;
|
background-color: #fff;
|
border-radius: 6px;
|
width: 100%;
|
box-sizing: border-box;
|
--n-label-text-color: rgba(23, 31, 38, 0.8);
|
}
|
|
.title {
|
font-size: 20px;
|
color: #171f26;
|
font-weight: 600;
|
}
|
|
.head {
|
font-family: PingFang SC;
|
font-size: 16px;
|
font-weight: 600;
|
line-height: 24px;
|
letter-spacing: normal;
|
color: #171f26;
|
position: relative;
|
text-indent: 11px;
|
margin-bottom: 16px;
|
|
&::before {
|
content: "";
|
position: absolute;
|
top: 50%;
|
left: 0;
|
transform: translateY(-50%);
|
width: 3px;
|
height: 14px;
|
z-index: 0;
|
border-radius: 3px;
|
background: #0068ff;
|
}
|
}
|
|
.material-container {
|
flex: 1;
|
min-height: 0;
|
overflow-y: auto;
|
|
&::-webkit-scrollbar {
|
width: 6px;
|
}
|
|
&::-webkit-scrollbar-track {
|
background: transparent;
|
}
|
|
&::-webkit-scrollbar-thumb {
|
border-radius: 999px;
|
background: rgba(23, 31, 38, 0.16);
|
}
|
|
&::-webkit-scrollbar-thumb:hover {
|
background: rgba(23, 31, 38, 0.24);
|
}
|
|
scrollbar-width: thin;
|
scrollbar-color: rgba(23, 31, 38, 0.16) transparent;
|
}
|
|
.grid-layout {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 16px 22px;
|
}
|
|
.grid-item {
|
overflow: hidden;
|
position: relative;
|
background-color: #f7f9fa;
|
width: 120px;
|
height: 120px;
|
border-radius: 4px;
|
}
|
|
.upload-item {
|
transition: border-color 0.3s;
|
width: 120px;
|
height: 120px;
|
border-radius: 4px;
|
opacity: 1;
|
background: rgba(244, 246, 248, 0.6008);
|
box-sizing: border-box;
|
border: 1px solid rgba(23, 31, 38, 0.15);
|
|
&:hover {
|
border-color: #0068FF;
|
}
|
|
:deep(.n-upload),
|
:deep(.n-upload-dragger) {
|
width: 100%;
|
height: 100%;
|
border: none;
|
background: transparent;
|
padding: 0;
|
.n-upload-trigger {
|
width: 100%;
|
height: 100%;
|
}
|
}
|
}
|
|
.image-item {
|
&:hover .overlay {
|
opacity: 1;
|
}
|
}
|
|
.overlay {
|
position: absolute;
|
top: 0;
|
left: 0;
|
width: 100%;
|
height: 100%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
opacity: 0;
|
box-sizing: border-box;
|
transition: opacity 0.3s ease;
|
}
|
|
.action-btn {
|
width: 32px;
|
height: 32px;
|
border-radius: 50%;
|
color: #fff;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
transition: all 0.3s ease;
|
}
|
</style>
|