Files
Document_translator_1panel/frontend/src/views/HistoryView.vue
beabigegg 6599716481 1panel
2025-10-03 08:19:40 +08:00

850 lines
25 KiB
Vue

<template>
<div class="history-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">歷史記錄</h1>
<div class="page-actions">
<el-button @click="exportHistory">
<el-icon><Download /></el-icon>
匯出記錄
</el-button>
</div>
</div>
<!-- 篩選區域 -->
<div class="content-card">
<div class="filters-section">
<div class="filters-row">
<div class="filter-group">
<label>時間範圍:</label>
<el-date-picker
v-model="dateRange"
type="daterange"
start-placeholder="開始日期"
end-placeholder="結束日期"
format="YYYY/MM/DD"
value-format="YYYY-MM-DD"
@change="handleDateRangeChange"
/>
</div>
<div class="filter-group">
<label>狀態:</label>
<el-select v-model="filters.status" @change="handleFilterChange">
<el-option label="全部" value="all" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="失敗" value="FAILED" />
</el-select>
</div>
<div class="filter-group">
<label>檔案類型:</label>
<el-select v-model="filters.fileType" @change="handleFilterChange">
<el-option label="全部" value="all" />
<el-option label="Word" value="doc" />
<el-option label="PowerPoint" value="ppt" />
<el-option label="Excel" value="xls" />
<el-option label="PDF" value="pdf" />
</el-select>
</div>
<div class="filter-actions">
<el-button @click="clearFilters">
<el-icon><Close /></el-icon>
清除篩選
</el-button>
</div>
</div>
<div class="search-row">
<el-input
v-model="filters.search"
placeholder="搜尋檔案名稱..."
style="width: 300px"
clearable
@input="handleSearchChange"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</div>
<!-- 統計概覽 -->
<div class="stats-section">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Files /></el-icon>
</div>
<div class="stat-value">{{ filteredJobs.length }}</div>
<div class="stat-label">總記錄數</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-value">{{ completedCount }}</div>
<div class="stat-label">成功完成</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><Money /></el-icon>
</div>
<div class="stat-value">${{ totalCost.toFixed(4) }}</div>
<div class="stat-label">總成本</div>
</div>
<div class="stat-card">
<div class="stat-icon info">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-value">{{ avgProcessingTime }}</div>
<div class="stat-label">平均處理時間</div>
</div>
</div>
</div>
<!-- 歷史記錄列表 -->
<div class="content-card">
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="filteredJobs.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<div class="empty-title">無歷史記錄</div>
<div class="empty-description">
在所選時間範圍內沒有找到符合條件的記錄
</div>
</div>
<div v-else>
<!-- 表格模式 -->
<div class="view-toggle">
<el-radio-group v-model="viewMode">
<el-radio-button label="table">表格檢視</el-radio-button>
<el-radio-button label="card">卡片檢視</el-radio-button>
</el-radio-group>
</div>
<!-- 表格檢視 -->
<div v-if="viewMode === 'table'" class="table-view">
<el-table :data="paginatedJobs" style="width: 100%">
<el-table-column prop="original_filename" label="檔案名稱" min-width="200">
<template #default="{ row }">
<div class="file-info">
<div class="file-icon" :class="getFileExtension(row.original_filename)">
{{ getFileExtension(row.original_filename).toUpperCase() }}
</div>
<span class="file-name">{{ row.original_filename }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="file_size" label="檔案大小" width="100">
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column prop="target_languages" label="翻譯語言" width="150">
<template #default="{ row }">
<div class="language-tags">
<el-tag
v-for="lang in row.target_languages.slice(0, 2)"
:key="lang"
size="small"
type="primary"
>
{{ getLanguageText(lang) }}
</el-tag>
<el-tag v-if="row.target_languages.length > 2" size="small" type="info">
+{{ row.target_languages.length - 2 }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="狀態" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_cost" label="成本" width="100">
<template #default="{ row }">
${{ (row.total_cost || 0).toFixed(4) }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="建立時間" width="130">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="completed_at" label="完成時間" width="130">
<template #default="{ row }">
{{ row.completed_at ? formatDate(row.completed_at) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button type="text" size="small" @click="viewJobDetail(row.job_uuid)">
查看
</el-button>
<el-button
v-if="row.status === 'COMPLETED'"
type="text"
size="small"
@click="downloadJob(row)"
>
下載
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 卡片檢視 -->
<div v-else class="card-view">
<div class="history-cards">
<div
v-for="job in paginatedJobs"
:key="job.job_uuid"
class="history-card"
@click="viewJobDetail(job.job_uuid)"
>
<div class="card-header">
<div class="file-info">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
<div class="file-details">
<div class="file-name">{{ job.original_filename }}</div>
<div class="file-meta">
{{ formatFileSize(job.file_size) }}
{{ formatDate(job.created_at) }}
</div>
</div>
</div>
<div class="card-status">
<el-tag :type="getStatusTagType(job.status)" size="small">
{{ getStatusText(job.status) }}
</el-tag>
</div>
</div>
<div class="card-content">
<div class="languages-section">
<div class="language-label">翻譯語言:</div>
<div class="language-tags">
<span
v-for="lang in job.target_languages"
:key="lang"
class="language-tag"
>
{{ getLanguageText(lang) }}
</span>
</div>
</div>
<div class="stats-section">
<div class="stat-item" v-if="job.total_cost > 0">
<span class="stat-label">成本:</span>
<span class="stat-value">${{ job.total_cost.toFixed(4) }}</span>
</div>
<div class="stat-item" v-if="job.total_tokens > 0">
<span class="stat-label">Token:</span>
<span class="stat-value">{{ job.total_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
<div class="card-footer" v-if="job.completed_at || job.processing_started_at">
<div class="time-info">
<div v-if="job.processing_started_at && job.completed_at">
處理時間: {{ calculateProcessingTime(job.processing_started_at, job.completed_at) }}
</div>
<div v-if="job.completed_at">
完成時間: {{ formatTime(job.completed_at) }}
</div>
</div>
<div class="card-actions" @click.stop>
<el-button
v-if="job.status === 'COMPLETED'"
type="primary"
size="small"
@click="downloadJob(job)"
>
下載
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 分頁 -->
<div class="pagination-section" v-if="totalPages > 1">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="filteredJobs.length"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage } from 'element-plus'
import { debounce } from 'lodash-es'
import {
Download, Close, Search, Files, SuccessFilled, Money, Clock, Document
} from '@element-plus/icons-vue'
// Router 和 Store
const router = useRouter()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const viewMode = ref('table')
const dateRange = ref([])
const currentPage = ref(1)
const pageSize = ref(20)
const filters = ref({
status: 'all',
fileType: 'all',
search: ''
})
// 語言映射
const languageMap = {
'zh-TW': '繁中',
'zh-CN': '簡中',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越文'
}
// 計算屬性
const allJobs = computed(() => jobsStore.jobs.filter(job =>
job.status === 'COMPLETED' || job.status === 'FAILED'
))
const filteredJobs = computed(() => {
let jobs = allJobs.value
// 狀態篩選
if (filters.value.status !== 'all') {
jobs = jobs.filter(job => job.status === filters.value.status)
}
// 檔案類型篩選
if (filters.value.fileType !== 'all') {
jobs = jobs.filter(job => {
const ext = getFileExtension(job.original_filename)
switch (filters.value.fileType) {
case 'doc': return ['docx', 'doc'].includes(ext)
case 'ppt': return ['pptx', 'ppt'].includes(ext)
case 'xls': return ['xlsx', 'xls'].includes(ext)
case 'pdf': return ext === 'pdf'
default: return true
}
})
}
// 日期範圍篩選
if (dateRange.value && dateRange.value.length === 2) {
const [startDate, endDate] = dateRange.value
jobs = jobs.filter(job => {
const jobDate = new Date(job.created_at).toDateString()
return jobDate >= new Date(startDate).toDateString() &&
jobDate <= new Date(endDate).toDateString()
})
}
// 搜尋篩選
if (filters.value.search.trim()) {
const searchTerm = filters.value.search.toLowerCase().trim()
jobs = jobs.filter(job =>
job.original_filename.toLowerCase().includes(searchTerm)
)
}
return jobs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
})
const paginatedJobs = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredJobs.value.slice(start, start + pageSize.value)
})
const totalPages = computed(() => Math.ceil(filteredJobs.value.length / pageSize.value))
const completedCount = computed(() =>
filteredJobs.value.filter(job => job.status === 'COMPLETED').length
)
const totalCost = computed(() =>
filteredJobs.value.reduce((sum, job) => sum + (job.total_cost || 0), 0)
)
const avgProcessingTime = computed(() => {
const completedJobs = filteredJobs.value.filter(job =>
job.status === 'COMPLETED' && job.processing_started_at && job.completed_at
)
if (completedJobs.length === 0) return '無資料'
const totalMs = completedJobs.reduce((sum, job) => {
const startTime = new Date(job.processing_started_at)
const endTime = new Date(job.completed_at)
return sum + (endTime - startTime)
}, 0)
const avgMs = totalMs / completedJobs.length
const minutes = Math.floor(avgMs / 60000)
const seconds = Math.floor((avgMs % 60000) / 1000)
return `${minutes}${seconds}`
})
// 方法
const handleFilterChange = () => {
currentPage.value = 1
}
const handleSearchChange = debounce(() => {
currentPage.value = 1
}, 300)
const handleDateRangeChange = () => {
currentPage.value = 1
}
const handlePageChange = (page) => {
currentPage.value = page
}
const clearFilters = () => {
filters.value.status = 'all'
filters.value.fileType = 'all'
filters.value.search = ''
dateRange.value = []
currentPage.value = 1
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const downloadJob = async (job) => {
try {
if (job.target_languages.length === 1) {
const originalExt = getFileExtension(job.original_filename)
const translatedExt = getTranslatedFileExtension(originalExt)
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${translatedExt}`
await jobsStore.downloadFile(job.job_uuid, job.target_languages[0], filename)
} else {
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_translated.zip`
await jobsStore.downloadAllFiles(job.job_uuid, filename)
}
} catch (error) {
console.error('下載失敗:', error)
}
}
const exportHistory = () => {
// 匯出 CSV 格式的歷史記錄
const csvContent = [
['檔案名稱', '檔案大小', '目標語言', '狀態', '成本', '建立時間', '完成時間'].join(','),
...filteredJobs.value.map(job => [
`"${job.original_filename}"`,
formatFileSize(job.file_size),
`"${job.target_languages.join(', ')}"`,
getStatusText(job.status),
(job.total_cost || 0).toFixed(4),
formatDate(job.created_at),
job.completed_at ? formatDate(job.completed_at) : ''
].join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `翻譯歷史記錄_${new Date().toISOString().slice(0, 10)}.csv`)
link.click()
ElMessage.success('歷史記錄已匯出')
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const getTranslatedFileExtension = (originalExt) => {
// PDF 翻譯後變成 DOCX
if (originalExt === 'pdf') {
return 'docx'
}
// 其他格式保持不變
return originalExt
}
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 formatDate = (timestamp) => {
return new Date(timestamp).toLocaleDateString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const formatTime = (timestamp) => {
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 86400000) return '今天'
if (diff < 172800000) return '昨天'
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`
return time.toLocaleDateString('zh-TW')
}
const calculateProcessingTime = (startTime, endTime) => {
const start = new Date(startTime)
const end = new Date(endTime)
const diff = end - start
const minutes = Math.floor(diff / 60000)
const seconds = Math.floor((diff % 60000) / 1000)
return `${minutes}${seconds}`
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'COMPLETED': '已完成',
'FAILED': '失敗'
}
return statusMap[status] || status
}
const getStatusTagType = (status) => {
const typeMap = {
'COMPLETED': 'success',
'FAILED': 'danger'
}
return typeMap[status] || 'info'
}
// 生命週期
onMounted(async () => {
loading.value = true
try {
await jobsStore.fetchJobs({ per_page: 100 })
} catch (error) {
console.error('載入歷史記錄失敗:', error)
} finally {
loading.value = false
}
})
// 監聽檢視模式變化,重置分頁
watch(viewMode, () => {
currentPage.value = 1
})
</script>
<style lang="scss" scoped>
.history-view {
.filters-section {
.filters-row {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 16px;
flex-wrap: wrap;
.filter-group {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 14px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
}
.filter-actions {
margin-left: auto;
@media (max-width: 768px) {
margin-left: 0;
width: 100%;
}
}
}
.search-row {
display: flex;
justify-content: flex-start;
}
}
.stats-section {
margin: 24px 0;
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
}
.view-toggle {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
.table-view {
.file-info {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: bold;
color: white;
flex-shrink: 0;
&.docx, &.doc { background-color: #2b579a; }
&.pptx, &.ppt { background-color: #d24726; }
&.xlsx, &.xls { background-color: #207245; }
&.pdf { background-color: #ff0000; }
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.table-actions {
display: flex;
gap: 8px;
}
}
.card-view {
.history-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 16px;
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.history-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px;
background: var(--el-bg-color);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.file-info {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
.file-icon {
width: 36px;
height: 36px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
flex-shrink: 0;
&.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: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
.card-content {
margin-bottom: 12px;
.languages-section {
margin-bottom: 8px;
.language-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
.language-tag {
display: inline-block;
padding: 2px 6px;
background-color: var(--el-color-primary-light-8);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
}
}
.stats-section {
display: flex;
gap: 16px;
font-size: 12px;
.stat-item {
display: flex;
gap: 4px;
.stat-label {
color: var(--el-text-color-secondary);
}
.stat-value {
color: var(--el-text-color-primary);
font-weight: 500;
}
}
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
.time-info {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
}
}
}
}
.pagination-section {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.loading-state {
padding: 40px 0;
}
</style>