This commit is contained in:
beabigegg
2025-09-04 18:34:05 +08:00
parent f093f4bbc2
commit 6eabdb2f07
35 changed files with 1097 additions and 212 deletions

View File

@@ -90,6 +90,15 @@ const routes = [
requiresAdmin: true,
showInMenu: true
}
},
{
path: '/admin/jobs',
name: 'AdminJobs',
component: () => import('@/views/AdminJobsView.vue'),
meta: {
title: '全部任務',
requiresAdmin: true
}
}
]
},

View File

@@ -119,5 +119,21 @@ export const adminAPI = {
cache_days: 90
}
return request.post('/admin/maintenance/cleanup', { ...defaultOptions, ...options })
},
/**
* 管理員取消任務
* @param {string} jobUuid - 任務 UUID
*/
adminCancelJob(jobUuid) {
return request.post(`/admin/jobs/${jobUuid}/cancel`)
},
/**
* 管理員刪除任務
* @param {string} jobUuid - 任務 UUID
*/
adminDeleteJob(jobUuid) {
return request.delete(`/admin/jobs/${jobUuid}`)
}
}

View File

@@ -264,7 +264,11 @@ export const useAdminStore = defineStore('admin', {
try {
const response = await adminAPI.getSystemMetrics()
if (response.success || response.jobs) {
if (response.success && response.data) {
this.systemMetrics = response.data
return response.data
} else if (response.jobs) {
// 兼容舊格式
this.systemMetrics = response
return response
}

View File

@@ -169,7 +169,11 @@ export const useJobsStore = defineStore('jobs', {
if (response.success) {
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], status: 'CANCELLED' }
this.jobs[jobIndex] = {
...this.jobs[jobIndex],
status: 'FAILED',
error_message: '使用者取消任務'
}
}
ElMessage.success('任務已取消')

View File

@@ -0,0 +1,538 @@
<template>
<div class="admin-jobs-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">全部任務管理</h1>
<div class="page-actions">
<el-button @click="refreshJobs" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 篩選條件 -->
<div class="filter-section">
<div class="content-card">
<div class="card-body">
<div class="filter-row">
<div class="filter-item">
<label>用戶</label>
<el-select v-model="filters.user_id" @change="handleFilterChange" clearable placeholder="選擇用戶">
<el-option label="全部用戶" value="all" />
<el-option
v-for="user in users"
:key="user.id"
:label="user.display_name || user.username"
:value="user.id"
/>
</el-select>
</div>
<div class="filter-item">
<label>狀態</label>
<el-select v-model="filters.status" @change="handleFilterChange" clearable placeholder="選擇狀態">
<el-option label="全部狀態" value="all" />
<el-option label="等待中" value="PENDING" />
<el-option label="處理中" value="PROCESSING" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="失敗" value="FAILED" />
<el-option label="重試" value="RETRY" />
</el-select>
</div>
<div class="filter-item">
<label>檔案名搜尋</label>
<el-input
v-model="filters.search"
@change="handleFilterChange"
placeholder="輸入檔案名"
clearable
/>
</div>
</div>
</div>
</div>
</div>
<!-- 任務列表 -->
<div class="jobs-section">
<div class="content-card">
<div class="card-header">
<h3 class="card-title">任務列表</h3>
<div class="card-info">
{{ pagination.total }} 個任務
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="jobs.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<div class="empty-title">暫無任務記錄</div>
</div>
<div v-else class="jobs-table">
<el-table :data="jobs" 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 label="用戶" width="120">
<template #default="{ row }">
{{ row.user?.display_name || row.user?.username || '未知用戶' }}
</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"
:key="lang"
size="small"
type="primary"
>
{{ getLanguageText(lang) }}
</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="80">
<template #default="{ row }">
${{ (row.total_cost || 0).toFixed(4) }}
</template>
</el-table-column>
<el-table-column prop="created_at" label="建立時間" width="120">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button
type="text"
size="small"
@click="viewJobDetail(row.job_uuid)"
>
查看
</el-button>
<el-button
v-if="row.status === 'PENDING' || row.status === 'PROCESSING'"
type="text"
size="small"
@click="cancelJob(row.job_uuid)"
>
取消
</el-button>
<el-button
type="text"
size="small"
@click="deleteJob(row.job_uuid)"
>
刪除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分頁 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.per_page"
:page-sizes="[20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { adminAPI } from '@/services/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Refresh, Document
} from '@element-plus/icons-vue'
// Router
const router = useRouter()
// 響應式數據
const loading = ref(false)
const jobs = ref([])
const users = ref([])
const pagination = ref({
page: 1,
per_page: 20,
total: 0,
pages: 0
})
const filters = ref({
user_id: 'all',
status: 'all',
search: ''
})
// 語言映射
const languageMap = {
'zh-TW': '繁體中文',
'zh-CN': '簡體中文',
'en': '英語',
'ja': '日語',
'ko': '韓語',
'es': '西班牙語',
'fr': '法語',
'de': '德語',
'pt': '葡萄牙語',
'ru': '俄語',
'ar': '阿拉伯語',
'hi': '印地語',
'th': '泰語',
'vi': '越南語',
'it': '義大利語',
'nl': '荷蘭語'
}
// 方法
const fetchJobs = async () => {
try {
loading.value = true
const params = {
page: pagination.value.page,
per_page: pagination.value.per_page,
status: filters.value.status,
search: filters.value.search
}
// 只有選擇特定用戶時才加入 user_id 參數
if (filters.value.user_id !== 'all' && filters.value.user_id) {
params.user_id = parseInt(filters.value.user_id)
}
const response = await adminAPI.getAllJobs(params)
if (response.success) {
jobs.value = response.data.jobs || []
pagination.value = response.data.pagination || pagination.value
}
} catch (error) {
console.error('取得任務列表失敗:', error)
ElMessage.error('載入任務列表失敗')
} finally {
loading.value = false
}
}
const fetchUsers = async () => {
try {
const response = await adminAPI.getUsers()
if (response.success) {
users.value = response.data.users || []
}
} catch (error) {
console.error('取得用戶列表失敗:', error)
}
}
const refreshJobs = async () => {
await fetchJobs()
}
const handleFilterChange = () => {
pagination.value.page = 1
fetchJobs()
}
const handlePageChange = () => {
fetchJobs()
}
const handleSizeChange = () => {
pagination.value.page = 1
fetchJobs()
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const cancelJob = async (jobUuid) => {
try {
await ElMessageBox.confirm(
'確定要取消這個任務嗎?',
'取消任務',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminAPI.adminCancelJob(jobUuid)
if (response.success) {
ElMessage.success('任務已取消')
await refreshJobs()
}
} catch (error) {
if (error !== 'cancel') {
console.error('取消任務失敗:', error)
ElMessage.error(error.response?.data?.message || '取消任務失敗')
}
}
}
const deleteJob = async (jobUuid) => {
try {
await ElMessageBox.confirm(
'確定要刪除這個任務嗎?刪除後將無法恢復',
'刪除任務',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminAPI.adminDeleteJob(jobUuid)
if (response.success) {
ElMessage.success('任務已刪除')
await refreshJobs()
}
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
ElMessage.error(error.response?.data?.message || '刪除任務失敗')
}
}
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
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': 'primary',
'COMPLETED': 'success',
'FAILED': 'danger',
'RETRY': 'warning'
}
return typeMap[status] || 'info'
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const now = new Date()
const time = new Date(timestamp)
const diff = now - time
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}時前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`
return time.toLocaleDateString('zh-TW')
}
// 生命週期
onMounted(async () => {
await Promise.all([
fetchUsers(),
fetchJobs()
])
})
</script>
<style lang="scss" scoped>
.admin-jobs-view {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
}
.page-actions {
display: flex;
gap: 12px;
}
}
.filter-section {
margin-bottom: 24px;
.filter-row {
display: grid;
grid-template-columns: 200px 150px 1fr;
gap: 16px;
align-items: end;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
}
}
}
}
.jobs-section {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-info {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
.jobs-table {
.file-info {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
width: 32px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
background: var(--el-color-primary);
flex-shrink: 0;
}
.file-name {
word-break: break-all;
line-height: 1.4;
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.pagination-wrapper {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--el-text-color-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 16px;
margin-bottom: 8px;
}
}
.loading-state {
padding: 24px;
}
}
}
.content-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.card-header {
padding: 20px 24px 0;
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
}
}
.card-body {
padding: 20px 24px;
}
}
</style>

View File

@@ -308,7 +308,7 @@
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button
type="text"
@@ -317,6 +317,21 @@
>
查看
</el-button>
<el-button
v-if="row.status === 'PENDING' || row.status === 'PROCESSING'"
type="text"
size="small"
@click="cancelJob(row.job_uuid)"
>
取消
</el-button>
<el-button
type="text"
size="small"
@click="deleteJob(row.job_uuid)"
>
刪除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -331,7 +346,8 @@
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { ElMessage } from 'element-plus'
import { adminAPI } from '@/services/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
import {
Download, ArrowDown, Refresh, DataBoard, SuccessFilled,
@@ -473,6 +489,56 @@ const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const cancelJob = async (jobUuid) => {
try {
await ElMessageBox.confirm(
'確定要取消這個任務嗎?',
'取消任務',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminAPI.adminCancelJob(jobUuid)
if (response.success) {
ElMessage.success('任務已取消')
await refreshData()
}
} catch (error) {
if (error !== 'cancel') {
console.error('取消任務失敗:', error)
ElMessage.error(error.response?.data?.message || '取消任務失敗')
}
}
}
const deleteJob = async (jobUuid) => {
try {
await ElMessageBox.confirm(
'確定要刪除這個任務嗎?刪除後將無法恢復',
'刪除任務',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await adminAPI.adminDeleteJob(jobUuid)
if (response.success) {
ElMessage.success('任務已刪除')
await refreshData()
}
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
ElMessage.error(error.response?.data?.message || '刪除任務失敗')
}
}
}
const initCharts = () => {
initDailyChart()
initCostChart()
@@ -502,14 +568,8 @@ const initDailyChart = () => {
const dates = dailyStats.value.map(stat => stat?.date || 'N/A')
const jobs = dailyStats.value.map(stat => stat?.jobs || 0)
const completed = dailyStats.value.map(stat => stat?.completed || 0)
// 注意:後端可能沒有提供 failed 數據,所以計算或預設為 0
const failed = dailyStats.value.map(stat => {
if (stat?.failed !== undefined) {
return stat.failed
}
// 如果沒有 failed 數據,可以計算為 total - completed或預設為 0
return Math.max(0, (stat?.jobs || 0) - (stat?.completed || 0))
})
// 使用後端提供failed欄位,如果沒有則預設為0
const failed = dailyStats.value.map(stat => stat?.failed || 0)
const option = {
title: {

View File

@@ -163,6 +163,12 @@
>
重新翻譯
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'PENDING' || job.status === 'PROCESSING'"
command="cancel"
>
取消任務
</el-dropdown-item>
<el-dropdown-item command="delete" divided>刪除</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -251,6 +257,27 @@ const handleJobAction = async (action, job) => {
}
break
case 'cancel':
try {
const statusText = job.status === 'PROCESSING' ? '處理中' : '等待中'
await ElMessageBox.confirm(
`確定要取消這個${statusText}的任務嗎?`,
'確認取消',
{
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
}
)
await jobsStore.cancelJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('取消任務失敗:', error)
}
}
break
case 'delete':
try {
await ElMessageBox.confirm('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {