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,917 @@
<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>