- 新增模板详情页面,支持上传PDF/图片作为底稿,并生成可拖拽调整的区域 - 新增模板类型定义、上传钩子及画布组件,支持批量生成区域、旋转、对齐参考线等功能 - 更新模板列表页,适配新的API接口并增加删除功能 - 更新OSS工具函数,修复上传凭证处理逻辑 - 更新公共类型定义,调整分页查询返回结构 - 移除旧的书籍相关API文件,替换为模板API - 更新依赖项,添加pdfjs-dist、vue3-draggable-resizable等库 - 隐藏页面底部,优化界面显示
489 lines
18 KiB
Vue
489 lines
18 KiB
Vue
<!-- eslint-disable no-console -->
|
||
<script setup lang="ts"
|
||
generic="K extends string | string[] | Record<string, unknown> | Record<string, unknown>[] | null | undefined"
|
||
>
|
||
import type OSS from 'ali-oss'
|
||
import type { UploadFileInfo } from 'naive-ui'
|
||
import type { PropType, Ref } from 'vue'
|
||
import { useAutoAnimate } from '@formkit/auto-animate/vue'
|
||
import { computed, ref, shallowRef, watch } from 'vue'
|
||
import { getAliOssTokenAxios } from '@/service/api/upload'
|
||
import { getOnlyId } from '@/utils/data'
|
||
import { browserPathJoin } from '@/utils/date'
|
||
import { fileToBase64, getFirstFrameOfVideo } from '@/utils/file'
|
||
import { initOSSClient, uploadFileToOSS } from '@/utils/oss'
|
||
import { FileUploadService } from '@/utils/upload-util'
|
||
import { isObject } from '@/utils/verify'
|
||
import { verifyAddIntRoZeroNumb } from '@/utils/verify-number'
|
||
|
||
export interface FileItem {
|
||
/** 唯一 ID */
|
||
__id: number | string
|
||
/** 浏览器中的文件对象 */
|
||
file?: File
|
||
/** 进度 */
|
||
percentage: number
|
||
/** 当前 index */
|
||
index: number
|
||
/** 上传状态; 0:正在上传; 1:上传成功; -1:上传失败; */
|
||
uploadStatus: -1 | 0 | 1
|
||
/** 错误原因 */
|
||
errMsg?: string
|
||
/** url */
|
||
url: string
|
||
/** 海报 */
|
||
poster: string
|
||
}
|
||
|
||
export interface CustomKeys {
|
||
/** 绑定上传文件链接的 key(即modelValue为对象或对象数组时绑定的上传文件链接的key) */
|
||
bindFileUrlKey?: string
|
||
/** 绑定返回海报链接的 key(即modelValue为对象或对象数组时绑定的海报链接的key) */
|
||
bindPosterUrlKey?: string
|
||
/** 上传成功后服务器返回链接中文件链接的 key */
|
||
upFileUrlKey?: string
|
||
/** 上传成功后服务器返回海报字段的的 key */
|
||
upPosterUrlKey?: string
|
||
}
|
||
|
||
const props = defineProps({
|
||
/** 上传图片的URL */
|
||
path: { type: String, default: undefined },
|
||
/** 自定义 key */
|
||
customKeys: { type: Object as PropType<CustomKeys>, default: (): CustomKeys => ({}) },
|
||
/** 提示文字 */
|
||
hint: { type: String, default: '点击上传' },
|
||
/** 图片对象数组 */
|
||
modelValue: { type: [Array, String, Object, null, undefined] as PropType<K>, default: () => [] },
|
||
/** 宽度 */
|
||
width: { type: [String, Number], default: 100 },
|
||
/** 高度 */
|
||
height: { type: [String, Number], default: 100 },
|
||
/** 最多上传几张图片 */
|
||
limit: { type: Number, default: 1 },
|
||
/** 限制可以选择的文件类型(根据后缀限制) 如:['.png', 'jpg'] */
|
||
accept: { type: Array as PropType<`.${string}`[]>, default: () => ['.png', '.jpg'] },
|
||
/** 加载状态 */
|
||
loading: { type: Boolean, default: false },
|
||
/** 选择图片完成后的回调 */
|
||
afterSelectFile: { type: Function as PropType<(data: File[]) => File[] | Promise<File[]>>, default: undefined },
|
||
/** 上传成功后的回调 */
|
||
afterUpload: { type: Function as PropType<(data: OSS.MultipartUploadResult) => Record<string, unknown> | string>, default: undefined },
|
||
/** 限制视频最大允许上传多少 kb, */
|
||
maxVideoSize: { type: Number, default: undefined },
|
||
/** 限制图片最小允许上传多少 kb,默认 1 */
|
||
minImageSize: { type: Number, default: 1 },
|
||
/** 限制图片最大允许上传多少 kb,默认 800 */
|
||
maxImageSize: { type: Number, default: undefined },
|
||
})
|
||
const emit = defineEmits<{
|
||
(e: 'change', list: FileItem[]): void
|
||
(e: 'update:loading', state: boolean): void
|
||
(e: 'update:modelValue', list: any): void
|
||
(e: 'error', myFile: FileItem): void
|
||
}>()
|
||
|
||
const [parent] = useAutoAnimate()
|
||
const upLoading = ref(props.loading)
|
||
|
||
// 视频预览状态
|
||
const showVideoPreview = ref(false)
|
||
const videoPreviewSrc = ref('')
|
||
const videoPreviewPoster = ref('')
|
||
|
||
const bindFileUrlKey = computed(() => props.customKeys.bindFileUrlKey || 'url')
|
||
const bindPosterUrlKey = computed(() => props.customKeys.bindPosterUrlKey || 'frameUrl')
|
||
const upFileUrlKey = computed(() => props.customKeys.upFileUrlKey || 'url')
|
||
const upPosterUrlKey = computed(() => props.customKeys.upPosterUrlKey || 'frameUrl')
|
||
|
||
const newWidth = computed(() => (verifyAddIntRoZeroNumb(`${props.width}`) || typeof props.width === 'number' ? `${props.width}px` : props.width))
|
||
const newHeight = computed(() => (verifyAddIntRoZeroNumb(`${props.height}`) || typeof props.height === 'number' ? `${props.height}px` : props.height))
|
||
|
||
/** 全部的文件(包含上传和未上传的) */
|
||
const fileList: Ref<FileItem[]> = ref(getDefFiles(props.modelValue))
|
||
/** 得到上传成功的文件(包含本次上传成功和之前上传成功的) */
|
||
const uploadedList = shallowRef<Record<string, string>[] | string[]>(getUploadedList())
|
||
/** 最终需要得到文件(根据是多选还是单选整理成需要的文件) */
|
||
const uploadedFiles = computed(() => (props.limit > 1 ? (uploadedList.value as K) : (uploadedList.value[0] as K)))
|
||
/** 现在还可以添加多少个 */
|
||
const efficientNumb = computed(() => props.limit - uploadedList.value.length - fileList.value.length)
|
||
|
||
// NUpload 文件列表,用于选择后清除
|
||
const nUploadFileList = ref<UploadFileInfo[]>([])
|
||
|
||
watch(
|
||
() => props.modelValue,
|
||
(val) => {
|
||
fileList.value = getDefFiles(val)
|
||
},
|
||
)
|
||
watch(
|
||
() => upLoading.value,
|
||
(val) => {
|
||
emit('update:loading', val)
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => uploadedFiles.value,
|
||
(val) => {
|
||
emit('update:modelValue', val)
|
||
},
|
||
)
|
||
|
||
/**
|
||
* 视频预览
|
||
*/
|
||
function previewVideoClick(src: string, poster: string) {
|
||
if (src) {
|
||
videoPreviewSrc.value = src
|
||
videoPreviewPoster.value = poster
|
||
showVideoPreview.value = true
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除 (已经上传的)
|
||
*/
|
||
function deleteClick(index: number) {
|
||
fileList.value.splice(index, 1)
|
||
emit('change', fileList.value)
|
||
uploadedList.value = getUploadedList()
|
||
}
|
||
/**
|
||
* 重新上传
|
||
*/
|
||
async function reUploadClick(index: number) {
|
||
try {
|
||
upLoading.value = true
|
||
await uploadFile(fileList.value[index]!)
|
||
upLoading.value = false
|
||
emit('change', fileList.value)
|
||
uploadedList.value = getUploadedList()
|
||
}
|
||
catch (error) {
|
||
upLoading.value = false
|
||
uploadedList.value = getUploadedList()
|
||
// eslint-disable-next-line no-console
|
||
console.log('重新上传失败====', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重置
|
||
*/
|
||
function reset() {
|
||
fileList.value = []
|
||
uploadedList.value = []
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
function handleUploadChange(options: { fileList: UploadFileInfo[] }) {
|
||
// 获取新添加的文件
|
||
const newFiles = options.fileList.map(item => item.file).filter(Boolean) as File[]
|
||
|
||
// 清除 NUpload 列表以避免重复并允许重新选择
|
||
nUploadFileList.value = []
|
||
|
||
if (newFiles.length > 0) {
|
||
uploadFileList(newFiles)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理上传所有图片
|
||
*/
|
||
async function uploadFileList(files: File[] | null) {
|
||
try {
|
||
if (files && files.length > 0) {
|
||
let processFiles = files
|
||
if (props.afterSelectFile) {
|
||
processFiles = await props.afterSelectFile(files)
|
||
}
|
||
|
||
const promiseArr: FileItem[] = []
|
||
// 使用临时列表以避免循环期间 fileList 发生变化的问题(尽管 push 是安全的)
|
||
// 但我们需要将其推入 fileList 以立即显示它们
|
||
for (let i = 0, len = processFiles.length; i < len; i++) {
|
||
if (efficientNumb.value > 0 && processFiles[i]) {
|
||
const newItem: FileItem = {
|
||
file: processFiles[i]!,
|
||
percentage: 0,
|
||
uploadStatus: 0,
|
||
index: fileList.value.length,
|
||
__id: getOnlyId(),
|
||
url: '',
|
||
poster: '',
|
||
}
|
||
fileList.value.push(newItem)
|
||
// 必须获取响应式对象,否则进度条不会更新
|
||
promiseArr.push(fileList.value[fileList.value.length - 1])
|
||
}
|
||
}
|
||
|
||
if (promiseArr.length > 0) {
|
||
upLoading.value = true
|
||
const uploadService = new FileUploadService(uploadFile, 5)
|
||
await uploadService.upload(promiseArr)
|
||
upLoading.value = false
|
||
emit('change', fileList.value)
|
||
uploadedList.value = getUploadedList()
|
||
}
|
||
else {
|
||
window.$message?.warning('暂无可上传文件或已达上限')
|
||
}
|
||
}
|
||
}
|
||
catch (error) {
|
||
uploadedList.value = getUploadedList()
|
||
window.$message?.error('上传失败')
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
async function uploadFile(myFile: FileItem): Promise<FileItem> {
|
||
try {
|
||
const fileSize = myFile.file!.size / 1024
|
||
if (myFile.file!.type.includes('image')) {
|
||
if (typeof props.maxImageSize === 'number' && fileSize > props.maxImageSize) {
|
||
const msg = getRestrictSizeMsg(props.maxImageSize, 'max')
|
||
myFile.uploadStatus = -1
|
||
myFile.errMsg = msg
|
||
return Promise.reject(new Error(msg))
|
||
}
|
||
else if (fileSize < props.minImageSize) {
|
||
const msg = getRestrictSizeMsg(props.minImageSize, 'min')
|
||
myFile.uploadStatus = -1
|
||
myFile.errMsg = msg
|
||
return Promise.reject(new Error(msg))
|
||
}
|
||
}
|
||
if (typeof props.maxVideoSize === 'number' && myFile.file!.type.includes('video') && fileSize > props.maxVideoSize) {
|
||
const msg = getRestrictSizeMsg(props.maxVideoSize, 'max')
|
||
myFile.uploadStatus = -1
|
||
myFile.errMsg = msg
|
||
return Promise.reject(new Error(msg))
|
||
}
|
||
const { data: tokenData, error: tokenError } = await getAliOssTokenAxios()
|
||
if (tokenError || !tokenData) {
|
||
throw new Error('获取上传凭证失败')
|
||
}
|
||
// 初始化OSS客户端
|
||
const client = initOSSClient(tokenData?.data || {})
|
||
let path = typeof props.path === 'string' ? '' : `temp/${Date.now()}`
|
||
path = browserPathJoin(path, myFile.file!.name)
|
||
// 上传文件
|
||
const res: OSS.MultipartUploadResult = await uploadFileToOSS(client, myFile.file!, path, (progress) => {
|
||
myFile.percentage = Math.floor((progress || 0) * 10000) / 100
|
||
})
|
||
const url = browserPathJoin(import.meta.env.VITE_BASE_OSS_URL, path)
|
||
const data = typeof props.afterUpload === 'function' ? props.afterUpload(res) : res
|
||
if (isObject(data)) {
|
||
const newData = data as Record<string, string>
|
||
myFile.url = upFileUrlKey.value in newData ? String(newData[upFileUrlKey.value] || url) : url
|
||
myFile.poster = upPosterUrlKey.value in newData ? String(newData[upPosterUrlKey.value] || '') : ''
|
||
if (myFile.file!.type.includes('video') && !myFile.poster) {
|
||
try {
|
||
const info = await getFirstFrameOfVideo(myFile.file!)
|
||
myFile.poster = (await fileToBase64(info.firstFrame)).img || ''
|
||
}
|
||
catch (error) {
|
||
// eslint-disable-next-line no-console
|
||
console.log('error====', error)
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
myFile.url = url
|
||
}
|
||
myFile.uploadStatus = 1
|
||
return Promise.resolve(myFile)
|
||
}
|
||
catch (error: any) {
|
||
console.log('上传错误====', error)
|
||
myFile.uploadStatus = -1
|
||
myFile.errMsg = typeof error.msg === 'string' ? error.msg : '上传失败'
|
||
emit('error', error)
|
||
return Promise.reject(new Error(myFile.errMsg))
|
||
}
|
||
}
|
||
|
||
/** 得到初始已上传的文件 */
|
||
function getDefFiles(data: unknown): FileItem[] {
|
||
const val: unknown[] = Array.isArray(data) ? data : data ? [data] : []
|
||
const newVal: FileItem[] = val.map((item, index: number) => {
|
||
const _item: FileItem = isObject(item)
|
||
? {
|
||
url: String(item[bindFileUrlKey.value] || ''),
|
||
poster: String(item[bindPosterUrlKey.value] || ''),
|
||
uploadStatus: 1,
|
||
index,
|
||
__id: getOnlyId(),
|
||
percentage: 0,
|
||
}
|
||
: { url: String(item), poster: '', uploadStatus: 1, index, __id: getOnlyId(), percentage: 0 }
|
||
return _item
|
||
})
|
||
return newVal
|
||
}
|
||
/** 得到已上传成功的文件 */
|
||
function getUploadedList() {
|
||
const list = fileList.value.filter(item => item.uploadStatus === 1 && item.url)
|
||
// 不能用 bindFileUrlKey 代替
|
||
if (props.customKeys.bindFileUrlKey) {
|
||
return list.map((item) => {
|
||
const obj: Record<string, string> = {}
|
||
obj[bindFileUrlKey.value] = item.url
|
||
obj[bindPosterUrlKey.value] = item.poster
|
||
return obj
|
||
})
|
||
}
|
||
else {
|
||
return list.map(item => item.url)
|
||
}
|
||
}
|
||
/** 得到不符合大小限制的提示语 */
|
||
function getRestrictSizeMsg(maxSize: number, type: 'max' | 'min') {
|
||
const maxSizeText = maxSize >= 1024 ? `${Number((maxSize / 1024).toFixed(2))}M` : `${maxSize}KB`
|
||
return type === 'max' ? `文件大小不能超过${maxSizeText}` : `文件大小不能小于${maxSizeText}`
|
||
}
|
||
|
||
defineExpose({ reset, getLoadState: () => upLoading.value })
|
||
</script>
|
||
|
||
<template>
|
||
<div class="rest-upload-container">
|
||
<NImageGroup>
|
||
<div ref="parent" class="flex flex-wrap gap-4">
|
||
<!-- 文件列表 -->
|
||
<div
|
||
v-for="(item, index) in fileList" :key="item.__id"
|
||
class="group relative overflow-hidden border border-gray-200 rounded-lg transition-all duration-300 hover:border-primary"
|
||
:style="{ width: newWidth, height: newHeight }"
|
||
>
|
||
<!-- 图片预览 -->
|
||
<div
|
||
v-if="!item.url.includes('.mp4') && !item.url.includes('.webm') && (item.file?.type?.includes('image') || !item.file)"
|
||
class="h-full w-full flex items-center justify-center bg-gray-50"
|
||
>
|
||
<NImage
|
||
:src="item.url" class="h-full w-full flex items-center justify-center"
|
||
:img-props="{ style: { width: '100%', height: '100%', objectFit: 'contain' } }" object-fit="cover"
|
||
>
|
||
<template #error>
|
||
<div class="h-full w-full flex items-center justify-center bg-gray-50 text-gray-400">
|
||
<SvgIcon icon="mdi:image-broken-variant" class="text-2xl" />
|
||
</div>
|
||
</template>
|
||
</NImage>
|
||
</div>
|
||
|
||
<!-- 视频预览 -->
|
||
<div
|
||
v-else class="h-full w-full flex items-center justify-center bg-black bg-cover bg-center bg-no-repeat"
|
||
:style="{ backgroundImage: item.poster ? `url(${item.poster})` : undefined }"
|
||
>
|
||
<SvgIcon icon="mdi:play-circle-outline" class="text-4xl text-white opacity-80" />
|
||
</div>
|
||
|
||
<!-- 进度覆盖层 -->
|
||
<div
|
||
v-if="item.uploadStatus === 0"
|
||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/50 p-1"
|
||
>
|
||
<NProgress
|
||
type="circle" :percentage="item.percentage" :color="{ stops: ['#E3F2FD', '#2080f0'] }"
|
||
style="width: 60px; height: 60px"
|
||
>
|
||
<template #default>
|
||
<span class="text-xs text-white">{{ item.percentage }}%</span>
|
||
</template>
|
||
</NProgress>
|
||
</div>
|
||
|
||
<!-- 错误覆盖层 -->
|
||
<div
|
||
v-if="item.uploadStatus === -1"
|
||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-red-50/90 p-2 text-center"
|
||
>
|
||
<span class="mb-1 text-xs text-red-500">{{ item.errMsg || '上传失败' }}</span>
|
||
<NButton size="tiny" type="error" ghost @click="reUploadClick(index)">
|
||
重试
|
||
</NButton>
|
||
<div class="absolute right-1 top-1 cursor-pointer" @click="deleteClick(index)">
|
||
<SvgIcon icon="mdi:close" class="text-red-500" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作覆盖层(悬停) -->
|
||
<div
|
||
v-if="item.uploadStatus === 1"
|
||
class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black/0 opacity-0 transition-all duration-300 group-hover:bg-black/50 group-hover:opacity-100"
|
||
>
|
||
<NButton
|
||
v-if="item.url.includes('.mp4') || item.url.includes('.webm') || item.file?.type?.includes('video')"
|
||
circle secondary type="info" class="pointer-events-auto"
|
||
@click="previewVideoClick(item.url, item.poster)"
|
||
>
|
||
<template #icon>
|
||
<SvgIcon icon="mdi:eye" />
|
||
</template>
|
||
</NButton>
|
||
<NButton v-else circle secondary type="info" class="pointer-events-none">
|
||
<template #icon>
|
||
<SvgIcon icon="mdi:eye" />
|
||
</template>
|
||
</NButton>
|
||
|
||
<NButton circle secondary type="error" class="pointer-events-auto" @click="deleteClick(index)">
|
||
<template #icon>
|
||
<SvgIcon icon="mdi:delete" />
|
||
</template>
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上传触发器 -->
|
||
<div v-if="efficientNumb > 0" :style="{ width: newWidth, height: newHeight }">
|
||
<NUpload
|
||
:accept="accept.join(',')" :multiple="limit > 1" :max="limit" :show-file-list="false"
|
||
:file-list="nUploadFileList" :default-upload="false" class="h-full w-full" @change="handleUploadChange"
|
||
>
|
||
<template #default>
|
||
<div
|
||
class="h-full w-full flex flex-col cursor-pointer items-center justify-center border border-gray-300 rounded-lg border-dashed bg-gray-50 transition-colors hover:border-primary hover:bg-primary-50"
|
||
:style="{ width: newWidth, height: newHeight }"
|
||
>
|
||
<SvgIcon icon="mdi:plus" class="text-3xl text-gray-400" />
|
||
<span class="mt-1 text-xs text-gray-400">{{ hint }}</span>
|
||
</div>
|
||
</template>
|
||
</NUpload>
|
||
</div>
|
||
</div>
|
||
</NImageGroup>
|
||
|
||
<!-- 视频预览弹窗 -->
|
||
<NModal v-model:show="showVideoPreview" preset="card" title="视频预览" class="max-w-full w-[800px]">
|
||
<div class="flex items-center justify-center rounded-lg bg-black/5 p-4">
|
||
<video
|
||
v-if="videoPreviewSrc" :src="videoPreviewSrc" :poster="videoPreviewPoster" controls
|
||
class="max-h-[600px] max-w-full w-auto"
|
||
/>
|
||
</div>
|
||
</NModal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.rest-upload-container {
|
||
/* 确保容器不会塌陷 */
|
||
min-height: v-bind(newHeight);
|
||
}
|
||
</style>
|