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

917 lines
26 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="job-detail-view">
<!-- 載入狀態 -->
<div v-if="loading" class="loading-wrapper">
<el-skeleton :rows="8" animated />
</div>
<!-- 任務不存在 -->
<div v-else-if="!job" class="not-found">
<div class="not-found-content">
<el-icon class="not-found-icon"><DocumentDelete /></el-icon>
<h2>任務不存在</h2>
<p>抱歉無法找到指定的翻譯任務</p>
<el-button type="primary" @click="$router.push('/jobs')">
返回任務列表
</el-button>
</div>
</div>
<!-- 任務詳情 -->
<div v-else class="job-detail-content">
<!-- 頁面標題 -->
<div class="page-header">
<div class="header-left">
<el-button type="text" @click="$router.back()" class="back-button">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h1 class="page-title">任務詳情</h1>
</div>
<div class="page-actions">
<el-button @click="refreshJob" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-dropdown @command="handleAction" v-if="job.status === 'COMPLETED'">
<el-button type="primary">
<el-icon><Download /></el-icon>
下載
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="lang in job.target_languages"
:key="lang"
:command="`download_${lang}`"
>
下載 {{ getLanguageText(lang) }} 版本
</el-dropdown-item>
<el-dropdown-item
v-if="hasCombinedFile"
command="download_combined"
divided
>
下載合併檔案
</el-dropdown-item>
<el-dropdown-item command="download_all" divided>
下載全部檔案 (ZIP)
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 任務基本資訊 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">基本資訊</h3>
<div class="job-status-badge">
<el-tag
:type="getStatusTagType(job.status)"
size="large"
effect="dark"
>
<el-icon>
<component :is="getStatusIcon(job.status)" />
</el-icon>
{{ getStatusText(job.status) }}
</el-tag>
</div>
</div>
<div class="card-body">
<div class="job-info-grid">
<div class="info-section">
<div class="section-title">檔案資訊</div>
<div class="info-items">
<div class="info-item">
<div class="info-icon">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
</div>
<div class="info-content">
<div class="info-label">檔案名稱</div>
<div class="info-value">{{ job.original_filename }}</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<el-icon><Document /></el-icon>
</div>
<div class="info-content">
<div class="info-label">檔案大小</div>
<div class="info-value">{{ formatFileSize(job.file_size) }}</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<el-icon><Key /></el-icon>
</div>
<div class="info-content">
<div class="info-label">任務 ID</div>
<div class="info-value job-uuid">{{ job.job_uuid }}</div>
</div>
</div>
</div>
</div>
<div class="info-section">
<div class="section-title">翻譯設定</div>
<div class="info-items">
<div class="info-item">
<div class="info-icon">
<el-icon><Switch /></el-icon>
</div>
<div class="info-content">
<div class="info-label">來源語言</div>
<div class="info-value">
<el-tag size="small" type="info">
{{ getLanguageText(job.source_language) }}
</el-tag>
</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">
<el-icon><Rank /></el-icon>
</div>
<div class="info-content">
<div class="info-label">目標語言</div>
<div class="info-value">
<div class="language-tags">
<el-tag
v-for="lang in job.target_languages"
:key="lang"
size="small"
type="primary"
>
{{ getLanguageText(lang) }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 處理進度 -->
<div class="content-card" v-if="job.status === 'PROCESSING' || job.status === 'RETRY'">
<div class="card-header">
<h3 class="card-title">處理進度</h3>
</div>
<div class="card-body">
<div class="progress-section">
<div class="progress-info">
<span>翻譯進度</span>
<span>{{ Math.round(job.progress || 0) }}%</span>
</div>
<el-progress
:percentage="job.progress || 0"
:stroke-width="12"
status="success"
/>
<div class="progress-description">
系統正在處理您的檔案請耐心等待...
</div>
</div>
</div>
</div>
<!-- 錯誤資訊 -->
<div class="content-card" v-if="job.status === 'FAILED' && job.error_message">
<div class="card-header">
<h3 class="card-title">錯誤資訊</h3>
<div class="card-actions">
<el-button type="primary" @click="retryJob" :loading="retrying">
<el-icon><RefreshRight /></el-icon>
重新翻譯
</el-button>
</div>
</div>
<div class="card-body">
<el-alert
:title="job.error_message"
type="error"
show-icon
:closable="false"
>
<template #default>
<div class="error-details">
<p>{{ job.error_message }}</p>
<p v-if="job.retry_count > 0" class="retry-info">
已重試 {{ job.retry_count }}
</p>
</div>
</template>
</el-alert>
</div>
</div>
<!-- 時間軸 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">處理時間軸</h3>
</div>
<div class="card-body">
<el-timeline>
<el-timeline-item
timestamp="建立任務"
:time="formatDateTime(job.created_at)"
type="primary"
size="large"
icon="Plus"
>
任務建立成功檔案已上傳至系統
</el-timeline-item>
<el-timeline-item
v-if="job.processing_started_at"
timestamp="開始處理"
:time="formatDateTime(job.processing_started_at)"
type="warning"
size="large"
icon="Loading"
>
系統開始處理翻譯任務
</el-timeline-item>
<el-timeline-item
v-if="job.completed_at"
timestamp="處理完成"
:time="formatDateTime(job.completed_at)"
type="success"
size="large"
icon="Check"
>
翻譯完成檔案可供下載
<div v-if="job.processing_started_at" class="processing-time">
處理耗時: {{ calculateProcessingTime(job.processing_started_at, job.completed_at) }}
</div>
</el-timeline-item>
<el-timeline-item
v-else-if="job.status === 'FAILED'"
timestamp="處理失敗"
time="發生錯誤"
type="danger"
size="large"
icon="Close"
>
翻譯過程中發生錯誤
</el-timeline-item>
</el-timeline>
</div>
</div>
<!-- 成本統計 -->
<div class="content-card" v-if="job.total_cost > 0 || job.total_tokens > 0">
<div class="card-header">
<h3 class="card-title">成本統計</h3>
</div>
<div class="card-body">
<div class="cost-stats">
<div class="cost-item" v-if="job.total_tokens > 0">
<div class="cost-icon">
<el-icon><Coin /></el-icon>
</div>
<div class="cost-info">
<div class="cost-label">使用 Token</div>
<div class="cost-value">{{ job.total_tokens.toLocaleString() }}</div>
</div>
</div>
<div class="cost-item" v-if="job.total_cost > 0">
<div class="cost-icon">
<el-icon><Money /></el-icon>
</div>
<div class="cost-info">
<div class="cost-label">總成本</div>
<div class="cost-value">${{ job.total_cost.toFixed(6) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 檔案列表 -->
<div class="content-card" v-if="jobFiles.length > 0">
<div class="card-header">
<h3 class="card-title">相關檔案</h3>
</div>
<div class="card-body">
<div class="files-list">
<div
v-for="file in jobFiles"
:key="`${file.file_type}_${file.language_code || 'original'}`"
class="file-item"
>
<div class="file-icon" :class="getFileExtension(file.filename)">
{{ getFileExtension(file.filename).toUpperCase() }}
</div>
<div class="file-info">
<div class="file-name">{{ file.filename }}</div>
<div class="file-details">
<span class="file-size">{{ formatFileSize(file.file_size) }}</span>
<span class="file-type">
{{ file.file_type === 'ORIGINAL' ? '原始檔案' :
file.language_code === 'combined' ? '組合翻譯檔案 (多語言)' :
`翻譯檔案 (${getLanguageText(file.language_code)})` }}
</span>
</div>
</div>
<div class="file-actions">
<el-button
v-if="file.file_type === 'TRANSLATED'"
type="primary"
size="small"
@click="file.language_code === 'combined' ? downloadCombinedFile() : downloadFile(file.language_code, file.filename)"
>
<el-icon><Download /></el-icon>
下載
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { jobsAPI, filesAPI } from '@/services/jobs'
import { ElMessage } from 'element-plus'
import {
DocumentDelete, ArrowLeft, Refresh, Download, ArrowDown,
Document, Key, Switch, Rank, RefreshRight, Plus, Loading,
Check, Close, Coin, Money
} from '@element-plus/icons-vue'
import { websocketService } from '@/utils/websocket'
// Router 和 Store
const route = useRoute()
const router = useRouter()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const retrying = ref(false)
const job = ref(null)
const jobFiles = ref([])
// 語言映射
const languageMap = {
'auto': '自動偵測',
'zh-TW': '繁體中文',
'zh-CN': '簡體中文',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越南文'
}
// 計算屬性
const jobUuid = computed(() => route.params.uuid)
// 檢查是否有combined檔案
const hasCombinedFile = computed(() => {
return jobFiles.value.some(file =>
file.language_code === 'combined' ||
file.filename.toLowerCase().includes('combine')
)
})
// 方法
const loadJobDetail = async () => {
loading.value = true
try {
const response = await jobsStore.fetchJobDetail(jobUuid.value)
if (!response || !response.job) {
throw new Error('響應資料格式錯誤')
}
job.value = response.job
jobFiles.value = response.job.files || []
// 訂閱 WebSocket 狀態更新
if (['PENDING', 'PROCESSING', 'RETRY'].includes(job.value.status)) {
websocketService.subscribeToJob(jobUuid.value)
}
} catch (error) {
console.error('載入任務詳情失敗:', error)
ElMessage.error('載入任務詳情失敗')
} finally {
loading.value = false
}
}
const refreshJob = async () => {
await loadJobDetail()
ElMessage.success('任務資訊已刷新')
}
const retryJob = async () => {
retrying.value = true
try {
await jobsStore.retryJob(jobUuid.value)
await loadJobDetail()
ElMessage.success('任務已重新提交處理')
} catch (error) {
console.error('重試任務失敗:', error)
} finally {
retrying.value = false
}
}
const handleAction = async (command) => {
if (command.startsWith('download_')) {
const langCode = command.replace('download_', '')
if (langCode === 'all') {
await downloadAllFiles()
} else if (langCode === 'combined') {
await downloadCombinedFile()
} else {
await downloadFile(langCode)
}
}
}
const downloadFile = async (langCode, customFilename = null) => {
try {
const ext = getFileExtension(job.value.original_filename)
const filename = customFilename || `${job.value.original_filename.replace(/\.[^/.]+$/, '')}_${langCode}_translated.${ext}`
await jobsStore.downloadFile(jobUuid.value, langCode, filename)
} catch (error) {
console.error('下載檔案失敗:', error)
}
}
const downloadCombinedFile = async () => {
try {
// 使用新的 combine 下載 API
const response = await filesAPI.downloadCombineFile(jobUuid.value)
// 從響應頭獲取檔案名
let filename = 'combined_file.docx'
if (response.headers && response.headers['content-disposition']) {
const contentDisposition = response.headers['content-disposition']
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match) {
filename = match[1].replace(/['"]/g, '')
}
} else {
// 使用預設檔名或從任務資料獲取
const originalName = job.value.original_filename
if (originalName) {
const nameParts = originalName.split('.')
const baseName = nameParts.slice(0, -1).join('.')
const extension = nameParts[nameParts.length - 1]
filename = `combined_${baseName}.${extension}`
} else {
filename = 'combined_file.docx'
}
}
// 創建下載連結
const blobData = response.data || response
const url = window.URL.createObjectURL(new Blob([blobData]))
const link = document.createElement('a')
link.href = url
link.setAttribute('download', filename)
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
ElMessage.success('合併檔案下載成功')
} catch (error) {
console.error('下載合併檔案失敗:', error)
ElMessage.error('合併檔案下載失敗')
}
}
const downloadAllFiles = async () => {
try {
const filename = `${job.value.original_filename.replace(/\.[^/.]+$/, '')}_translated.zip`
await jobsStore.downloadAllFiles(jobUuid.value, filename)
} catch (error) {
console.error('批量下載失敗:', error)
}
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
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 formatDateTime = (timestamp) => {
if (!timestamp) return ''
return new Date(timestamp).toLocaleString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const calculateProcessingTime = (startTime, endTime) => {
const start = new Date(startTime)
const end = new Date(endTime)
const diff = end - start
const hours = Math.floor(diff / 3600000)
const minutes = Math.floor((diff % 3600000) / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
if (hours > 0) {
return `${hours}${minutes}${seconds}`
} else if (minutes > 0) {
return `${minutes}${seconds}`
} else {
return `${seconds}`
}
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待處理',
'PROCESSING': '處理中',
'COMPLETED': '已完成',
'FAILED': '處理失敗',
'RETRY': '重試中'
}
return statusMap[status] || status
}
const getStatusTagType = (status) => {
const typeMap = {
'PENDING': 'info',
'PROCESSING': 'warning',
'COMPLETED': 'success',
'FAILED': 'danger',
'RETRY': 'warning'
}
return typeMap[status] || 'info'
}
const getStatusIcon = (status) => {
const iconMap = {
'PENDING': 'Clock',
'PROCESSING': 'Loading',
'COMPLETED': 'SuccessFilled',
'FAILED': 'CircleCloseFilled',
'RETRY': 'RefreshRight'
}
return iconMap[status] || 'InfoFilled'
}
// WebSocket 狀態更新處理
const handleJobStatusUpdate = (update) => {
if (job.value && update.job_uuid === job.value.job_uuid) {
Object.assign(job.value, update)
}
}
// 生命週期
onMounted(async () => {
await loadJobDetail()
// 監聽 WebSocket 狀態更新
websocketService.on('job_status', handleJobStatusUpdate)
})
onUnmounted(() => {
// 取消訂閱 WebSocket
if (job.value) {
websocketService.unsubscribeFromJob(job.value.job_uuid)
}
websocketService.off('job_status', handleJobStatusUpdate)
})
</script>
<style lang="scss" scoped>
.job-detail-view {
.loading-wrapper {
padding: 40px;
}
.not-found {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
.not-found-content {
text-align: center;
.not-found-icon {
font-size: 64px;
color: var(--el-color-info);
margin-bottom: 16px;
}
h2 {
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
}
p {
margin: 0 0 24px 0;
color: var(--el-text-color-secondary);
}
}
}
.page-header {
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-button {
padding: 8px;
&:hover {
background-color: var(--el-color-primary-light-9);
}
}
.page-title {
margin: 0;
}
}
}
.job-status-badge {
.el-tag {
font-size: 14px;
padding: 8px 16px;
.el-icon {
margin-right: 4px;
}
}
}
.job-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 24px;
}
.info-section {
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.info-items {
.info-item {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.info-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.file-icon {
width: 32px;
height: 32px;
border-radius: 6px;
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; }
}
}
.info-content {
flex: 1;
min-width: 0;
.info-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
color: var(--el-text-color-primary);
font-weight: 500;
&.job-uuid {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
}
}
}
}
}
.progress-section {
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.progress-description {
margin-top: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.error-details {
.retry-info {
margin-top: 8px;
font-size: 13px;
opacity: 0.8;
}
}
.processing-time {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.cost-stats {
display: flex;
gap: 24px;
@media (max-width: 480px) {
flex-direction: column;
gap: 16px;
}
.cost-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background-color: var(--el-fill-color-lighter);
border-radius: 8px;
flex: 1;
.cost-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--el-color-warning-light-9);
color: var(--el-color-warning);
display: flex;
align-items: center;
justify-content: center;
}
.cost-info {
.cost-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.cost-value {
font-size: 16px;
font-weight: bold;
color: var(--el-text-color-primary);
}
}
}
}
.files-list {
.file-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.file-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
margin-right: 12px;
&.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: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-details {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
.file-actions {
margin-left: 16px;
}
}
}
}
</style>