feat: 上传视频/图片处理

This commit is contained in:
rd
2025-08-01 11:49:15 +08:00
parent 1cb33bd3ad
commit c211693d1f
7 changed files with 444 additions and 169 deletions

View File

@ -5,8 +5,9 @@ import CommonSelect from '@/components/common-select';
import { VueDraggable } from 'vue-draggable-plus';
import TextOverTips from '@/components/text-over-tips';
import { formatFileSize, getVideoDuration } from '@/utils/tools';
import { formatFileSize, getVideoInfo, formatDuration, formatUploadSpeed } from '@/utils/tools';
import { getProjectList } from '@/api/all/propertyMarketing';
import { EnumManuscriptType } from '@/views/creative-generation-workshop/manuscript/list/constants.ts';
import { getImagePreSignedUrl, getVideoPreSignedUrl } from '@/api/all/common';
import icon1 from '@/assets/img/creative-generation-workshop/icon-close.png';
@ -15,13 +16,27 @@ import icon1 from '@/assets/img/creative-generation-workshop/icon-close.png';
const FORM_RULES = {
title: [{ required: true, message: '请输入标题' }],
};
const ENUM_UPLOAD_STATUS = {
export const ENUM_UPLOAD_STATUS = {
DEFAULT: 'default',
UPLOADING: 'uploading',
END: 'end',
};
export const INITIAL_VIDEO_INFO = {
name: '',
size: '',
percent: 0,
poster: '',
time: '',
uploadSpeed: '0 KB/s',
startTime: 0,
lastTime: 0,
lastLoaded: 0,
estimatedTime: 0,
poster: '',
uploadStatus: ENUM_UPLOAD_STATUS.DEFAULT,
};
export default {
name: 'ManuscriptForm',
props: {
@ -33,124 +48,167 @@ export default {
type: Object,
default: () => FORM_RULES,
},
formData: {
type: Object,
default: () => ({}),
},
},
emits: ['reValidate', 'change'],
emits: ['reValidate', 'change', 'update:modelValue', 'updateVideoInfo'],
setup(props, { emit, expose }) {
const formRef = ref(null);
const formData = ref({});
const uploadRef = ref(null);
const projects = ref([]);
const uploadStatus = ref(ENUM_UPLOAD_STATUS.DEFAULT);
const videoInfo = ref({
name: '',
size: '',
percent: 0,
poster: '',
time: '',
});
function getFileExtension(filename) {
const match = filename.match(/\.([^.]+)$/);
return match ? match[1].toLowerCase() : '';
}
const isVideo = computed(() => true);
const isVideo = computed(() => formData.value.type === EnumManuscriptType.Video);
const setVideoInfo = (file) => {
formData.value.videoInfo.percent = 0;
formData.value.videoInfo.name = file.name;
formData.value.videoInfo.size = formatFileSize(file.size);
formData.value.videoInfo.startTime = Date.now();
formData.value.videoInfo.lastTime = Date.now();
formData.value.videoInfo.lastLoaded = 0;
formData.value.videoInfo.uploadSpeed = '0 KB/s';
emit('updateVideoInfo', formData.value.videoInfo);
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.floor((seconds % 1) * 100);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}.${milliseconds
.toString()
.padStart(2, '0')}`;
}
getVideoInfo(file)
.then(({ duration, firstFrame }) => {
console.log({ duration, firstFrame });
formData.value.videoInfo.poster = firstFrame;
formData.value.videoInfo.time = formatDuration(duration);
emit('updateVideoInfo', formData.value.videoInfo);
})
.catch((error) => {
console.error('获取视频时长失败:', error);
});
};
const replaceVideo = async (option) => {
const uploadVideo = async (option) => {
try {
formData.value.videoInfo.uploadStatus = ENUM_UPLOAD_STATUS.UPLOADING;
emit('updateVideoInfo', formData.value.videoInfo);
const {
fileItem: { file },
} = option;
uploadStatus.value = ENUM_UPLOAD_STATUS.UPLOADING;
// 重置进度为0
videoInfo.value.percent = 0;
videoInfo.value.name = file.name;
videoInfo.value.size = formatFileSize(file.size);
// const duration = getVideoDuration(file);
// videoInfo.value.time = formatDuration(duration);
setVideoInfo(file);
const response = await getVideoPreSignedUrl({ suffix: getFileExtension(file.name) });
const { file_name, upload_url } = response?.data;
const { file_name, upload_url, file_url } = response?.data;
if (!upload_url) {
throw new Error('未能获取有效的预签名上传地址');
}
const blob = new Blob([file], { type: file.type });
const res = await axios.put(upload_url, blob, {
await axios.put(upload_url, blob, {
headers: { 'Content-Type': file.type },
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(progressEvent.progress * 100);
videoInfo.value.percent = percentCompleted;
formData.value.videoInfo.percent = percentCompleted;
const currentTime = Date.now();
const currentLoaded = progressEvent.loaded;
const totalSize = progressEvent.total;
if (formData.value.videoInfo.lastLoaded === 0) {
formData.value.videoInfo.lastLoaded = currentLoaded;
formData.value.videoInfo.lastTime = currentTime;
return;
}
const timeDiff = (currentTime - formData.value.videoInfo.lastTime) / 1000;
const bytesDiff = currentLoaded - formData.value.videoInfo.lastLoaded;
// 避免频繁更新至少间隔200ms计算一次速率
if (timeDiff >= 0.2) {
const bytesPerSecond = bytesDiff / timeDiff;
formData.value.videoInfo.uploadSpeed = formatUploadSpeed(bytesPerSecond);
formData.value.videoInfo.lastLoaded = currentLoaded;
formData.value.videoInfo.lastTime = currentTime;
// 计算预估剩余时间
if (totalSize && bytesPerSecond > 0) {
const remainingBytes = totalSize - currentLoaded;
const remainingSeconds = remainingBytes / bytesPerSecond;
formData.value.videoInfo.estimatedTime = formatDuration(remainingSeconds);
} else {
formData.value.videoInfo.estimatedTime = 0;
}
}
emit('updateVideoInfo', formData.value.videoInfo);
},
});
props.modelValue.files.push(file_name);
formData.value.files.push(file_url);
onChange();
} finally {
uploadStatus.value = ENUM_UPLOAD_STATUS.END;
formData.value.videoInfo.uploadStatus = ENUM_UPLOAD_STATUS.END;
emit('updateVideoInfo', formData.value.videoInfo);
}
};
const onChange = () => {
emit('change', formData.value);
};
// 文件上传处理
const handleUpload = (option) => {
const uploadImage = async (option) => {
const {
fileItem: { file },
} = option;
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
AMessage.error('只能上传图片文件!');
return;
}
// 验证文件大小 (5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
AMessage.error('图片大小不能超过5MB');
return;
}
// const maxSize = 5 * 1024 * 1024;
// if (file.size > maxSize) {
// AMessage.error('图片大小不能超过5MB');
// return;
// }
// 验证文件数量
if (props.modelValue.files?.length >= 18) {
if (formData.value.files?.length >= 18) {
AMessage.error('最多只能上传18张图片');
return;
}
props.modelValue.files.push(URL.createObjectURL(file));
emit('change');
const response = await getImagePreSignedUrl({ suffix: getFileExtension(file.name) });
const { file_name, upload_url, file_url } = response?.data;
const blob = new Blob([file], { type: file.type });
await axios.put(upload_url, blob, {
headers: { 'Content-Type': file.type },
});
formData.value.files.push(file_url);
onChange();
};
// 删除文件
const handleDeleteFile = (index) => {
props.modelValue.files.splice(index, 1);
emit('change');
formData.value.files.splice(index, 1);
onChange();
};
// 获取项目列表
const getProjects = async () => {
try {
const { code, data } = await getProjectList();
if (code === 200) {
projects.value = data;
}
} catch (error) {
console.error('获取项目列表失败:', error);
}
};
// // 获取项目列表
// const getProjects = async () => {
// try {
// const { code, data } = await getProjectList();
// if (code === 200) {
// projects.value = data;
// }
// } catch (error) {
// console.error('获取项目列表失败:', error);
// }
// };
// 表单验证
const validate = () => {
return new Promise((resolve, reject) => {
formRef.value?.validate((errors) => {
if (errors) {
reject(props.modelValue);
reject(formData.value);
} else {
resolve();
}
@ -160,6 +218,7 @@ export default {
// 重置表单
const resetForm = () => {
formData.value = {};
formRef.value?.resetFields?.();
formRef.value?.clearValidate?.();
};
@ -170,13 +229,13 @@ export default {
ref={uploadRef}
action="/"
draggable
custom-request={replaceVideo}
custom-request={uploadVideo}
accept=".mp4,.webm,.ogg,.mov,.avi,.flv,.wmv,.mkv"
show-file-list={false}
>
{{
'upload-button': () => {
if (uploadStatus.value === ENUM_UPLOAD_STATUS.DEFAULT) {
if (formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT) {
return (
<div class="upload-box">
<icon-plus size="14" class="mb-16px color-#3C4043" />
@ -192,8 +251,8 @@ export default {
);
};
const renderVideo = () => {
const isUploading = uploadStatus.value === ENUM_UPLOAD_STATUS.UPLOADING;
const isEnd = uploadStatus.value === ENUM_UPLOAD_STATUS.END;
const isUploading = formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.UPLOADING;
const isEnd = formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.END;
return (
<FormItem
field="files"
@ -206,7 +265,7 @@ export default {
),
}}
>
{uploadStatus.value === ENUM_UPLOAD_STATUS.DEFAULT ? (
{formData.value.videoInfo.uploadStatus === ENUM_UPLOAD_STATUS.DEFAULT ? (
renderVideoUpload()
) : (
<div class="flex items-center justify-between p-12px rounded-8px bg-#F7F8FA w-784px">
@ -216,20 +275,31 @@ export default {
<icon-loading size="24" class="color-#B1B2B5" />
</div>
) : (
<img src={''} class="w-80 h-80 object-cover mr-16px rounded-8px" />
<img src={formData.value.videoInfo.poster} class="w-80 h-80 object-cover mr-16px rounded-8px" />
)}
<div class="flex flex-col">
<TextOverTips context={videoInfo.value.name} class="mb-4px cts !text-14px !lh-22px color-#211F24" />
<TextOverTips
context={formData.value.videoInfo.name}
class="mb-4px cts !text-14px !lh-22px color-#211F24"
/>
{isEnd ? (
<p>
<span class="cts color-#939499 mr-24px">视频大小{videoInfo.value.size}</span>
<span class="cts color-#939499">视频时长2.73s</span>
<span class="cts color-#939499 mr-24px">视频大小{formData.value.videoInfo.size}</span>
<span class="cts color-#939499">视频时长{formData.value.videoInfo.time}</span>
</p>
) : (
<div class="flex items-center">
<icon-loading size="16" class="color-#6D4CFE mr-8px" />
<span class="cts !color-#6D4CFE mr-4px">上传中</span>
<span class="cts !color-#6D4CFE mr-4px">{videoInfo.value.percent}%</span>
<div class="flex items-center mr-24px w-100px">
<icon-loading size="16" class="color-#6D4CFE mr-8px" />
<span class="cts !color-#6D4CFE mr-4px">上传中</span>
<span class="cts !color-#6D4CFE ">{formData.value.videoInfo.percent}%</span>
</div>
<div class="flex items-center w-160px mr-24px">
<span class="cts color-#939499">上传速度{formData.value.videoInfo.uploadSpeed}</span>
</div>
<div class="flex items-center">
<span class="cts color-#939499">预估剩余时间{formData.value.videoInfo.estimatedTime}</span>
</div>
</div>
)}
</div>
@ -249,7 +319,7 @@ export default {
label: () => (
<div class="flex items-center">
<span class="cts !color-#211F24 mr-4px">图片</span>
<span class="cts mr-8px !color-#939499">{`(${props.modelValue.files?.length ?? 0}/18)`}</span>
<span class="cts mr-8px !color-#939499">{`(${formData.value.files?.length ?? 0}/18)`}</span>
<span class="cts !color-#939499">第一张为首图支持拖拽排序</span>
</div>
),
@ -258,8 +328,8 @@ export default {
<div>
<div class="">
{/* 已上传的图片列表 */}
<VueDraggable v-model={props.modelValue.files} class="grid grid-cols-7 gap-8px">
{props.modelValue.files?.map((file, index) => (
<VueDraggable v-model={formData.value.files} class="grid grid-cols-7 gap-8px">
{formData.value.files?.map((file, index) => (
<div key={index} class="group relative cursor-move">
<img src={file} class="w-100px h-100px object-cover rounded-8px border-1px border-#E6E6E8" />
<img
@ -273,16 +343,16 @@ export default {
))}
</VueDraggable>
</div>
{props.modelValue.files?.length < 18 && (
{formData.value.files?.length < 18 && (
<Upload
ref={uploadRef}
action="/"
draggable
custom-request={handleUpload}
custom-request={uploadImage}
accept=".jpg,.jpeg,.png,.gif,.webp"
show-file-list={false}
multiple
limit={18 - props.modelValue.files?.length}
limit={18 - formData.value.files?.length}
>
{{
'upload-button': () => (
@ -305,18 +375,21 @@ export default {
resetForm,
});
// 组件挂载时获取项目列表
onMounted(() => {
getProjects();
});
watch(
() => props.formData,
(val) => {
formData.value = val;
},
{ deep: true, immediate: true },
);
return () => (
<Form ref={formRef} model={props.modelValue} rules={props.rules} layout="vertical" auto-label-width>
<Form ref={formRef} model={formData.value} rules={props.rules} layout="vertical" auto-label-width>
<FormItem label="标题" field="title" required>
<Input
v-model={props.modelValue.title}
onChange={() => {
emit('change');
v-model={formData.value.title}
onInput={() => {
onChange();
emit('reValidate');
}}
placeholder="请输入标题"
@ -327,10 +400,10 @@ export default {
/>
</FormItem>
<FormItem label="作品描述" field="desc">
<FormItem label="作品描述" field="content">
<Textarea
v-model={props.modelValue.content}
onChange={() => emit('change')}
v-model={formData.value.content}
onInput={onChange}
placeholder="请输入作品描述"
size="large"
class="h-200px !w-784px"
@ -341,16 +414,16 @@ export default {
</FormItem>
{isVideo.value ? renderVideo() : renderImage()}
<FormItem label="所属项目" field="project_ids">
{/* <FormItem label="所属项目" field="project_ids">
<CommonSelect
v-model={props.modelValue.project_ids}
v-model={formData.value.project_ids}
onChange={() => emit('change')}
options={projects.value}
placeholder="请选择所属项目"
size="large"
class="!w-280px"
/>
</FormItem>
</FormItem>*/}
</Form>
);
},
@ -358,35 +431,5 @@ export default {
</script>
<style lang="scss" scoped>
.cts {
font-family: $font-family-regular;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.upload-box {
display: flex;
width: 100px;
height: 100px;
cursor: pointer;
transition: all 0.3s ease;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 1px dashed var(--Border-1, #d7d7d9);
background: var(--BG-200, #f2f3f5);
&:hover {
background: var(--Primary-1, #e6e6e8);
}
}
.group {
cursor: move;
border-radius: 8px;
&:hover {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
url(<path-to-image>) lightgray 0px -40.771px / 100% 149.766% no-repeat;
}
}
@import './style.scss';
</style>

View File

@ -0,0 +1,31 @@
.cts {
font-family: $font-family-regular;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.upload-box {
display: flex;
width: 100px;
height: 100px;
cursor: pointer;
transition: all 0.3s ease;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 8px;
border: 1px dashed var(--Border-1, #d7d7d9);
background: var(--BG-200, #f2f3f5);
&:hover {
background: var(--Primary-1, #e6e6e8);
}
}
.group {
cursor: move;
border-radius: 8px;
&:hover {
background: linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
url(<path-to-image>) lightgray 0px -40.771px / 100% 149.766% no-repeat;
}
}