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

@@ -1 +0,0 @@
.admin-view .overview-section[data-v-706b47d1]{margin-bottom:24px}.admin-view .overview-section .stats-grid[data-v-706b47d1]{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.admin-view .overview-section .stats-grid .stat-total[data-v-706b47d1]{font-size:12px;color:var(--el-text-color-secondary);margin-top:4px}.admin-view .charts-section[data-v-706b47d1]{margin-bottom:24px}.admin-view .charts-section .chart-row[data-v-706b47d1]{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width: 1200px){.admin-view .charts-section .chart-row[data-v-706b47d1]{grid-template-columns:1fr}}.admin-view .charts-section .chart-row .chart-card .chart-container[data-v-706b47d1]{height:300px;width:100%}.admin-view .info-section[data-v-706b47d1]{margin-bottom:24px}.admin-view .info-section .info-row[data-v-706b47d1]{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width: 768px){.admin-view .info-section .info-row[data-v-706b47d1]{grid-template-columns:1fr}}.admin-view .info-section .user-rankings .ranking-item[data-v-706b47d1]{display:flex;align-items:center;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.admin-view .info-section .user-rankings .ranking-item[data-v-706b47d1]:last-child{border-bottom:none}.admin-view .info-section .user-rankings .ranking-item .ranking-position[data-v-706b47d1]{margin-right:16px}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number[data-v-706b47d1]{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;background-color:var(--el-color-info)}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number.gold[data-v-706b47d1]{background:linear-gradient(45deg,#ffd700,#ffed4e);color:#8b4513}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number.silver[data-v-706b47d1]{background:linear-gradient(45deg,#c0c0c0,#e8e8e8);color:#666}.admin-view .info-section .user-rankings .ranking-item .ranking-position .position-number.bronze[data-v-706b47d1]{background:linear-gradient(45deg,#cd7f32,#daa520);color:#fff}.admin-view .info-section .user-rankings .ranking-item .user-info[data-v-706b47d1]{flex:1;min-width:0}.admin-view .info-section .user-rankings .ranking-item .user-info .user-name[data-v-706b47d1]{font-weight:600;color:var(--el-text-color-primary);margin-bottom:4px}.admin-view .info-section .user-rankings .ranking-item .user-info .user-stats[data-v-706b47d1]{display:flex;gap:16px;font-size:13px;color:var(--el-text-color-secondary)}.admin-view .info-section .user-rankings .ranking-item .ranking-progress[data-v-706b47d1]{width:80px;margin-left:16px}.admin-view .info-section .system-health .health-item[data-v-706b47d1]{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--el-border-color-lighter)}.admin-view .info-section .system-health .health-item[data-v-706b47d1]:last-child{border-bottom:none}.admin-view .info-section .system-health .health-item .health-label[data-v-706b47d1]{color:var(--el-text-color-regular)}.admin-view .info-section .system-health .health-item .health-value[data-v-706b47d1]{font-weight:500;color:var(--el-text-color-primary)}.admin-view .recent-jobs-section .file-info[data-v-706b47d1]{display:flex;align-items:center;gap:8px}.admin-view .recent-jobs-section .file-info .file-icon[data-v-706b47d1]{width:24px;height:24px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:700;color:#fff;flex-shrink:0}.admin-view .recent-jobs-section .file-info .file-icon.docx[data-v-706b47d1],.admin-view .recent-jobs-section .file-info .file-icon.doc[data-v-706b47d1]{background-color:#2b579a}.admin-view .recent-jobs-section .file-info .file-icon.pptx[data-v-706b47d1],.admin-view .recent-jobs-section .file-info .file-icon.ppt[data-v-706b47d1]{background-color:#d24726}.admin-view .recent-jobs-section .file-info .file-icon.xlsx[data-v-706b47d1],.admin-view .recent-jobs-section .file-info .file-icon.xls[data-v-706b47d1]{background-color:#207245}.admin-view .recent-jobs-section .file-info .file-icon.pdf[data-v-706b47d1]{background-color:red}.admin-view .recent-jobs-section .file-info .file-name[data-v-706b47d1]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.admin-view .recent-jobs-section .language-tags[data-v-706b47d1]{display:flex;flex-wrap:wrap;gap:4px}.loading-state[data-v-706b47d1]{padding:20px 0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.error-message[data-v-17157d64]{margin-top:16px}.login-tips[data-v-17157d64]{margin-top:24px}.login-tips[data-v-17157d64] .el-alert__content p{margin:4px 0;font-size:13px;line-height:1.4}.login-tips[data-v-17157d64] .el-alert__content p:first-child{margin-top:0}.login-tips[data-v-17157d64] .el-alert__content p:last-child{margin-bottom:0}@media (max-width: 480px){.login-layout[data-v-17157d64]{padding:16px}.login-layout .login-container[data-v-17157d64]{max-width:100%}.login-layout .login-container .login-header[data-v-17157d64]{padding:24px}.login-layout .login-container .login-header .login-logo[data-v-17157d64]{width:48px;height:48px;margin-bottom:16px}.login-layout .login-container .login-header .login-title[data-v-17157d64]{font-size:20px;margin-bottom:8px}.login-layout .login-container .login-header .login-subtitle[data-v-17157d64]{font-size:13px}.login-layout .login-container .login-body[data-v-17157d64]{padding:24px}.login-layout .login-container .login-footer[data-v-17157d64]{padding:16px 24px;font-size:12px}}.loading[data-v-17157d64]{pointer-events:none;opacity:.7}.login-container[data-v-17157d64]{animation:slideInUp-17157d64 .5s ease-out}@keyframes slideInUp-17157d64{0%{transform:translateY(30px);opacity:0}to{transform:translateY(0);opacity:1}}[data-v-17157d64] .el-form-item__label{color:var(--el-text-color-primary);font-weight:500}[data-v-17157d64] .el-input__inner{border-radius:6px}[data-v-17157d64] .el-button{border-radius:6px;font-weight:500}[data-v-17157d64] .el-checkbox__label{font-size:14px;color:var(--el-text-color-regular)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/png" href="/panjit-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PANJIT Document Translator</title>
<meta name="description" content="PANJIT Document Translator Web System - 企業級文件批量翻譯管理系統" />
@@ -34,8 +34,8 @@
100% { transform: rotate(360deg); }
}
</style>
<script type="module" crossorigin src="/js/index-cb898b04.js"></script>
<link rel="stylesheet" href="/css/index-f9b7dc59.css">
<script type="module" crossorigin src="/js/index-fa5efca2.js"></script>
<link rel="stylesheet" href="/css/index-fda0a621.css">
</head>
<body>
<div id="app">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{_ as T}from"./_plugin-vue_export-helper-af00840d.js";/* empty css *//* empty css *//* empty css *//* empty css */import{u as L,r as g,a as N,o as D,w as F,b as P,c as A,d as a,e as s,f as r,g as S,h as z,i as B,E as C,j as J,k as R,l as V,m as b,n as U,p as q,q as M,s as x,t as j,v as K,x as $,y as G,z as Z}from"./index-cb898b04.js";const H={class:"login-layout"},O={class:"login-container"},Q={class:"login-header"},W={class:"login-logo"},X={class:"login-body"},Y={key:0,class:"error-message"},ee={class:"login-tips"},se={__name:"LoginView",setup(le){const f=B(),v=L(),w=g(),n=g(!1),c=g(!1),l=g(""),o=N({username:"",password:""}),I={username:[{required:!0,message:"請輸入 AD 帳號",trigger:"blur"},{min:3,message:"帳號長度不能少於3個字元",trigger:"blur"},{pattern:/^[a-zA-Z0-9._@-]+$/,message:"帳號格式不正確,只能包含字母、數字、點、下劃線、@符號和連字符",trigger:"blur"}],password:[{required:!0,message:"請輸入密碼",trigger:"blur"},{min:1,message:"密碼不能為空",trigger:"blur"}]},h=async()=>{var _,e,u,m,i;try{if(l.value="",!await w.value.validate())return;n.value=!0;const d={username:o.username.trim(),password:o.password};d.username.includes("@")||(d.username=`${d.username}@panjit.com.tw`),await v.login(d),c.value&&localStorage.setItem("rememberLogin","true"),f.push("/")}catch(t){console.error("登入失敗:",t),((_=t.response)==null?void 0:_.status)===401?l.value="帳號或密碼錯誤,請重新輸入":((e=t.response)==null?void 0:e.status)===403?l.value="您的帳號沒有權限存取此系統":((u=t.response)==null?void 0:u.status)===500?l.value="伺服器錯誤,請稍後再試":(m=t.message)!=null&&m.includes("LDAP")?l.value="AD 伺服器連接失敗,請聯繫 IT 部門":(i=t.message)!=null&&i.includes("network")?l.value="網路連接失敗,請檢查網路設定":l.value=t.message||"登入失敗,請重試",o.password="",setTimeout(()=>{l.value=""},5e3)}finally{n.value=!1}},k=()=>{l.value=""};return D(()=>{if(v.isAuthenticated){f.push("/");return}localStorage.getItem("rememberLogin")==="true"&&(c.value=!0),v.checkAuth().then(u=>{u&&f.push("/")}).catch(()=>{});const e=F([()=>o.username,()=>o.password],()=>{l.value&&k()});P(()=>{e()})}),(_,e)=>{const u=C,m=K,i=$,t=G,d=Z,E=J,y=R;return V(),A("div",H,[a("div",O,[a("div",Q,[a("div",W,[s(u,null,{default:r(()=>[s(b(U))]),_:1})]),e[3]||(e[3]=a("h1",{class:"login-title"},"PANJIT 翻譯系統",-1)),e[4]||(e[4]=a("p",{class:"login-subtitle"},"企業級文件批量翻譯管理系統",-1))]),a("div",X,[s(E,{ref_key:"loginFormRef",ref:w,model:o,rules:I,onKeyup:S(h,["enter"]),"label-position":"top",size:"large"},{default:r(()=>[s(i,{label:"AD 帳號",prop:"username"},{default:r(()=>[s(m,{modelValue:o.username,"onUpdate:modelValue":e[0]||(e[0]=p=>o.username=p),placeholder:"請輸入您的 AD 帳號","prefix-icon":b(q),clearable:"",disabled:n.value},null,8,["modelValue","prefix-icon","disabled"])]),_:1}),s(i,{label:"密碼",prop:"password"},{default:r(()=>[s(m,{modelValue:o.password,"onUpdate:modelValue":e[1]||(e[1]=p=>o.password=p),type:"password",placeholder:"請輸入密碼","prefix-icon":b(M),"show-password":"",clearable:"",disabled:n.value},null,8,["modelValue","prefix-icon","disabled"])]),_:1}),s(i,null,{default:r(()=>[s(t,{modelValue:c.value,"onUpdate:modelValue":e[2]||(e[2]=p=>c.value=p),disabled:n.value},{default:r(()=>[...e[5]||(e[5]=[x(" 記住登入狀態 ",-1)])]),_:1},8,["modelValue","disabled"])]),_:1}),s(i,null,{default:r(()=>[s(d,{type:"primary",size:"large",loading:n.value,disabled:!o.username||!o.password,onClick:h,style:{width:"100%"}},{default:r(()=>[x(j(n.value?"登入中...":"登入"),1)]),_:1},8,["loading","disabled"])]),_:1})]),_:1},8,["model"]),l.value?(V(),A("div",Y,[s(y,{title:l.value,type:"error",closable:!1,"show-icon":""},null,8,["title"])])):z("",!0),a("div",ee,[s(y,{title:"登入說明",type:"info",closable:!1,"show-icon":""},{default:r(()=>[...e[6]||(e[6]=[a("p",null,"請使用您的 PANJIT AD 域帳號登入系統。",-1),a("p",null,"如果您忘記密碼或遇到登入問題,請聯繫 IT 部門協助。",-1)])]),_:1})])]),e[7]||(e[7]=a("div",{class:"login-footer"},[a("p",null,"© 2024 PANJIT Group. All rights reserved."),a("p",null,"Powered by PANJIT IT Team")],-1))])])}}},ue=T(se,[["__scopeId","data-v-17157d64"]]);export{ue as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{_ as r}from"./_plugin-vue_export-helper-af00840d.js";import{c as f,d as s,e as l,f as o,i as k,E as v,z as p,H as m,l as w,m as i,aK as g,aL as x,s as c,aC as y,W as N,X as V,_ as B,p as C}from"./index-cb898b04.js";const E={class:"not-found-view"},b={class:"not-found-container"},h={class:"not-found-illustration"},z={class:"error-icon"},F={class:"not-found-content"},H={class:"error-actions"},I={class:"helpful-links"},j={class:"links-grid"},q={class:"link-icon"},K={class:"link-icon"},L={class:"link-icon"},R={class:"link-icon"},T={__name:"NotFoundView",setup(W){const d=k(),u=()=>{d.push("/")},_=()=>{window.history.length>1?d.back():d.push("/")};return(X,t)=>{const n=v,a=p,e=m("router-link");return w(),f("div",E,[s("div",b,[s("div",h,[t[0]||(t[0]=s("div",{class:"error-code"},"404",-1)),s("div",z,[l(n,null,{default:o(()=>[l(i(g))]),_:1})])]),s("div",F,[t[3]||(t[3]=s("h1",{class:"error-title"},"頁面不存在",-1)),t[4]||(t[4]=s("p",{class:"error-description"}," 抱歉,您訪問的頁面不存在或已被移除。 ",-1)),s("div",H,[l(a,{type:"primary",size:"large",onClick:u},{default:o(()=>[l(n,null,{default:o(()=>[l(i(x))]),_:1}),t[1]||(t[1]=c(" 回到首頁 ",-1))]),_:1}),l(a,{size:"large",onClick:_},{default:o(()=>[l(n,null,{default:o(()=>[l(i(y))]),_:1}),t[2]||(t[2]=c(" 返回上頁 ",-1))]),_:1})])]),s("div",I,[t[9]||(t[9]=s("h3",null,"您可能在尋找:",-1)),s("div",j,[l(e,{to:"/upload",class:"link-card"},{default:o(()=>[s("div",q,[l(n,null,{default:o(()=>[l(i(N))]),_:1})]),t[5]||(t[5]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"檔案上傳"),s("div",{class:"link-desc"},"上傳新的檔案進行翻譯")],-1))]),_:1}),l(e,{to:"/jobs",class:"link-card"},{default:o(()=>[s("div",K,[l(n,null,{default:o(()=>[l(i(V))]),_:1})]),t[6]||(t[6]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"任務列表"),s("div",{class:"link-desc"},"查看您的翻譯任務")],-1))]),_:1}),l(e,{to:"/history",class:"link-card"},{default:o(()=>[s("div",L,[l(n,null,{default:o(()=>[l(i(B))]),_:1})]),t[7]||(t[7]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"歷史記錄"),s("div",{class:"link-desc"},"瀏覽過往的翻譯記錄")],-1))]),_:1}),l(e,{to:"/profile",class:"link-card"},{default:o(()=>[s("div",R,[l(n,null,{default:o(()=>[l(i(C))]),_:1})]),t[8]||(t[8]=s("div",{class:"link-content"},[s("div",{class:"link-title"},"個人設定"),s("div",{class:"link-desc"},"管理您的個人資料")],-1))]),_:1})])])])])}}},G=r(T,[["__scopeId","data-v-6d786883"]]);export{G as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {