Files
Document_translator/frontend/src/views/UploadView.vue
beabigegg 0bc8c4c81c backup
2025-09-12 08:56:44 +08:00

865 lines
24 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.

<template>
<div class="upload-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">檔案上傳</h1>
<div class="page-actions">
<el-button @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看任務列表
</el-button>
</div>
</div>
<div class="upload-content">
<!-- 上傳區域 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">選擇要翻譯的檔案</h3>
<div class="card-subtitle">
支援 DOCXDOCPPTXXLSXXLSPDF 格式單檔最大 25MB
</div>
</div>
<div class="card-body">
<!-- 檔案上傳器 -->
<el-upload
ref="uploadRef"
class="upload-dragger"
:class="{ disabled: uploading }"
drag
:multiple="true"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:http-request="() => {}"
:disabled="uploading"
>
<div class="upload-content-inner">
<el-icon class="upload-icon">
<UploadFilled />
</el-icon>
<div class="upload-text">
<div class="upload-title">拖拽檔案至此或點擊選擇檔案</div>
<div class="upload-hint">
支援 .docx, .doc, .pptx, .xlsx, .xls, .pdf 格式
</div>
</div>
</div>
</el-upload>
<!-- 已選擇的檔案列表 -->
<div v-if="selectedFiles.length > 0" class="selected-files">
<div class="files-header">
<h4>已選擇的檔案 ({{ selectedFiles.length }})</h4>
<el-button type="text" @click="clearFiles" :disabled="uploading">
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
<div class="files-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="file-item"
>
<div class="file-icon">
<div class="file-type" :class="getFileExtension(file.name)">
{{ getFileExtension(file.name).toUpperCase() }}
</div>
</div>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-details">
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<span class="file-type-text">{{ getFileTypeText(file.name) }}</span>
</div>
</div>
<div class="file-actions">
<el-button
type="text"
size="small"
@click="removeFile(index)"
:disabled="uploading"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 翻譯設定 -->
<div class="content-card" v-if="selectedFiles.length > 0">
<div class="card-header">
<h3 class="card-title">翻譯設定</h3>
</div>
<div class="card-body">
<el-form
ref="translationFormRef"
:model="translationForm"
:rules="translationRules"
label-width="120px"
size="large"
>
<el-form-item label="來源語言" prop="sourceLanguage">
<el-select
v-model="translationForm.sourceLanguage"
placeholder="請選擇來源語言"
style="width: 100%"
:disabled="uploading"
>
<el-option label="自動偵測" value="auto" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="越南文" value="vi" />
</el-select>
</el-form-item>
<el-form-item label="目標語言" prop="targetLanguages">
<el-select
v-model="translationForm.targetLanguages"
multiple
placeholder="請選擇目標語言可多選"
style="width: 100%"
:disabled="uploading"
collapse-tags
collapse-tags-tooltip
>
<el-option label="英文" value="en" />
<el-option label="越南文" value="vi" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="泰文" value="th" />
<el-option label="印尼文" value="id" />
<el-option label="馬來文" value="ms" />
</el-select>
<div class="form-tip">
<el-icon><InfoFilled /></el-icon>
可以同時選擇多個目標語言,系統會分別生成對應的翻譯檔案
</div>
</el-form-item>
<el-form-item>
<div class="translation-actions">
<el-button
type="primary"
size="large"
:loading="uploading"
:disabled="selectedFiles.length === 0 || translationForm.targetLanguages.length === 0"
@click="startTranslation"
>
<el-icon><Upload /></el-icon>
{{ uploading ? '上傳中...' : `開始翻譯 (${selectedFiles.length} 個檔案)` }}
</el-button>
<el-button size="large" @click="resetForm" :disabled="uploading">
重置
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</div>
<!-- 上傳進度 -->
<div class="content-card" v-if="uploading || uploadResults.length > 0">
<div class="card-header">
<h3 class="card-title">上傳進度</h3>
</div>
<div class="card-body">
<div class="upload-progress">
<!-- 總體進度 -->
<div class="overall-progress" v-if="uploading">
<div class="progress-info">
<span>整體進度: {{ currentFileIndex + 1 }} / {{ selectedFiles.length }}</span>
<span>{{ Math.round(overallProgress) }}%</span>
</div>
<el-progress
:percentage="overallProgress"
:stroke-width="8"
:show-text="false"
status="success"
/>
</div>
<!-- 個別檔案進度 -->
<div class="files-progress">
<div
v-for="(result, index) in uploadResults"
:key="index"
class="file-progress-item"
:class="result.status"
>
<div class="file-info">
<div class="file-icon">
<div class="file-type" :class="getFileExtension(result.filename)">
{{ getFileExtension(result.filename).toUpperCase() }}
</div>
</div>
<div class="file-details">
<div class="file-name">{{ result.filename }}</div>
<div class="file-status">
<el-icon v-if="result.status === 'success'">
<SuccessFilled />
</el-icon>
<el-icon v-else-if="result.status === 'error'">
<CircleCloseFilled />
</el-icon>
<el-icon v-else>
<Loading />
</el-icon>
<span>{{ getUploadStatusText(result.status) }}</span>
</div>
</div>
</div>
<div class="file-progress" v-if="result.status === 'uploading'">
<el-progress
:percentage="result.progress || 0"
:stroke-width="4"
:show-text="false"
/>
</div>
<div class="file-actions" v-if="result.status === 'success'">
<el-button
type="text"
size="small"
@click="viewJob(result.jobUuid)"
>
查看任務
</el-button>
</div>
</div>
</div>
<!-- 完成後的操作 -->
<div class="upload-complete-actions" v-if="!uploading && uploadResults.length > 0">
<el-button type="primary" @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看所有任務
</el-button>
<el-button @click="resetUpload">
<el-icon><RefreshRight /></el-icon>
重新上傳
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
List, UploadFilled, Delete, Close, Upload, InfoFilled,
SuccessFilled, CircleCloseFilled, Loading, RefreshRight
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const jobsStore = useJobsStore()
// 組件引用
const uploadRef = ref()
const translationFormRef = ref()
// 響應式數據
const selectedFiles = ref([])
const uploading = ref(false)
const currentFileIndex = ref(0)
const uploadResults = ref([])
// 表單數據
const translationForm = reactive({
sourceLanguage: 'auto',
targetLanguages: []
})
// 表單驗證規則
const translationRules = {
targetLanguages: [
{ required: true, message: '請至少選擇一個目標語言', trigger: 'change' },
{
type: 'array',
min: 1,
message: '請至少選擇一個目標語言',
trigger: 'change'
}
]
}
// 支援的檔案類型
const supportedTypes = {
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc': 'application/msword',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'ppt': 'application/vnd.ms-powerpoint',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel',
'pdf': 'application/pdf'
}
// 計算屬性
const overallProgress = computed(() => {
if (uploadResults.value.length === 0) return 0
const totalProgress = uploadResults.value.reduce((sum, result) => {
if (result.status === 'success') return sum + 100
if (result.status === 'error') return sum + 100
return sum + (result.progress || 0)
}, 0)
return (totalProgress / selectedFiles.value.length)
})
// 方法
const handleBeforeUpload = (file) => {
// 檢查檔案類型
const extension = getFileExtension(file.name)
if (!supportedTypes[extension]) {
ElMessage.error(`不支援的檔案類型: ${extension}`)
return false
}
// 檢查檔案大小
const maxSize = 25 * 1024 * 1024 // 25MB
if (file.size > maxSize) {
ElMessage.error(`檔案大小不能超過 25MB當前檔案: ${formatFileSize(file.size)}`)
return false
}
// 檢查是否已存在
const exists = selectedFiles.value.some(f => f.name === file.name)
if (exists) {
ElMessage.warning('檔案已存在於列表中')
return false
}
// 添加到選擇列表
selectedFiles.value.push(file)
ElMessage.success(`已添加檔案: ${file.name}`)
return false // 阻止自動上傳
}
const removeFile = (index) => {
const filename = selectedFiles.value[index].name
selectedFiles.value.splice(index, 1)
ElMessage.info(`已移除檔案: ${filename}`)
}
const clearFiles = async () => {
try {
await ElMessageBox.confirm('確定要清空所有已選檔案嗎?', '確認清空', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
selectedFiles.value = []
ElMessage.success('已清空檔案列表')
} catch (error) {
// 用戶取消
}
}
const startTranslation = async () => {
try {
// 驗證表單
const valid = await translationFormRef.value.validate()
if (!valid) {
return
}
if (selectedFiles.value.length === 0) {
ElMessage.warning('請先選擇要翻譯的檔案')
return
}
// 開始上傳
uploading.value = true
currentFileIndex.value = 0
uploadResults.value = []
// 為每個檔案創建上傳記錄
selectedFiles.value.forEach(file => {
uploadResults.value.push({
filename: file.name,
status: 'waiting',
progress: 0,
jobUuid: null,
error: null
})
})
// 逐個上傳檔案
for (let i = 0; i < selectedFiles.value.length; i++) {
currentFileIndex.value = i
const file = selectedFiles.value[i]
const resultIndex = i
try {
// 更新狀態為上傳中
uploadResults.value[resultIndex].status = 'uploading'
// 創建 FormData
const formData = new FormData()
formData.append('file', file)
formData.append('source_language', translationForm.sourceLanguage)
formData.append('target_languages', JSON.stringify(translationForm.targetLanguages))
// 上傳檔案
const result = await jobsStore.uploadFile(formData, (progress) => {
uploadResults.value[resultIndex].progress = progress
})
// 上傳成功
uploadResults.value[resultIndex].status = 'success'
uploadResults.value[resultIndex].progress = 100
uploadResults.value[resultIndex].jobUuid = result.job_uuid
} catch (error) {
console.error(`檔案 ${file.name} 上傳失敗:`, error)
uploadResults.value[resultIndex].status = 'error'
uploadResults.value[resultIndex].error = error.message || '上傳失敗'
ElMessage.error(`檔案 ${file.name} 上傳失敗: ${error.message || '未知錯誤'}`)
}
}
// 檢查上傳結果
const successCount = uploadResults.value.filter(r => r.status === 'success').length
const failCount = uploadResults.value.filter(r => r.status === 'error').length
if (successCount > 0) {
ElMessage.success(`成功上傳 ${successCount} 個檔案`)
}
if (failCount > 0) {
ElMessage.error(`${failCount} 個檔案上傳失敗`)
}
} catch (error) {
console.error('批量上傳失敗:', error)
ElMessage.error('批量上傳失敗')
} finally {
uploading.value = false
}
}
const resetForm = () => {
selectedFiles.value = []
translationForm.sourceLanguage = 'auto'
translationForm.targetLanguages = []
uploadResults.value = []
translationFormRef.value?.resetFields()
}
const resetUpload = () => {
uploadResults.value = []
currentFileIndex.value = 0
}
const viewJob = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const getFileTypeText = (filename) => {
const ext = getFileExtension(filename)
const typeMap = {
'docx': 'Word 文件',
'doc': 'Word 文件',
'pptx': 'PowerPoint 簡報',
'ppt': 'PowerPoint 簡報',
'xlsx': 'Excel 試算表',
'xls': 'Excel 試算表',
'pdf': 'PDF 文件'
}
return typeMap[ext] || ext.toUpperCase()
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const getUploadStatusText = (status) => {
const statusMap = {
'waiting': '等待中',
'uploading': '上傳中',
'success': '上傳成功',
'error': '上傳失敗'
}
return statusMap[status] || status
}
// 生命週期
onMounted(() => {
// 載入使用者偏好設定(如果有的話)
const savedSettings = localStorage.getItem('translation_settings')
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings)
translationForm.sourceLanguage = settings.sourceLanguage || 'auto'
translationForm.targetLanguages = settings.targetLanguages || []
} catch (error) {
console.error('載入設定失敗:', error)
}
}
})
// 監聽表單變化,保存設定
watch([() => translationForm.sourceLanguage, () => translationForm.targetLanguages], () => {
const settings = {
sourceLanguage: translationForm.sourceLanguage,
targetLanguages: translationForm.targetLanguages
}
localStorage.setItem('translation_settings', JSON.stringify(settings))
}, { deep: true })
</script>
<style lang="scss" scoped>
.upload-view {
.upload-content {
.content-card {
&:not(:last-child) {
margin-bottom: 24px;
}
.card-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
}
.upload-dragger {
:deep(.el-upload-dragger) {
border: 2px dashed var(--el-border-color);
border-radius: 8px;
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
&.is-dragover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-8);
}
}
&.disabled :deep(.el-upload-dragger) {
cursor: not-allowed;
opacity: 0.6;
&:hover {
border-color: var(--el-border-color);
background-color: transparent;
}
}
.upload-content-inner {
text-align: center;
.upload-icon {
font-size: 48px;
color: var(--el-color-primary);
margin-bottom: 16px;
}
.upload-title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.upload-hint {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
}
.selected-files {
margin-top: 24px;
.files-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h4 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 16px;
}
}
.files-list {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
.file-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background-color 0.3s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--el-fill-color-light);
}
.file-icon {
margin-right: 12px;
.file-type {
width: 32px;
height: 32px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
&.docx, &.doc {
background-color: #2b579a;
}
&.pptx, &.ppt {
background-color: #d24726;
}
&.xlsx, &.xls {
background-color: #207245;
}
&.pdf {
background-color: #ff0000;
}
}
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-details {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
}
}
}
.form-tip {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background-color: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 13px;
color: var(--el-color-info);
line-height: 1.4;
.el-icon {
margin-top: 1px;
flex-shrink: 0;
}
}
.translation-actions {
display: flex;
gap: 12px;
@media (max-width: 480px) {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
.upload-progress {
.overall-progress {
margin-bottom: 24px;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
}
.files-progress {
.file-progress-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
&.success {
.file-status {
color: var(--el-color-success);
}
}
&.error {
.file-status {
color: var(--el-color-danger);
}
}
&.uploading {
.file-status {
color: var(--el-color-primary);
}
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.file-icon {
margin-right: 12px;
.file-type {
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: white;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
}
.file-details {
flex: 1;
min-width: 0;
.file-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.el-icon {
font-size: 14px;
}
}
}
}
.file-progress {
width: 120px;
margin: 0 16px;
}
.file-actions {
margin-left: 16px;
}
}
}
.upload-complete-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
@media (max-width: 480px) {
flex-direction: column;
}
}
}
}
</style>