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