backup
This commit is contained in:
917
frontend/src/views/JobDetailView.vue
Normal file
917
frontend/src/views/JobDetailView.vue
Normal 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>
|
Reference in New Issue
Block a user