This commit is contained in:
beabigegg
2025-09-12 08:56:44 +08:00
commit 0bc8c4c81c
86 changed files with 23146 additions and 0 deletions

View File

@@ -0,0 +1,865 @@
<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>