Files
reader-star/apps/admin/src/components/common/rest-upload/rest-upload.vue
rjl 16d7ecd172 feat(template): 新增答题卡模板编辑器及相关功能
- 新增模板详情页面,支持上传PDF/图片作为底稿,并生成可拖拽调整的区域
- 新增模板类型定义、上传钩子及画布组件,支持批量生成区域、旋转、对齐参考线等功能
- 更新模板列表页,适配新的API接口并增加删除功能
- 更新OSS工具函数,修复上传凭证处理逻辑
- 更新公共类型定义,调整分页查询返回结构
- 移除旧的书籍相关API文件,替换为模板API
- 更新依赖项,添加pdfjs-dist、vue3-draggable-resizable等库
- 隐藏页面底部,优化界面显示
2026-01-28 18:00:17 +08:00

489 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- 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>