feat: cos upload support

This commit is contained in:
2025-12-19 11:43:05 +08:00
parent f9eaa441a3
commit 9a12ef5e2e
11 changed files with 571 additions and 8 deletions

View File

@@ -40,7 +40,8 @@
"vue-json-pretty": "2.4.0",
"vue-router": "4.5.0",
"vue-types": "6.0.0",
"vxe-table": "4.13.7"
"vxe-table": "4.13.7",
"cos-js-sdk-v5": "^1.8.7"
},
"devDependencies": {
"@iconify/json": "^2.2.276",

View File

@@ -0,0 +1,69 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import COS from 'cos-js-sdk-v5';
/**
* COS临时凭证响应
*/
export interface CosCredentialResponse {
credentials: {
tmpSecretId: string;
tmpSecretKey: string;
sessionToken: string;
};
startTime: number;
expiredTime: number;
expiration: string;
}
/**
* COS配置响应
*/
export interface CosConfigResponse {
bucket: string;
region: string;
}
/**
* 获取COS临时凭证
*/
export const getCosCredential = (): AxiosPromise<CosCredentialResponse> => {
return request({
url: '/inspection/cos/credential',
method: 'get'
});
};
/**
* 获取COS配置信息
*/
export const getCosConfig = (): AxiosPromise<CosConfigResponse> => {
return request({
url: '/inspection/cos/config',
method: 'get'
});
};
/**
* 创建COS实例(包含自动获取凭证)
*/
export const createCosInstance = (): COS => {
return new COS({
getAuthorization: (options, callback) => {
getCosCredential()
.then((res) => {
const credentials = res.data.credentials;
callback({
TmpSecretId: credentials.tmpSecretId,
TmpSecretKey: credentials.tmpSecretKey,
SecurityToken: credentials.sessionToken,
StartTime: res.data.startTime,
ExpiredTime: res.data.expiredTime
});
})
.catch((error) => {
console.error('获取COS临时凭证失败:', error);
});
}
});
};

View File

@@ -0,0 +1,18 @@
/**
* COS上传回调参数
*/
export interface CosUploadCallbackData {
Location: string; // 完整URL(不含协议)
statusCode: number;
headers: any;
}
/**
* COS上传进度回调
*/
export interface CosUploadProgress {
loaded: number; // 已上传字节数
total: number; // 总字节数
speed: number; // 上传速度(字节/秒)
percent: number; // 上传进度(0-1)
}

View File

@@ -0,0 +1,275 @@
<template>
<div class="audio-upload-container">
<el-upload
ref="audioUploadRef"
:auto-upload="false"
:show-file-list="false"
:accept="fileAccept"
:on-change="handleFileChange"
:disabled="disabled || uploading"
>
<el-button type="primary" :icon="Upload" :disabled="disabled || uploading">
{{ uploading ? '上传中...' : '选择音频文件' }}
</el-button>
</el-upload>
<!-- 上传提示 -->
<div v-if="showTip" class="el-upload__tip">
支持格式: <b style="color: #f56c6c">{{ fileType.join('、') }}</b>,
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
</div>
<!-- 上传进度 -->
<div v-if="uploading" class="upload-progress">
<el-progress :percentage="uploadPercent" :status="uploadStatus" />
<span class="progress-text">{{ progressText }}</span>
</div>
<!-- 已上传文件显示 -->
<div v-if="currentFile && !uploading" class="file-preview">
<div class="file-info">
<el-icon class="file-icon"><Headset /></el-icon>
<span class="file-name">{{ currentFile.name }}</span>
</div>
<div class="file-actions">
<el-button v-if="currentFile.url" type="primary" link @click="previewAudio">试听</el-button>
<el-button v-if="!disabled" type="danger" link @click="handleRemove">删除</el-button>
</div>
</div>
<!-- 音频预览对话框 -->
<el-dialog v-model="previewVisible" title="音频预览" width="500px" append-to-body>
<audio v-if="previewUrl" :src="previewUrl" controls style="width: 100%"></audio>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, getCurrentInstance, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { Upload, Headset } from '@element-plus/icons-vue';
import { createCosInstance, getCosConfig } from '@/api/inspection/cos';
import type { CosUploadProgress } from '@/api/inspection/cos/types';
import type { ComponentInternalInstance } from 'vue';
interface Props {
modelValue?: string; // v-model绑定的URL
fileSize?: number; // 文件大小限制(MB)
fileType?: string[]; // 支持的文件类型
disabled?: boolean; // 是否禁用
showTip?: boolean; // 是否显示提示
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
fileSize: 10,
fileType: () => ['mp3', 'wav', 'm4a', 'aac', 'ogg'],
disabled: false,
showTip: true
});
const emit = defineEmits<{
'update:modelValue': [value: string];
'upload-success': [url: string];
'upload-error': [error: any];
}>();
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const audioUploadRef = ref();
const currentFile = ref<{ name: string; url?: string } | null>(null);
const uploading = ref(false);
const uploadPercent = ref(0);
const uploadStatus = ref<'success' | 'exception' | 'warning'>('success');
const previewVisible = ref(false);
const previewUrl = ref('');
// COS配置
const cosConfig = ref<{ bucket: string; region: string }>();
// 计算accept属性
const fileAccept = computed(() => props.fileType.map((type) => `.${type}`).join(','));
// 进度文本
const progressText = computed(() => {
if (uploadPercent.value < 100) {
return `正在上传: ${uploadPercent.value}%`;
}
return '上传完成';
});
// 监听modelValue变化
watch(
() => props.modelValue,
(url) => {
if (url) {
// 从URL提取文件名
const fileName = url.substring(url.lastIndexOf('/') + 1);
currentFile.value = { name: decodeURIComponent(fileName), url };
} else {
currentFile.value = null;
}
},
{ immediate: true }
);
// 初始化COS配置
const initCosConfig = async () => {
try {
const res = await getCosConfig();
cosConfig.value = res.data;
} catch (error) {
console.error('获取COS配置失败:', error);
}
};
// 组件挂载时初始化
onMounted(() => {
initCosConfig();
});
// 文件选择变化
const handleFileChange = (file: any) => {
// 校验文件类型
const fileName = file.name;
const fileExt = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
if (!props.fileType.includes(fileExt)) {
proxy?.$modal.msgError(`文件格式不正确,请上传${props.fileType.join('、')}格式的音频文件!`);
return;
}
// 校验文件大小
const fileSizeMB = file.size / 1024 / 1024;
if (fileSizeMB > props.fileSize) {
proxy?.$modal.msgError(`文件大小不能超过${props.fileSize}MB!`);
return;
}
// 开始上传
uploadToCos(file.raw, fileName);
};
// 上传到COS
const uploadToCos = async (file: File, fileName: string) => {
if (!cosConfig.value) {
ElMessage.error('COS配置未加载,请稍后重试');
return;
}
uploading.value = true;
uploadPercent.value = 0;
uploadStatus.value = 'success';
try {
const cos = createCosInstance();
const timestamp = Date.now();
const key = `audio/${timestamp}_${fileName}`;
cos.uploadFile(
{
Bucket: cosConfig.value.bucket,
Region: cosConfig.value.region,
Key: key,
Body: file,
SliceSize: 1024 * 1024 * 100, // 大于100MB使用分块上传
onProgress: (progressData: CosUploadProgress) => {
uploadPercent.value = Math.round(progressData.percent * 100);
}
},
(err: any, data: any) => {
uploading.value = false;
if (err) {
uploadStatus.value = 'exception';
ElMessage.error('上传失败: ' + err.message);
emit('upload-error', err);
return;
}
// 上传成功
const url = 'https://' + data.Location;
currentFile.value = { name: fileName, url };
emit('update:modelValue', url);
emit('upload-success', url);
ElMessage.success('上传成功');
}
);
} catch (error) {
uploading.value = false;
uploadStatus.value = 'exception';
ElMessage.error('上传失败: ' + error);
emit('upload-error', error);
}
};
// 删除文件
const handleRemove = () => {
currentFile.value = null;
uploadPercent.value = 0;
emit('update:modelValue', '');
};
// 预览音频
const previewAudio = () => {
if (currentFile.value?.url) {
previewUrl.value = currentFile.value.url;
previewVisible.value = true;
}
};
</script>
<style lang="scss" scoped>
.audio-upload-container {
.el-upload__tip {
margin-top: 8px;
font-size: 12px;
color: #606266;
}
.upload-progress {
margin-top: 12px;
.progress-text {
display: block;
margin-top: 8px;
font-size: 12px;
color: #606266;
}
}
.file-preview {
margin-top: 12px;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
.file-info {
display: flex;
align-items: center;
flex: 1;
.file-icon {
font-size: 20px;
margin-right: 8px;
color: #409eff;
}
.file-name {
font-size: 14px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.file-actions {
display: flex;
gap: 8px;
}
}
}
</style>

View File

@@ -140,8 +140,8 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="内容语音URL" prop="contentVoice">
<el-input v-model="form.contentVoice" placeholder="请输入内容语音URL" />
<el-form-item label="内容语音" prop="contentVoice">
<AudioUpload v-model="form.contentVoice" :file-size="10" />
</el-form-item>
</el-col>
</el-row>
@@ -172,8 +172,8 @@
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="复述语音URL" prop="rephraseVoice">
<el-input v-model="form.rephraseVoice" placeholder="语音URL" />
<el-form-item label="复述语音" prop="rephraseVoice">
<AudioUpload v-model="form.rephraseVoice" :file-size="10" />
</el-form-item>
</el-col>
</el-row>
@@ -189,8 +189,8 @@
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="确认语音URL" prop="confirmVoice">
<el-input v-model="form.confirmVoice" placeholder="语音URL" />
<el-form-item label="确认语音" prop="confirmVoice">
<AudioUpload v-model="form.confirmVoice" :file-size="10" />
</el-form-item>
</el-col>
</el-row>
@@ -243,6 +243,7 @@ import { listArTask } from '@/api/inspection/task';
import { ArTaskVO } from '@/api/inspection/task/types';
import { listArPoint } from '@/api/inspection/point';
import { ArPointVO } from '@/api/inspection/point/types';
import AudioUpload from '@/components/AudioUpload/index.vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const route = useRoute();