917 lines
26 KiB
Vue
917 lines
26 KiB
Vue
<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> |