This commit is contained in:
beabigegg
2025-09-02 13:11:48 +08:00
parent a60d965317
commit b11a8272c4
76 changed files with 15321 additions and 200 deletions

95
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
onMounted(async () => {
// 應用啟動時檢查用戶是否已登入
await authStore.checkAuth()
})
</script>
<style lang="scss">
#app {
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
}
// 全局樣式重置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
}
// 自定義滾動條樣式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color-light);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
// Element Plus 主題色彩自定義
:root {
--el-color-primary: #409eff;
--el-color-primary-light-3: #79bbff;
--el-color-primary-light-5: #a0cfff;
--el-color-primary-light-7: #c6e2ff;
--el-color-primary-light-8: #d9ecff;
--el-color-primary-light-9: #ecf5ff;
--el-color-primary-dark-2: #337ecc;
}
// 過渡動畫
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(20px);
opacity: 0;
}
</style>

View File

@@ -0,0 +1,431 @@
<template>
<div class="app-layout">
<!-- 側邊欄 -->
<aside class="layout-sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-show': mobileSidebarVisible }">
<div class="sidebar-header">
<router-link to="/home" class="logo">
<div class="logo-icon">
<img src="/panjit-logo.png" alt="PANJIT Logo" style="width: 32px; height: 32px;" />
</div>
<div class="logo-text">PANJIT 翻譯系統</div>
</router-link>
</div>
<nav class="sidebar-menu">
<router-link
v-for="route in menuRoutes"
:key="route.name"
:to="route.path"
class="menu-item"
:class="{ active: isActiveRoute(route.path) }"
@click="handleMenuClick"
>
<el-icon class="menu-icon">
<component :is="route.meta.icon" />
</el-icon>
<span class="menu-text">{{ route.meta.title }}</span>
</router-link>
</nav>
<div class="sidebar-footer" v-if="!sidebarCollapsed">
<button class="collapse-toggle" @click="toggleSidebar">
<el-icon><Fold /></el-icon>
收合側邊欄
</button>
</div>
<div class="sidebar-footer" v-else>
<button class="collapse-toggle" @click="toggleSidebar">
<el-icon><Expand /></el-icon>
</button>
</div>
</aside>
<!-- 移動設備遮罩 -->
<div class="mobile-mask" :class="{ show: mobileSidebarVisible }" @click="closeMobileSidebar"></div>
<!-- 主要內容區 -->
<main class="layout-main">
<!-- 頂部導航欄 -->
<header class="layout-header">
<div class="header-left">
<button class="menu-toggle" @click="toggleMobileSidebar">
<el-icon><Menu /></el-icon>
</button>
<nav class="breadcrumb">
<span class="breadcrumb-item">{{ currentRoute.meta?.title || '首頁' }}</span>
</nav>
</div>
<div class="header-right">
<!-- 通知鈴鐺 -->
<div class="notification-bell" @click="showNotifications">
<el-icon><Bell /></el-icon>
<div class="badge" v-if="unreadCount > 0"></div>
</div>
<!-- 用戶下拉選單 -->
<el-dropdown class="user-avatar" @command="handleUserMenuCommand">
<div class="avatar-button">
<div class="avatar">
{{ userInitials }}
</div>
<div class="user-info">
<div class="user-name">{{ authStore.userName }}</div>
<div class="user-role">{{ authStore.isAdmin ? '管理員' : '使用者' }}</div>
</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
個人設定
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
登出
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 內容區域 -->
<div class="layout-content">
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</div>
</main>
<!-- 通知抽屜 -->
<el-drawer
v-model="notificationDrawerVisible"
title="系統通知"
direction="rtl"
size="400px"
>
<div class="notification-list">
<div v-if="notifications.length === 0" class="empty-state">
<el-icon class="empty-icon"><Bell /></el-icon>
<div class="empty-title">暂无通知</div>
<div class="empty-description">您目前没有未读通知</div>
</div>
<div v-else>
<div
v-for="notification in notifications"
:key="notification.id"
class="notification-item"
:class="{ unread: !notification.read }"
>
<div class="notification-icon" :class="notification.type">
<el-icon>
<component :is="getNotificationIcon(notification.type)" />
</el-icon>
</div>
<div class="notification-content">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-message">{{ notification.message }}</div>
<div class="notification-time">{{ formatTime(notification.created_at) }}</div>
</div>
<div class="notification-actions">
<el-button
v-if="!notification.read"
type="text"
size="small"
@click="markAsRead(notification.id)"
>
標記已讀
</el-button>
</div>
</div>
</div>
</div>
<template #footer>
<div class="notification-footer">
<el-button @click="markAllAsRead" v-if="unreadCount > 0">
全部標記已讀
</el-button>
<el-button type="primary" @click="notificationDrawerVisible = false">
關閉
</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Document, House, Upload, List, Clock, User, Setting, Bell, Menu,
Fold, Expand, SwitchButton, SuccessFilled, WarningFilled,
CircleCloseFilled, InfoFilled
} from '@element-plus/icons-vue'
import { initWebSocket, cleanupWebSocket } from '@/utils/websocket'
// Store 和 Router
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// 響應式數據
const sidebarCollapsed = ref(false)
const mobileSidebarVisible = ref(false)
const notificationDrawerVisible = ref(false)
const notifications = ref([])
const unreadCount = ref(0)
// 計算屬性
const currentRoute = computed(() => route)
const userInitials = computed(() => {
const name = authStore.userName || authStore.user?.username || 'U'
return name.charAt(0).toUpperCase()
})
const menuRoutes = computed(() => {
const routes = [
{ name: 'Home', path: '/home', meta: { title: '首頁', icon: 'House', showInMenu: true }},
{ name: 'Upload', path: '/upload', meta: { title: '檔案上傳', icon: 'Upload', showInMenu: true }},
{ name: 'Jobs', path: '/jobs', meta: { title: '任務列表', icon: 'List', showInMenu: true }},
{ name: 'History', path: '/history', meta: { title: '歷史記錄', icon: 'Clock', showInMenu: true }}
]
// 如果是管理員,顯示管理後台選項
if (authStore.isAdmin) {
routes.push({
name: 'Admin',
path: '/admin',
meta: { title: '管理後台', icon: 'Setting', showInMenu: true, requiresAdmin: true }
})
}
return routes.filter(route => route.meta.showInMenu)
})
// 方法
const isActiveRoute = (path) => {
return route.path === path || route.path.startsWith(path + '/')
}
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('sidebarCollapsed', sidebarCollapsed.value.toString())
}
const toggleMobileSidebar = () => {
mobileSidebarVisible.value = !mobileSidebarVisible.value
}
const closeMobileSidebar = () => {
mobileSidebarVisible.value = false
}
const handleMenuClick = () => {
if (window.innerWidth <= 768) {
closeMobileSidebar()
}
}
const showNotifications = () => {
notificationDrawerVisible.value = true
// 可以在這裡載入最新通知
loadNotifications()
}
const handleUserMenuCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'logout':
try {
await ElMessageBox.confirm('確定要登出嗎?', '確認登出', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await authStore.logout()
cleanupWebSocket()
router.push('/login')
} catch (error) {
if (error !== 'cancel') {
console.error('登出錯誤:', error)
}
}
break
}
}
const loadNotifications = async () => {
try {
// 這裡應該從 API 載入通知,目前使用模擬數據
notifications.value = [
{
id: 1,
type: 'success',
title: '翻譯完成',
message: '檔案「文件.docx」翻譯完成',
created_at: new Date().toISOString(),
read: false
},
{
id: 2,
type: 'warning',
title: '系統維護通知',
message: '系統將於今晚 23:00 進行維護',
created_at: new Date(Date.now() - 3600000).toISOString(),
read: true
}
]
unreadCount.value = notifications.value.filter(n => !n.read).length
} catch (error) {
console.error('載入通知失敗:', error)
}
}
const markAsRead = (notificationId) => {
const notification = notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.read = true
unreadCount.value = notifications.value.filter(n => !n.read).length
}
}
const markAllAsRead = () => {
notifications.value.forEach(notification => {
notification.read = true
})
unreadCount.value = 0
ElMessage.success('所有通知已標記為已讀')
}
const getNotificationIcon = (type) => {
const iconMap = {
success: 'SuccessFilled',
warning: 'WarningFilled',
error: 'CircleCloseFilled',
info: 'InfoFilled'
}
return iconMap[type] || 'InfoFilled'
}
const formatTime = (timestamp) => {
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)} 小時前`
return time.toLocaleDateString('zh-TW')
}
// 響應式處理
const handleResize = () => {
if (window.innerWidth > 768) {
mobileSidebarVisible.value = false
}
}
// 生命周期
onMounted(() => {
// 恢復側邊欄狀態
const savedCollapsed = localStorage.getItem('sidebarCollapsed')
if (savedCollapsed !== null) {
sidebarCollapsed.value = savedCollapsed === 'true'
}
// 暫時禁用 WebSocket 避免連接錯誤
// initWebSocket()
// 載入通知
loadNotifications()
// 監聽窗口大小變化
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
<style lang="scss" scoped>
// 通知相關樣式
.notification-list {
.notification-item {
display: flex;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&.unread {
background-color: var(--el-color-primary-light-9);
border-left: 3px solid var(--el-color-primary);
padding-left: 9px;
}
.notification-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
&.success { background-color: var(--el-color-success-light-9); color: var(--el-color-success); }
&.warning { background-color: var(--el-color-warning-light-9); color: var(--el-color-warning); }
&.error { background-color: var(--el-color-danger-light-9); color: var(--el-color-danger); }
&.info { background-color: var(--el-color-info-light-9); color: var(--el-color-info); }
}
.notification-content {
flex: 1;
.notification-title {
font-weight: 600;
margin-bottom: 4px;
color: var(--el-text-color-primary);
}
.notification-message {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.4;
margin-bottom: 4px;
}
.notification-time {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.notification-actions {
margin-left: 12px;
}
}
}
.notification-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

49
frontend/src/main.js Normal file
View File

@@ -0,0 +1,49 @@
import { createApp, nextTick } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'
import './style/main.scss'
// 創建應用實例
const app = createApp(App)
// 註冊 Element Plus 圖標
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn
})
// 全局錯誤處理
app.config.errorHandler = (err, vm, info) => {
console.error('全局錯誤處理:', err, info)
}
// 隱藏載入畫面
const hideLoading = () => {
const loading = document.getElementById('loading')
if (loading) {
loading.style.display = 'none'
}
}
// 掛載應用
app.mount('#app')
// 應用載入完成後隱藏載入畫面
nextTick(() => {
hideLoading()
})
export default app

View File

@@ -0,0 +1,165 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 配置 NProgress
NProgress.configure({
showSpinner: false,
minimum: 0.1,
speed: 200
})
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: {
title: '登入',
requiresAuth: false,
hideLayout: true
}
},
{
path: '/',
name: 'Layout',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/home',
meta: {
requiresAuth: true
},
children: [
{
path: '/home',
name: 'Home',
component: () => import('@/views/HomeView.vue'),
meta: {
title: '首頁',
icon: 'House',
showInMenu: true
}
},
{
path: '/upload',
name: 'Upload',
component: () => import('@/views/UploadView.vue'),
meta: {
title: '檔案上傳',
icon: 'Upload',
showInMenu: true
}
},
{
path: '/jobs',
name: 'Jobs',
component: () => import('@/views/JobListView.vue'),
meta: {
title: '任務列表',
icon: 'List',
showInMenu: true
}
},
{
path: '/history',
name: 'History',
component: () => import('@/views/HistoryView.vue'),
meta: {
title: '歷史記錄',
icon: 'Clock',
showInMenu: true
}
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/ProfileView.vue'),
meta: {
title: '個人設定',
icon: 'User'
}
},
{
path: '/admin',
name: 'Admin',
component: () => import('@/views/AdminView.vue'),
meta: {
title: '管理後台',
icon: 'Setting',
requiresAdmin: true,
showInMenu: true
}
}
]
},
{
path: '/job/:uuid',
name: 'JobDetail',
component: () => import('@/views/JobDetailView.vue'),
meta: {
title: '任務詳情',
requiresAuth: true,
hideLayout: false
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
meta: {
title: '頁面不存在',
hideLayout: true
}
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由守衛
router.beforeEach(async (to, from, next) => {
NProgress.start()
const authStore = useAuthStore()
// 設置頁面標題
document.title = to.meta.title ? `${to.meta.title} - PANJIT Document Translator` : 'PANJIT Document Translator'
// 檢查是否需要認證
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
ElMessage.warning('請先登入')
next('/login')
return
}
// 檢查管理員權限
if (to.meta.requiresAdmin && !authStore.isAdmin) {
ElMessage.error('無權限存取此頁面')
next('/home')
return
}
// 如果已經登入且訪問登入頁面,重定向到首頁
if (to.path === '/login' && authStore.isAuthenticated) {
next('/home')
return
}
next()
})
router.afterEach(() => {
NProgress.done()
})
export default router

View File

@@ -0,0 +1,114 @@
import { request } from '@/utils/request'
/**
* 管理員相關 API
*/
export const adminAPI = {
/**
* 取得系統統計資訊
* @param {string} period - 統計週期 (day/week/month/year)
*/
getStats(period = 'month') {
return request.get('/admin/stats', { params: { period } })
},
/**
* 取得所有使用者任務
* @param {Object} params - 查詢參數
*/
getAllJobs(params = {}) {
const defaultParams = {
page: 1,
per_page: 50,
user_id: 'all',
status: 'all'
}
return request.get('/admin/jobs', { params: { ...defaultParams, ...params } })
},
/**
* 取得使用者列表
*/
getUsers() {
return request.get('/admin/users')
},
/**
* 取得使用者詳細資訊
* @param {number} userId - 使用者 ID
*/
getUserDetail(userId) {
return request.get(`/admin/users/${userId}`)
},
/**
* 更新使用者狀態
* @param {number} userId - 使用者 ID
* @param {Object} data - 更新資料
*/
updateUser(userId, data) {
return request.put(`/admin/users/${userId}`, data)
},
/**
* 取得 API 使用統計
* @param {Object} params - 查詢參數
*/
getApiUsageStats(params = {}) {
return request.get('/admin/api-usage', { params })
},
/**
* 取得系統日誌
* @param {Object} params - 查詢參數
*/
getSystemLogs(params = {}) {
const defaultParams = {
page: 1,
per_page: 100,
level: 'all'
}
return request.get('/admin/logs', { params: { ...defaultParams, ...params } })
},
/**
* 取得成本報表
* @param {Object} params - 查詢參數
*/
getCostReport(params = {}) {
return request.get('/admin/cost-report', { params })
},
/**
* 匯出報表
* @param {string} type - 報表類型
* @param {Object} params - 查詢參數
*/
exportReport(type, params = {}) {
return request.get(`/admin/export/${type}`, {
params,
responseType: 'blob'
})
},
/**
* 系統健康檢查
*/
getSystemHealth() {
return request.get('/admin/health')
},
/**
* 取得系統指標
*/
getSystemMetrics() {
return request.get('/admin/metrics')
},
/**
* 清理舊檔案
*/
cleanupOldFiles() {
return request.post('/admin/cleanup')
}
}

View File

@@ -0,0 +1,44 @@
import { request } from '@/utils/request'
/**
* 認證相關 API
*/
export const authAPI = {
/**
* 使用者登入
* @param {Object} credentials - 登入憑證
* @param {string} credentials.username - AD 帳號
* @param {string} credentials.password - 密碼
*/
login(credentials) {
return request.post('/auth/login', credentials)
},
/**
* 使用者登出
*/
logout() {
return request.post('/auth/logout')
},
/**
* 取得當前使用者資訊
*/
getCurrentUser() {
return request.get('/auth/me')
},
/**
* 檢查認證狀態
*/
checkAuth() {
return request.get('/auth/check')
},
/**
* 刷新認證狀態
*/
refresh() {
return request.post('/auth/refresh')
}
}

View File

@@ -0,0 +1,103 @@
import { request, uploadRequest } from '@/utils/request'
/**
* 任務相關 API
*/
export const jobsAPI = {
/**
* 上傳檔案
* @param {FormData} formData - 包含檔案和設定的表單資料
*/
uploadFile(formData) {
return uploadRequest.post('/files/upload', formData, {
onUploadProgress: (progressEvent) => {
// 上傳進度回調在外部處理
if (formData.onUploadProgress) {
formData.onUploadProgress(progressEvent)
}
}
})
},
/**
* 取得使用者任務列表
* @param {Object} params - 查詢參數
* @param {number} params.page - 頁數
* @param {number} params.per_page - 每頁數量
* @param {string} params.status - 任務狀態篩選
*/
getJobs(params = {}) {
const defaultParams = {
page: 1,
per_page: 20,
status: 'all'
}
return request.get('/jobs', { params: { ...defaultParams, ...params } })
},
/**
* 取得任務詳細資訊
* @param {string} jobUuid - 任務 UUID
*/
getJobDetail(jobUuid) {
return request.get(`/jobs/${jobUuid}`)
},
/**
* 重試失敗任務
* @param {string} jobUuid - 任務 UUID
*/
retryJob(jobUuid) {
return request.post(`/jobs/${jobUuid}/retry`)
},
/**
* 取消任務
* @param {string} jobUuid - 任務 UUID
*/
cancelJob(jobUuid) {
return request.post(`/jobs/${jobUuid}/cancel`)
},
/**
* 刪除任務
* @param {string} jobUuid - 任務 UUID
*/
deleteJob(jobUuid) {
return request.delete(`/jobs/${jobUuid}`)
}
}
/**
* 檔案相關 API
*/
export const filesAPI = {
/**
* 下載翻譯檔案
* @param {string} jobUuid - 任務 UUID
* @param {string} languageCode - 語言代碼
*/
downloadFile(jobUuid, languageCode) {
return request.get(`/files/${jobUuid}/download/${languageCode}`, {
responseType: 'blob'
})
},
/**
* 批量下載檔案
* @param {string} jobUuid - 任務 UUID
*/
downloadAllFiles(jobUuid) {
return request.get(`/files/${jobUuid}/download-all`, {
responseType: 'blob'
})
},
/**
* 取得檔案資訊
* @param {string} jobUuid - 任務 UUID
*/
getFileInfo(jobUuid) {
return request.get(`/files/${jobUuid}/info`)
}
}

View File

@@ -0,0 +1,279 @@
import { defineStore } from 'pinia'
import { adminAPI } from '@/services/admin'
import { ElMessage } from 'element-plus'
export const useAdminStore = defineStore('admin', {
state: () => ({
stats: null,
users: [],
allJobs: [],
systemLogs: [],
apiUsageStats: [],
costReport: null,
systemHealth: null,
systemMetrics: null,
loading: false,
pagination: {
page: 1,
per_page: 50,
total: 0,
pages: 0
}
}),
getters: {
// 系統概覽統計
overviewStats: (state) => state.stats?.overview || {},
// 每日統計資料
dailyStats: (state) => state.stats?.daily_stats || [],
// 使用者排名
userRankings: (state) => state.stats?.user_rankings || [],
// 活躍使用者數量
activeUsersCount: (state) => state.stats?.overview?.active_users_today || 0,
// 總成本
totalCost: (state) => state.stats?.overview?.total_cost || 0,
// 系統是否健康
isSystemHealthy: (state) => state.systemHealth?.status === 'healthy'
},
actions: {
/**
* 取得系統統計資訊
* @param {string} period - 統計週期
*/
async fetchStats(period = 'month') {
try {
this.loading = true
const response = await adminAPI.getStats(period)
if (response.success) {
this.stats = response.data
return response.data
}
} catch (error) {
console.error('取得統計資訊失敗:', error)
ElMessage.error('載入統計資訊失敗')
} finally {
this.loading = false
}
},
/**
* 取得所有使用者任務
* @param {Object} params - 查詢參數
*/
async fetchAllJobs(params = {}) {
try {
this.loading = true
const response = await adminAPI.getAllJobs(params)
if (response.success) {
this.allJobs = response.data.jobs
this.pagination = response.data.pagination
return response.data
}
} catch (error) {
console.error('取得所有任務失敗:', error)
ElMessage.error('載入任務資料失敗')
} finally {
this.loading = false
}
},
/**
* 取得使用者列表
*/
async fetchUsers() {
try {
const response = await adminAPI.getUsers()
if (response.success) {
this.users = response.data.users
return response.data
}
} catch (error) {
console.error('取得使用者列表失敗:', error)
ElMessage.error('載入使用者資料失敗')
}
},
/**
* 更新使用者狀態
* @param {number} userId - 使用者 ID
* @param {Object} data - 更新資料
*/
async updateUser(userId, data) {
try {
const response = await adminAPI.updateUser(userId, data)
if (response.success) {
// 更新本地使用者資料
const userIndex = this.users.findIndex(user => user.id === userId)
if (userIndex !== -1) {
this.users[userIndex] = { ...this.users[userIndex], ...response.data }
}
ElMessage.success('使用者資料更新成功')
return response.data
}
} catch (error) {
console.error('更新使用者失敗:', error)
ElMessage.error('更新使用者失敗')
}
},
/**
* 取得 API 使用統計
* @param {Object} params - 查詢參數
*/
async fetchApiUsageStats(params = {}) {
try {
const response = await adminAPI.getApiUsageStats(params)
if (response.success) {
this.apiUsageStats = response.data.stats
return response.data
}
} catch (error) {
console.error('取得 API 使用統計失敗:', error)
ElMessage.error('載入 API 統計失敗')
}
},
/**
* 取得系統日誌
* @param {Object} params - 查詢參數
*/
async fetchSystemLogs(params = {}) {
try {
this.loading = true
const response = await adminAPI.getSystemLogs(params)
if (response.success) {
this.systemLogs = response.data.logs
return response.data
}
} catch (error) {
console.error('取得系統日誌失敗:', error)
ElMessage.error('載入系統日誌失敗')
} finally {
this.loading = false
}
},
/**
* 取得成本報表
* @param {Object} params - 查詢參數
*/
async fetchCostReport(params = {}) {
try {
const response = await adminAPI.getCostReport(params)
if (response.success) {
this.costReport = response.data
return response.data
}
} catch (error) {
console.error('取得成本報表失敗:', error)
ElMessage.error('載入成本報表失敗')
}
},
/**
* 匯出報表
* @param {string} type - 報表類型
* @param {Object} params - 查詢參數
*/
async exportReport(type, params = {}) {
try {
const response = await adminAPI.exportReport(type, params)
// 下載檔案
const blob = new Blob([response], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${type}_report_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
ElMessage.success('報表匯出成功')
} catch (error) {
console.error('匯出報表失敗:', error)
ElMessage.error('匯出報表失敗')
}
},
/**
* 取得系統健康狀態
*/
async fetchSystemHealth() {
try {
const response = await adminAPI.getSystemHealth()
this.systemHealth = response
return response
} catch (error) {
console.error('取得系統健康狀態失敗:', error)
this.systemHealth = { status: 'unhealthy' }
}
},
/**
* 取得系統指標
*/
async fetchSystemMetrics() {
try {
const response = await adminAPI.getSystemMetrics()
if (response.success || response.jobs) {
this.systemMetrics = response
return response
}
} catch (error) {
console.error('取得系統指標失敗:', error)
}
},
/**
* 清理舊檔案
*/
async cleanupOldFiles() {
try {
const response = await adminAPI.cleanupOldFiles()
if (response.success) {
ElMessage.success('檔案清理完成')
return response.data
}
} catch (error) {
console.error('清理檔案失敗:', error)
ElMessage.error('清理檔案失敗')
}
},
/**
* 重置管理員資料
*/
resetAdminData() {
this.stats = null
this.users = []
this.allJobs = []
this.systemLogs = []
this.apiUsageStats = []
this.costReport = null
this.systemHealth = null
this.systemMetrics = null
}
}
})

182
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,182 @@
import { defineStore } from 'pinia'
import { authAPI } from '@/services/auth'
import { ElMessage } from 'element-plus'
import Cookies from 'js-cookie'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
isAuthenticated: false,
token: null,
refreshToken: null,
loading: false
}),
getters: {
isAdmin: (state) => state.user?.is_admin || false,
userName: (state) => state.user?.display_name || '',
userEmail: (state) => state.user?.email || '',
department: (state) => state.user?.department || ''
},
actions: {
/**
* 使用者登入
* @param {Object} credentials - 登入憑證
*/
async login(credentials) {
try {
this.loading = true
console.log('🔑 [Auth] 開始登入流程', credentials.username)
const response = await authAPI.login(credentials)
console.log('🔑 [Auth] 登入 API 回應', response)
if (response.success) {
this.user = response.data.user
this.token = response.data.access_token // 改為使用 access_token
this.refreshToken = response.data.refresh_token // 儲存 refresh_token
this.isAuthenticated = true
console.log('🔑 [Auth] 設定認證狀態', {
user: this.user,
token: this.token ? `${this.token.substring(0, 20)}...` : null,
isAuthenticated: this.isAuthenticated
})
// 儲存認證資訊到 localStorage
localStorage.setItem('auth_user', JSON.stringify(response.data.user))
localStorage.setItem('auth_token', this.token)
localStorage.setItem('auth_refresh_token', this.refreshToken)
localStorage.setItem('auth_authenticated', 'true')
// JWT 不需要 cookie移除 cookie 設定
console.log('🔑 [Auth] 登入成功JWT tokens 已儲存')
ElMessage.success(response.message || '登入成功')
return response.data
} else {
throw new Error(response.message || '登入失敗')
}
} catch (error) {
console.error('❌ [Auth] 登入錯誤:', error)
this.clearAuth()
throw error
} finally {
this.loading = false
}
},
/**
* 使用者登出
*/
async logout() {
try {
console.log('🚪 [Auth] 開始登出流程')
await authAPI.logout()
console.log('🚪 [Auth] 登出 API 完成')
} catch (error) {
console.error('❌ [Auth] 登出錯誤:', error)
} finally {
console.log('🚪 [Auth] 清除認證資料')
this.clearAuth()
ElMessage.success('已安全登出')
}
},
/**
* 檢查認證狀態
*/
async checkAuth() {
try {
// 先檢查 localStorage 中的認證資訊
const authUser = localStorage.getItem('auth_user')
const authToken = localStorage.getItem('auth_token')
const authRefreshToken = localStorage.getItem('auth_refresh_token')
const authAuthenticated = localStorage.getItem('auth_authenticated')
if (!authUser || !authToken || authAuthenticated !== 'true') {
return false
}
// 恢復認證狀態
this.user = JSON.parse(authUser)
this.token = authToken
this.refreshToken = authRefreshToken
this.isAuthenticated = true
console.log('🔑 [Auth] 從 localStorage 恢復認證狀態', {
user: this.user,
hasToken: !!this.token,
hasRefreshToken: !!this.refreshToken
})
return true
} catch (error) {
console.error('❌ [Auth] 認證檢查失敗:', error)
this.clearAuth()
return false
}
},
/**
* 刷新用戶資訊
*/
async refreshUser() {
try {
const response = await authAPI.getCurrentUser()
if (response.success && response.data.user) {
this.user = response.data.user
}
} catch (error) {
console.error('刷新用戶資訊失敗:', error)
this.clearAuth()
}
},
/**
* 清除認證資訊
*/
clearAuth() {
console.log('🧡 [Auth] 清除認證資料前', {
user: this.user,
token: this.token,
refreshToken: this.refreshToken,
isAuthenticated: this.isAuthenticated
})
this.user = null
this.token = null
this.refreshToken = null
this.isAuthenticated = false
this.loading = false
// 清除所有認證相關的存儲
localStorage.removeItem('auth_user')
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_refresh_token')
localStorage.removeItem('auth_authenticated')
console.log('🧡 [Auth] JWT 認證資料已清除')
},
/**
* 更新用戶資訊
* @param {Object} userData - 用戶資料
*/
updateUser(userData) {
if (this.user) {
this.user = { ...this.user, ...userData }
}
}
},
// 持久化設定(可選)
persist: {
key: 'auth_store',
storage: localStorage,
paths: ['user', 'isAuthenticated'] // 只持久化這些欄位
}
})

403
frontend/src/stores/jobs.js Normal file
View File

@@ -0,0 +1,403 @@
import { defineStore } from 'pinia'
import { jobsAPI, filesAPI } from '@/services/jobs'
import { ElMessage, ElNotification } from 'element-plus'
import { saveAs } from 'file-saver'
export const useJobsStore = defineStore('jobs', {
state: () => ({
jobs: [],
currentJob: null,
pagination: {
page: 1,
per_page: 20,
total: 0,
pages: 0
},
loading: false,
uploadProgress: 0,
filters: {
status: 'all',
search: ''
},
// 輪詢管理
pollingIntervals: new Map() // 存儲每個任務的輪詢間隔 ID
}),
getters: {
// 按狀態分組的任務
pendingJobs: (state) => state.jobs.filter(job => job.status === 'PENDING'),
processingJobs: (state) => state.jobs.filter(job => job.status === 'PROCESSING'),
completedJobs: (state) => state.jobs.filter(job => job.status === 'COMPLETED'),
failedJobs: (state) => state.jobs.filter(job => job.status === 'FAILED'),
retryJobs: (state) => state.jobs.filter(job => job.status === 'RETRY'),
// 根據 UUID 查找任務
getJobByUuid: (state) => (uuid) => {
return state.jobs.find(job => job.job_uuid === uuid)
},
// 統計資訊
jobStats: (state) => ({
total: state.jobs.length,
pending: state.jobs.filter(job => job.status === 'PENDING').length,
processing: state.jobs.filter(job => job.status === 'PROCESSING').length,
completed: state.jobs.filter(job => job.status === 'COMPLETED').length,
failed: state.jobs.filter(job => job.status === 'FAILED').length
})
},
actions: {
/**
* 取得任務列表
* @param {Object} options - 查詢選項
*/
async fetchJobs(options = {}) {
try {
this.loading = true
const params = {
page: options.page || this.pagination.page,
per_page: options.per_page || this.pagination.per_page,
status: options.status || this.filters.status
}
const response = await jobsAPI.getJobs(params)
if (response.success) {
this.jobs = response.data.jobs
this.pagination = response.data.pagination
return response.data
}
} catch (error) {
console.error('取得任務列表失敗:', error)
ElMessage.error('載入任務列表失敗')
} finally {
this.loading = false
}
},
/**
* 上傳檔案
* @param {FormData} formData - 表單資料
* @param {Function} onProgress - 進度回調
*/
async uploadFile(formData, onProgress) {
try {
this.uploadProgress = 0
// 設定進度回調
if (onProgress) {
formData.onUploadProgress = (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
this.uploadProgress = progress
onProgress(progress)
}
}
const response = await jobsAPI.uploadFile(formData)
if (response.success) {
// 將新任務添加到列表頂部
const newJob = response.data
this.jobs.unshift(newJob)
ElMessage.success('檔案上傳成功,已加入翻譯佇列')
return newJob
}
} catch (error) {
console.error('檔案上傳失敗:', error)
throw error
} finally {
this.uploadProgress = 0
}
},
/**
* 取得任務詳情
* @param {string} jobUuid - 任務 UUID
*/
async fetchJobDetail(jobUuid) {
try {
const response = await jobsAPI.getJobDetail(jobUuid)
if (response.success) {
this.currentJob = response.data
return response.data
}
} catch (error) {
console.error('取得任務詳情失敗:', error)
ElMessage.error('載入任務詳情失敗')
}
},
/**
* 重試失敗任務
* @param {string} jobUuid - 任務 UUID
*/
async retryJob(jobUuid) {
try {
const response = await jobsAPI.retryJob(jobUuid)
if (response.success) {
// 更新本地任務狀態
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...response.data }
}
ElMessage.success('任務已重新加入佇列')
return response.data
}
} catch (error) {
console.error('重試任務失敗:', error)
ElMessage.error('重試任務失敗')
}
},
/**
* 取消任務
* @param {string} jobUuid - 任務 UUID
*/
async cancelJob(jobUuid) {
try {
const response = await jobsAPI.cancelJob(jobUuid)
if (response.success) {
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], status: 'CANCELLED' }
}
ElMessage.success('任務已取消')
}
} catch (error) {
console.error('取消任務失敗:', error)
ElMessage.error('取消任務失敗')
}
},
/**
* 刪除任務
* @param {string} jobUuid - 任務 UUID
*/
async deleteJob(jobUuid) {
try {
const response = await jobsAPI.deleteJob(jobUuid)
if (response.success) {
// 先停止輪詢
this.unsubscribeFromJobUpdates(jobUuid)
// 從列表中移除任務
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs.splice(jobIndex, 1)
}
ElMessage.success('任務已刪除')
}
} catch (error) {
console.error('刪除任務失敗:', error)
ElMessage.error('刪除任務失敗')
}
},
/**
* 下載檔案
* @param {string} jobUuid - 任務 UUID
* @param {string} languageCode - 語言代碼
* @param {string} filename - 檔案名稱
*/
async downloadFile(jobUuid, languageCode, filename) {
try {
const response = await filesAPI.downloadFile(jobUuid, languageCode)
// 使用 FileSaver.js 下載檔案
const blob = new Blob([response], { type: 'application/octet-stream' })
saveAs(blob, filename)
ElMessage.success('檔案下載完成')
} catch (error) {
console.error('下載檔案失敗:', error)
ElMessage.error('檔案下載失敗')
}
},
/**
* 批量下載檔案
* @param {string} jobUuid - 任務 UUID
* @param {string} filename - 壓縮檔名稱
*/
async downloadAllFiles(jobUuid, filename) {
try {
const response = await filesAPI.downloadAllFiles(jobUuid)
const blob = new Blob([response], { type: 'application/zip' })
saveAs(blob, filename || `${jobUuid}.zip`)
ElMessage.success('檔案打包下載完成')
} catch (error) {
console.error('批量下載失敗:', error)
ElMessage.error('批量下載失敗')
}
},
/**
* 更新任務狀態(用於 WebSocket 即時更新)
* @param {string} jobUuid - 任務 UUID
* @param {Object} statusUpdate - 狀態更新資料
*/
updateJobStatus(jobUuid, statusUpdate) {
const jobIndex = this.jobs.findIndex(job => job.job_uuid === jobUuid)
if (jobIndex !== -1) {
this.jobs[jobIndex] = { ...this.jobs[jobIndex], ...statusUpdate }
// 如果是當前查看的任務詳情,也要更新
if (this.currentJob && this.currentJob.job_uuid === jobUuid) {
this.currentJob = { ...this.currentJob, ...statusUpdate }
}
// 任務完成時顯示通知
if (statusUpdate.status === 'COMPLETED') {
ElNotification({
title: '翻譯完成',
message: `檔案「${this.jobs[jobIndex].original_filename}」翻譯完成`,
type: 'success',
duration: 5000
})
} else if (statusUpdate.status === 'FAILED') {
ElNotification({
title: '翻譯失敗',
message: `檔案「${this.jobs[jobIndex].original_filename}」翻譯失敗`,
type: 'error',
duration: 5000
})
}
}
},
/**
* 設定篩選條件
* @param {Object} filters - 篩選條件
*/
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
},
/**
* 訂閱任務更新 (輪詢機制)
* @param {string} jobUuid - 任務 UUID
*/
subscribeToJobUpdates(jobUuid) {
// 如果已經在輪詢這個任務,先停止舊的輪詢
if (this.pollingIntervals.has(jobUuid)) {
this.unsubscribeFromJobUpdates(jobUuid)
}
console.log(`[DEBUG] 開始訂閱任務更新: ${jobUuid}`)
const pollInterval = setInterval(async () => {
try {
const job = await this.fetchJobDetail(jobUuid)
if (job) {
// 任務存在,更新本地狀態
const existingJobIndex = this.jobs.findIndex(j => j.job_uuid === jobUuid)
if (existingJobIndex !== -1) {
// 更新現有任務
this.jobs[existingJobIndex] = { ...this.jobs[existingJobIndex], ...job }
}
// 檢查任務是否已完成
if (['COMPLETED', 'FAILED'].includes(job.status)) {
console.log(`[DEBUG] 任務 ${jobUuid} 已完成 (${job.status}),停止輪詢`)
this.unsubscribeFromJobUpdates(jobUuid)
// 顯示完成通知
if (job.status === 'COMPLETED') {
ElNotification({
title: '翻譯完成',
message: `檔案 "${job.original_filename}" 翻譯完成`,
type: 'success',
duration: 5000
})
}
}
} else {
// 任務不存在(可能被刪除),停止輪詢
console.log(`[DEBUG] 任務 ${jobUuid} 不存在,停止輪詢`)
this.unsubscribeFromJobUpdates(jobUuid)
// 從本地列表中移除任務
const existingJobIndex = this.jobs.findIndex(j => j.job_uuid === jobUuid)
if (existingJobIndex !== -1) {
this.jobs.splice(existingJobIndex, 1)
}
}
} catch (error) {
console.error(`輪詢任務 ${jobUuid} 狀態失敗:`, error)
// 檢查是否是 404 錯誤(任務不存在)
if (error.response?.status === 404) {
console.log(`[DEBUG] 任務 ${jobUuid} 已被刪除,停止輪詢`)
this.unsubscribeFromJobUpdates(jobUuid)
// 從本地列表中移除任務
const existingJobIndex = this.jobs.findIndex(j => j.job_uuid === jobUuid)
if (existingJobIndex !== -1) {
this.jobs.splice(existingJobIndex, 1)
}
} else {
// 其他錯誤,繼續輪詢但記錄錯誤
console.warn(`輪詢任務 ${jobUuid} 時發生錯誤,將繼續重試:`, error.message)
}
}
}, 3000) // 每 3 秒檢查一次
// 儲存輪詢間隔 ID
this.pollingIntervals.set(jobUuid, pollInterval)
},
/**
* 取消訂閱任務更新
* @param {string} jobUuid - 任務 UUID
*/
unsubscribeFromJobUpdates(jobUuid) {
const intervalId = this.pollingIntervals.get(jobUuid)
if (intervalId) {
clearInterval(intervalId)
this.pollingIntervals.delete(jobUuid)
console.log(`[DEBUG] 已取消任務 ${jobUuid} 的輪詢訂閱`)
}
},
/**
* 停止所有輪詢
*/
stopAllPolling() {
for (const [jobUuid, intervalId] of this.pollingIntervals) {
clearInterval(intervalId)
console.log(`[DEBUG] 已停止任務 ${jobUuid} 的輪詢`)
}
this.pollingIntervals.clear()
},
/**
* 重置任務列表
*/
resetJobs() {
// 先停止所有輪詢
this.stopAllPolling()
this.jobs = []
this.currentJob = null
this.pagination = {
page: 1,
per_page: 20,
total: 0,
pages: 0
}
}
}
})

View File

@@ -0,0 +1,325 @@
// 組件樣式
// 狀態標籤樣式
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $border-radius-base;
font-size: $font-size-small;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
&.pending {
background-color: map-get($status-colors, 'PENDING');
color: white;
}
&.processing {
background-color: map-get($status-colors, 'PROCESSING');
color: white;
}
&.completed {
background-color: map-get($status-colors, 'COMPLETED');
color: white;
}
&.failed {
background-color: map-get($status-colors, 'FAILED');
color: white;
}
&.retry {
background-color: map-get($status-colors, 'RETRY');
color: white;
}
}
// 檔案圖示樣式
.file-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: $border-radius-base;
color: white;
font-size: $font-size-small;
font-weight: bold;
&.docx, &.doc {
background-color: map-get($file-type-colors, 'docx');
}
&.pptx, &.ppt {
background-color: map-get($file-type-colors, 'pptx');
}
&.xlsx, &.xls {
background-color: map-get($file-type-colors, 'xlsx');
}
&.pdf {
background-color: map-get($file-type-colors, 'pdf');
}
}
// 進度條樣式
.progress-bar {
width: 100%;
height: 6px;
background-color: $border-color-lighter;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, $primary-color, lighten($primary-color, 10%));
border-radius: 3px;
transition: width 0.3s ease;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.2) 75%,
transparent 75%,
transparent
);
background-size: 20px 20px;
animation: progress-stripes 1s linear infinite;
}
}
}
@keyframes progress-stripes {
0% { background-position: 0 0; }
100% { background-position: 20px 0; }
}
// 上傳區域樣式
.upload-area {
border: 2px dashed $border-color;
border-radius: $border-radius-base;
background-color: $bg-color-light;
transition: all $transition-duration-base;
&:hover, &.dragover {
border-color: $primary-color;
background-color: rgba($primary-color, 0.05);
}
&.disabled {
border-color: $border-color-lighter;
background-color: $border-color-extra-light;
cursor: not-allowed;
* {
pointer-events: none;
}
}
}
// 任務卡片樣式
.job-card {
@include card-style;
margin-bottom: $spacing-md;
cursor: pointer;
position: relative;
&:hover {
border-color: $primary-color;
transform: translateY(-1px);
}
.job-header {
@include flex-between;
margin-bottom: $spacing-sm;
.job-title {
font-weight: 600;
color: $text-color-primary;
@include text-ellipsis;
max-width: 60%;
}
.job-actions {
display: flex;
gap: $spacing-xs;
}
}
.job-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-sm;
font-size: $font-size-small;
color: $text-color-secondary;
@include respond-to(sm) {
grid-template-columns: 1fr;
}
}
.job-progress {
margin-top: $spacing-sm;
.progress-text {
@include flex-between;
font-size: $font-size-small;
color: $text-color-secondary;
margin-bottom: $spacing-xs;
}
}
.job-footer {
@include flex-between;
margin-top: $spacing-sm;
padding-top: $spacing-sm;
border-top: 1px solid $border-color-lighter;
.job-time {
font-size: $font-size-small;
color: $text-color-secondary;
}
}
}
// 統計卡片樣式
.stat-card {
@include card-style($spacing-lg);
text-align: center;
.stat-icon {
width: 48px;
height: 48px;
margin: 0 auto $spacing-sm;
border-radius: 50%;
@include flex-center;
&.primary { background-color: rgba($primary-color, 0.1); color: $primary-color; }
&.success { background-color: rgba($success-color, 0.1); color: $success-color; }
&.warning { background-color: rgba($warning-color, 0.1); color: $warning-color; }
&.danger { background-color: rgba($danger-color, 0.1); color: $danger-color; }
&.info { background-color: rgba($info-color, 0.1); color: $info-color; }
}
.stat-value {
font-size: $font-size-extra-large;
font-weight: bold;
color: $text-color-primary;
margin-bottom: $spacing-xs;
}
.stat-label {
font-size: $font-size-small;
color: $text-color-secondary;
margin-bottom: $spacing-sm;
}
.stat-change {
font-size: $font-size-small;
&.positive { color: $success-color; }
&.negative { color: $danger-color; }
}
}
// 空狀態樣式
.empty-state {
text-align: center;
padding: $spacing-xxl * 2;
color: $text-color-secondary;
.empty-icon {
font-size: 64px;
color: $border-color;
margin-bottom: $spacing-lg;
}
.empty-title {
font-size: $font-size-large;
color: $text-color-primary;
margin-bottom: $spacing-sm;
}
.empty-description {
font-size: $font-size-base;
line-height: 1.6;
margin-bottom: $spacing-lg;
}
}
// 語言標籤樣式
.language-tag {
display: inline-block;
padding: 2px 6px;
margin: 2px;
background-color: $primary-color;
color: white;
border-radius: $border-radius-small;
font-size: $font-size-small;
&:last-child {
margin-right: 0;
}
}
// 載入覆蓋層
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(2px);
@include flex-center;
z-index: $z-index-modal;
.loading-content {
text-align: center;
.loading-spinner {
@include loading-spinner(32px);
margin: 0 auto $spacing-md;
}
.loading-text {
color: $text-color-secondary;
font-size: $font-size-base;
}
}
}
// 工具提示樣式覆蓋
.custom-tooltip {
&.el-popper {
max-width: 300px;
.el-popper__arrow::before {
border-color: rgba(0, 0, 0, 0.8);
}
}
.el-tooltip__content {
background-color: rgba(0, 0, 0, 0.8);
color: white;
border-radius: $border-radius-base;
padding: $spacing-sm $spacing-md;
font-size: $font-size-small;
line-height: 1.4;
}
}

View File

@@ -0,0 +1,458 @@
// 布局樣式
// 主要布局容器
.app-layout {
display: flex;
height: 100vh;
overflow: hidden;
// 側邊欄
.layout-sidebar {
width: 240px;
background-color: $sidebar-bg;
color: $sidebar-text-color;
display: flex;
flex-direction: column;
transition: width $transition-duration-base;
z-index: $z-index-top;
&.collapsed {
width: 64px;
}
@include respond-to(md) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
transform: translateX(-100%);
&.mobile-show {
transform: translateX(0);
}
}
.sidebar-header {
padding: $spacing-lg;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
@include flex-center;
.logo {
display: flex;
align-items: center;
color: white;
font-size: $font-size-large;
font-weight: bold;
text-decoration: none;
.logo-icon {
width: 32px;
height: 32px;
margin-right: $spacing-sm;
background: linear-gradient(45deg, $primary-color, lighten($primary-color, 10%));
border-radius: $border-radius-base;
@include flex-center;
color: white;
}
.logo-text {
transition: opacity $transition-duration-base;
.collapsed & {
opacity: 0;
width: 0;
overflow: hidden;
}
}
}
}
.sidebar-menu {
flex: 1;
padding: $spacing-lg 0;
overflow-y: auto;
@include custom-scrollbar(rgba(255, 255, 255, 0.3), transparent, 4px);
.menu-item {
display: block;
padding: $spacing-md $spacing-lg;
color: $sidebar-text-color;
text-decoration: none;
transition: all $transition-duration-fast;
position: relative;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
&.active {
background-color: rgba($primary-color, 0.2);
color: $primary-color;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: $primary-color;
}
}
.menu-icon {
width: 20px;
margin-right: $spacing-sm;
text-align: center;
transition: margin-right $transition-duration-base;
.collapsed & {
margin-right: 0;
}
}
.menu-text {
transition: opacity $transition-duration-base;
.collapsed & {
opacity: 0;
width: 0;
overflow: hidden;
}
}
}
}
.sidebar-footer {
padding: $spacing-lg;
border-top: 1px solid rgba(255, 255, 255, 0.1);
.collapse-toggle {
width: 100%;
padding: $spacing-sm;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: $border-radius-base;
color: $sidebar-text-color;
cursor: pointer;
transition: all $transition-duration-fast;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
}
}
}
// 主要內容區
.layout-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: $bg-color-page;
// 頂部導航欄
.layout-header {
height: 60px;
background-color: $header-bg;
border-bottom: 1px solid $border-color-lighter;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
@include flex-between;
padding: 0 $spacing-lg;
z-index: $z-index-normal;
@include respond-to(md) {
padding: 0 $spacing-md;
}
.header-left {
display: flex;
align-items: center;
.menu-toggle {
display: none;
padding: $spacing-sm;
background: transparent;
border: none;
cursor: pointer;
margin-right: $spacing-md;
@include respond-to(md) {
display: block;
}
}
.breadcrumb {
display: flex;
align-items: center;
font-size: $font-size-base;
color: $text-color-secondary;
.breadcrumb-item {
&:not(:last-child)::after {
content: '/';
margin: 0 $spacing-sm;
color: $text-color-placeholder;
}
&:last-child {
color: $text-color-primary;
font-weight: 500;
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: $spacing-md;
.notification-bell {
position: relative;
cursor: pointer;
padding: $spacing-sm;
border-radius: $border-radius-base;
transition: background-color $transition-duration-fast;
&:hover {
background-color: $bg-color-light;
}
.badge {
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
background-color: $danger-color;
border-radius: 50%;
}
}
.user-avatar {
cursor: pointer;
.avatar-button {
display: flex;
align-items: center;
padding: $spacing-sm;
border-radius: $border-radius-base;
transition: background-color $transition-duration-fast;
&:hover {
background-color: $bg-color-light;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(45deg, $primary-color, lighten($primary-color, 10%));
@include flex-center;
color: white;
font-weight: bold;
margin-right: $spacing-sm;
@include respond-to(sm) {
margin-right: 0;
}
}
.user-info {
@include respond-to(sm) {
display: none;
}
.user-name {
font-size: $font-size-base;
font-weight: 500;
color: $text-color-primary;
line-height: 1.2;
}
.user-role {
font-size: $font-size-small;
color: $text-color-secondary;
line-height: 1.2;
}
}
}
}
}
}
// 內容區域
.layout-content {
flex: 1;
overflow: hidden;
position: relative;
.content-wrapper {
height: 100%;
overflow: auto;
padding: $spacing-lg;
@include respond-to(md) {
padding: $spacing-md;
}
@include respond-to(sm) {
padding: $spacing-sm;
}
}
}
}
}
// 移動設備遮罩
.mobile-mask {
display: none;
@include respond-to(md) {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: $z-index-top - 1;
opacity: 0;
visibility: hidden;
transition: all $transition-duration-base;
&.show {
opacity: 1;
visibility: visible;
}
}
}
// 登入頁面布局
.login-layout {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@include flex-center;
padding: $spacing-lg;
.login-container {
width: 100%;
max-width: 400px;
background: white;
border-radius: $border-radius-base * 2;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
.login-header {
background: linear-gradient(45deg, $primary-color, lighten($primary-color, 10%));
padding: $spacing-xxl;
text-align: center;
color: white;
.login-logo {
width: 64px;
height: 64px;
margin: 0 auto $spacing-lg;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
@include flex-center;
font-size: $font-size-extra-large;
font-weight: bold;
}
.login-title {
font-size: $font-size-extra-large;
font-weight: bold;
margin-bottom: $spacing-sm;
}
.login-subtitle {
font-size: $font-size-base;
opacity: 0.9;
}
}
.login-body {
padding: $spacing-xxl;
}
.login-footer {
padding: $spacing-lg $spacing-xxl;
background-color: $bg-color-light;
text-align: center;
color: $text-color-secondary;
font-size: $font-size-small;
}
}
}
// 頁面標題區域
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 1px solid $border-color-lighter;
@include respond-to(sm) {
flex-direction: column;
align-items: flex-start;
gap: $spacing-md;
}
.page-title {
font-size: $font-size-extra-large;
font-weight: bold;
color: $text-color-primary;
margin: 0;
}
.page-actions {
display: flex;
gap: $spacing-sm;
}
}
// 內容卡片
.content-card {
@include card-style;
&:not(:last-child) {
margin-bottom: $spacing-lg;
}
.card-header {
@include flex-between;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 1px solid $border-color-lighter;
.card-title {
font-size: $font-size-large;
font-weight: 600;
color: $text-color-primary;
margin: 0;
}
.card-actions {
display: flex;
gap: $spacing-sm;
}
}
.card-body {
// 內容樣式由具體組件定義
}
.card-footer {
margin-top: $spacing-lg;
padding-top: $spacing-md;
border-top: 1px solid $border-color-lighter;
@include flex-between;
}
}

View File

@@ -0,0 +1,187 @@
// 主要樣式文件
@import './variables.scss';
@import './mixins.scss';
@import './components.scss';
@import './layouts.scss';
// 全局重置樣式
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
height: 100%;
font-size: 14px;
}
body {
height: 100%;
font-family: $font-family;
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
}
// 滾動條樣式
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 4px;
&:hover {
background: var(--el-border-color-darker);
}
}
// Firefox 滾動條
* {
scrollbar-width: thin;
scrollbar-color: var(--el-border-color) var(--el-fill-color-lighter);
}
// 文字選擇顏色
::selection {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
::-moz-selection {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
// 通用輔助類別
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.flex { display: flex; }
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
// 間距輔助類別
@for $i from 1 through 10 {
.m-#{$i} { margin: #{$i * 4}px; }
.mt-#{$i} { margin-top: #{$i * 4}px; }
.mr-#{$i} { margin-right: #{$i * 4}px; }
.mb-#{$i} { margin-bottom: #{$i * 4}px; }
.ml-#{$i} { margin-left: #{$i * 4}px; }
.mx-#{$i} {
margin-left: #{$i * 4}px;
margin-right: #{$i * 4}px;
}
.my-#{$i} {
margin-top: #{$i * 4}px;
margin-bottom: #{$i * 4}px;
}
.p-#{$i} { padding: #{$i * 4}px; }
.pt-#{$i} { padding-top: #{$i * 4}px; }
.pr-#{$i} { padding-right: #{$i * 4}px; }
.pb-#{$i} { padding-bottom: #{$i * 4}px; }
.pl-#{$i} { padding-left: #{$i * 4}px; }
.px-#{$i} {
padding-left: #{$i * 4}px;
padding-right: #{$i * 4}px;
}
.py-#{$i} {
padding-top: #{$i * 4}px;
padding-bottom: #{$i * 4}px;
}
}
// 響應式斷點
.hidden-xs {
@include respond-to(xs) { display: none !important; }
}
.hidden-sm {
@include respond-to(sm) { display: none !important; }
}
.hidden-md {
@include respond-to(md) { display: none !important; }
}
.hidden-lg {
@include respond-to(lg) { display: none !important; }
}
// 動畫類別
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(20px);
opacity: 0;
}
// 卡片陰影
.card-shadow {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.card-hover-shadow {
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
}
}
// 載入狀態
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}

View File

@@ -0,0 +1,272 @@
// SCSS Mixins 混合器
// 響應式斷點混合器
@mixin respond-to($breakpoint) {
@if $breakpoint == xs {
@media (max-width: #{$breakpoint-xs - 1px}) { @content; }
}
@if $breakpoint == sm {
@media (max-width: #{$breakpoint-sm - 1px}) { @content; }
}
@if $breakpoint == md {
@media (max-width: #{$breakpoint-md - 1px}) { @content; }
}
@if $breakpoint == lg {
@media (max-width: #{$breakpoint-lg - 1px}) { @content; }
}
@if $breakpoint == xl {
@media (min-width: $breakpoint-xl) { @content; }
}
}
// 最小寬度斷點
@mixin respond-above($breakpoint) {
@if $breakpoint == xs {
@media (min-width: $breakpoint-xs) { @content; }
}
@if $breakpoint == sm {
@media (min-width: $breakpoint-sm) { @content; }
}
@if $breakpoint == md {
@media (min-width: $breakpoint-md) { @content; }
}
@if $breakpoint == lg {
@media (min-width: $breakpoint-lg) { @content; }
}
}
// Flexbox 輔助混合器
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin flex-start {
display: flex;
align-items: center;
justify-content: flex-start;
}
@mixin flex-end {
display: flex;
align-items: center;
justify-content: flex-end;
}
@mixin flex-column {
display: flex;
flex-direction: column;
}
@mixin flex-column-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
// 文字省略號
@mixin text-ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin multi-line-ellipsis($lines: 2) {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
// 清除浮動
@mixin clearfix {
&::after {
content: '';
display: table;
clear: both;
}
}
// 隱藏滾動條
@mixin hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
&::-webkit-scrollbar {
display: none; /* Chrome Safari */
}
}
// 自定義滾動條
@mixin custom-scrollbar($thumb-color: $border-color, $track-color: transparent, $size: 6px) {
&::-webkit-scrollbar {
width: $size;
height: $size;
}
&::-webkit-scrollbar-track {
background: $track-color;
border-radius: $size / 2;
}
&::-webkit-scrollbar-thumb {
background: $thumb-color;
border-radius: $size / 2;
&:hover {
background: darken($thumb-color, 10%);
}
}
}
// 絕對定位置中
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@mixin absolute-center-x {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
@mixin absolute-center-y {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
// 固定比例容器
@mixin aspect-ratio($width: 16, $height: 9) {
position: relative;
overflow: hidden;
&::before {
content: '';
display: block;
width: 100%;
padding-top: ($height / $width) * 100%;
}
> * {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
// 過渡動畫
@mixin transition($property: all, $duration: $transition-duration-base, $timing-function: ease-in-out) {
transition: $property $duration $timing-function;
}
@mixin hover-lift {
transition: transform $transition-duration-fast ease-out, box-shadow $transition-duration-fast ease-out;
&:hover {
transform: translateY(-2px);
box-shadow: $box-shadow-dark;
}
}
// 按鈕樣式混合器
@mixin button-variant($color, $background, $border: $background) {
color: $color;
background-color: $background;
border-color: $border;
&:hover,
&:focus {
color: $color;
background-color: lighten($background, 5%);
border-color: lighten($border, 5%);
}
&:active {
color: $color;
background-color: darken($background, 5%);
border-color: darken($border, 5%);
}
}
// 狀態標籤樣式
@mixin status-badge($color) {
display: inline-block;
padding: 2px 8px;
font-size: $font-size-small;
font-weight: 500;
color: white;
background-color: $color;
border-radius: $border-radius-base;
text-transform: uppercase;
letter-spacing: 0.5px;
}
// 卡片樣式
@mixin card-style($padding: $spacing-lg, $border-radius: $border-radius-base) {
background: $bg-color;
border: 1px solid $border-color-lighter;
border-radius: $border-radius;
box-shadow: $box-shadow-light;
padding: $padding;
transition: box-shadow $transition-duration-base;
&:hover {
box-shadow: $box-shadow-dark;
}
}
// 表單輸入樣式
@mixin form-input {
display: block;
width: 100%;
padding: 8px 12px;
font-size: $font-size-base;
line-height: $line-height-base;
color: $text-color-primary;
background-color: $bg-color;
border: 1px solid $border-color;
border-radius: $border-radius-base;
transition: border-color $transition-duration-fast, box-shadow $transition-duration-fast;
&:focus {
outline: none;
border-color: $primary-color;
box-shadow: 0 0 0 2px rgba($primary-color, 0.2);
}
&:disabled {
background-color: $bg-color-light;
color: $text-color-placeholder;
cursor: not-allowed;
}
}
// Loading 動畫
@mixin loading-spinner($size: 20px, $color: $primary-color) {
width: $size;
height: $size;
border: 2px solid transparent;
border-top-color: $color;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,106 @@
// SCSS 變數定義
// 顏色系統
$primary-color: #409eff;
$success-color: #67c23a;
$warning-color: #e6a23c;
$danger-color: #f56c6c;
$info-color: #909399;
// 文字顏色
$text-color-primary: #303133;
$text-color-regular: #606266;
$text-color-secondary: #909399;
$text-color-placeholder: #c0c4cc;
// 背景顏色
$bg-color-page: #f2f3f5;
$bg-color: #ffffff;
$bg-color-light: #fafafa;
// 邊框顏色
$border-color: #dcdfe6;
$border-color-light: #e4e7ed;
$border-color-lighter: #ebeef5;
$border-color-extra-light: #f2f6fc;
// 字體
$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
$font-size-extra-large: 20px;
$font-size-large: 18px;
$font-size-medium: 16px;
$font-size-base: 14px;
$font-size-small: 13px;
$font-size-extra-small: 12px;
// 行高
$line-height-base: 1.5;
// 間距
$spacing-base: 4px;
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 16px;
$spacing-xl: 20px;
$spacing-xxl: 24px;
// 邊框半徑
$border-radius-base: 4px;
$border-radius-small: 2px;
$border-radius-round: 20px;
$border-radius-circle: 50%;
// 陰影
$box-shadow-base: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04);
$box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, .1);
$box-shadow-dark: 0 4px 20px 0 rgba(0, 0, 0, .15);
// z-index 層級
$z-index-normal: 1;
$z-index-top: 1000;
$z-index-popper: 2000;
$z-index-modal: 3000;
// 斷點
$breakpoint-xs: 480px;
$breakpoint-sm: 768px;
$breakpoint-md: 992px;
$breakpoint-lg: 1200px;
$breakpoint-xl: 1920px;
// 動畫持續時間
$transition-duration-fast: 0.2s;
$transition-duration-base: 0.3s;
$transition-duration-slow: 0.5s;
// 動畫緩動函數
$ease-in-out-circ: cubic-bezier(0.78, 0.14, 0.15, 0.86);
$ease-out-back: cubic-bezier(0.12, 0.4, 0.29, 1.46);
$ease-in-out-back: cubic-bezier(0.71, -0.46, 0.29, 1.46);
// 組件特定顏色
$header-bg: #fff;
$sidebar-bg: #304156;
$sidebar-text-color: #bfcbd9;
$sidebar-active-color: #409eff;
// 狀態顏色映射
$status-colors: (
'PENDING': #909399,
'PROCESSING': #409eff,
'COMPLETED': #67c23a,
'FAILED': #f56c6c,
'RETRY': #e6a23c
);
// 檔案類型圖示顏色
$file-type-colors: (
'docx': #2b579a,
'doc': #2b579a,
'pptx': #d24726,
'ppt': #d24726,
'xlsx': #207245,
'xls': #207245,
'pdf': #ff0000
);

View File

@@ -0,0 +1,196 @@
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
import NProgress from 'nprogress'
// 創建 axios 實例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000/api/v1',
timeout: 30000, // 30秒超時
headers: {
'Content-Type': 'application/json'
}
})
// 請求攔截器
service.interceptors.request.use(
config => {
NProgress.start()
console.log('🚀 [API Request]', {
method: config.method.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
fullURL: `${config.baseURL}${config.url}`,
headers: config.headers,
timestamp: new Date().toISOString()
})
// JWT 認證:添加 Authorization header
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
console.log('🔑 [JWT Token]', `Bearer ${authStore.token.substring(0, 20)}...`)
}
return config
},
error => {
NProgress.done()
console.error('❌ [Request Error]:', error)
return Promise.reject(error)
}
)
// 回應攔截器
service.interceptors.response.use(
response => {
NProgress.done()
console.log('✅ [API Response]', {
status: response.status,
statusText: response.statusText,
url: response.config.url,
method: response.config.method.toUpperCase(),
data: response.data,
headers: response.headers,
timestamp: new Date().toISOString()
})
const { data } = response
// 後端統一回應格式處理
if (data && typeof data === 'object') {
if (data.success === false) {
// 業務錯誤處理
const message = data.message || '操作失敗'
console.warn('⚠️ [Business Error]:', message)
ElMessage.error(message)
return Promise.reject(new Error(message))
}
return data
}
return response
},
error => {
NProgress.done()
const { response } = error
const authStore = useAuthStore()
if (response) {
const { status, data } = response
switch (status) {
case 401:
// 避免在登入頁面或登入過程中觸發自動登出
const requestUrl = error.config?.url || ''
const currentPath = router.currentRoute.value.path
console.error('🔐 [401 Unauthorized]', {
requestUrl,
currentPath,
isLoginPage: currentPath === '/login',
isLoginRequest: requestUrl.includes('/auth/login'),
willTriggerLogout: currentPath !== '/login' && !requestUrl.includes('/auth/login'),
timestamp: new Date().toISOString(),
errorData: data,
requestHeaders: error.config?.headers
})
if (currentPath !== '/login' && !requestUrl.includes('/auth/login')) {
console.error('🚪 [Auto Logout] 認證失效,觸發自動登出')
ElMessage.error('認證失效,請重新登入')
authStore.logout()
router.push('/login')
} else {
console.log('🔐 [401 Ignored] 在登入頁面或登入請求,不觸發自動登出')
}
break
case 403:
ElMessage.error('無權限存取此資源')
break
case 404:
ElMessage.error('請求的資源不存在')
break
case 422:
// 表單驗證錯誤
const message = data.message || '輸入資料格式錯誤'
ElMessage.error(message)
break
case 429:
ElMessage.error('請求過於頻繁,請稍後再試')
break
case 500:
ElMessage.error('伺服器內部錯誤')
break
case 502:
case 503:
case 504:
ElMessage.error('伺服器暫時無法存取,請稍後再試')
break
default:
const errorMessage = data?.message || error.message || '網路錯誤'
ElMessage.error(errorMessage)
}
} else if (error.code === 'ECONNABORTED') {
ElMessage.error('請求超時,請檢查網路連線')
} else {
ElMessage.error('網路連線失敗,請檢查網路設定')
}
return Promise.reject(error)
}
)
// 檔案上傳專用請求實例
export const uploadRequest = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000/api/v1',
timeout: 120000, // 2分鐘超時
headers: {
'Content-Type': 'multipart/form-data'
}
})
// 為上傳請求添加攔截器
uploadRequest.interceptors.request.use(
config => {
// JWT 認證:添加 Authorization header
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
},
error => Promise.reject(error)
)
uploadRequest.interceptors.response.use(
response => response.data,
error => {
const message = error.response?.data?.message || '檔案上傳失敗'
ElMessage.error(message)
return Promise.reject(error)
}
)
// 常用請求方法封裝
export const request = {
get: (url, config = {}) => service.get(url, config),
post: (url, data = {}, config = {}) => service.post(url, data, config),
put: (url, data = {}, config = {}) => service.put(url, data, config),
delete: (url, config = {}) => service.delete(url, config),
patch: (url, data = {}, config = {}) => service.patch(url, data, config)
}
export default service

View File

@@ -0,0 +1,323 @@
import { io } from 'socket.io-client'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElNotification } from 'element-plus'
/**
* WebSocket 服務類
*/
class WebSocketService {
constructor() {
this.socket = null
this.isConnected = false
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectInterval = 5000
this.jobSubscriptions = new Set()
}
/**
* 初始化並連接 WebSocket
*/
connect() {
// 暫時禁用 WebSocket 連接
console.warn('WebSocket 功能已暫時禁用,避免連接錯誤')
return
// 以下代碼已暫時禁用
/*
if (this.socket) {
return
}
try {
// 建立 Socket.IO 連接
const wsUrl = import.meta.env.VITE_WS_BASE_URL || 'ws://127.0.0.1:5000'
this.socket = io(wsUrl, {
path: '/socket.io/',
transports: ['websocket', 'polling'],
upgrade: true,
rememberUpgrade: true,
autoConnect: true,
forceNew: false,
reconnection: true,
reconnectionDelay: this.reconnectInterval,
reconnectionAttempts: this.maxReconnectAttempts
})
this.setupEventHandlers()
} catch (error) {
console.error('WebSocket 連接失敗:', error)
}
*/
}
/**
* 設定事件處理器
*/
setupEventHandlers() {
if (!this.socket) return
// 連接成功
this.socket.on('connect', () => {
console.log('WebSocket 已連接')
this.isConnected = true
this.reconnectAttempts = 0
// 重新訂閱所有任務
this.resubscribeJobs()
})
// 連接失敗
this.socket.on('connect_error', (error) => {
console.error('WebSocket 連接錯誤:', error)
this.isConnected = false
})
// 斷線
this.socket.on('disconnect', (reason) => {
console.log('WebSocket 已斷線:', reason)
this.isConnected = false
if (reason === 'io server disconnect') {
// 服務器主動斷線,需要重新連接
this.socket.connect()
}
})
// 任務狀態更新
this.socket.on('job_status', (data) => {
this.handleJobStatusUpdate(data)
})
// 系統通知
this.socket.on('system_notification', (data) => {
this.handleSystemNotification(data)
})
// 連接狀態回應
this.socket.on('connected', (data) => {
console.log('WebSocket 連接確認:', data)
})
// 訂閱成功回應
this.socket.on('subscribed', (data) => {
console.log('任務訂閱成功:', data.job_uuid)
})
// 取消訂閱成功回應
this.socket.on('unsubscribed', (data) => {
console.log('任務取消訂閱成功:', data.job_uuid)
})
// 錯誤處理
this.socket.on('error', (error) => {
console.error('WebSocket 錯誤:', error)
ElMessage.error(error.message || 'WebSocket 連接錯誤')
})
}
/**
* 處理任務狀態更新
* @param {Object} data - 狀態更新資料
*/
handleJobStatusUpdate(data) {
try {
if (data.type === 'job_status' && data.data) {
const jobsStore = useJobsStore()
const { job_uuid, ...statusUpdate } = data.data
// 更新任務狀態
jobsStore.updateJobStatus(job_uuid, statusUpdate)
console.log('任務狀態已更新:', job_uuid, statusUpdate)
}
} catch (error) {
console.error('處理任務狀態更新失敗:', error)
}
}
/**
* 處理系統通知
* @param {Object} data - 通知資料
*/
handleSystemNotification(data) {
const { type, message, title, level } = data
switch (level) {
case 'success':
ElNotification.success({
title: title || '系統通知',
message: message,
duration: 5000
})
break
case 'warning':
ElNotification.warning({
title: title || '系統警告',
message: message,
duration: 8000
})
break
case 'error':
ElNotification.error({
title: title || '系統錯誤',
message: message,
duration: 10000
})
break
default:
ElNotification({
title: title || '系統消息',
message: message,
duration: 5000
})
}
}
/**
* 訂閱任務狀態更新
* @param {string} jobUuid - 任務 UUID
*/
subscribeToJob(jobUuid) {
if (!this.socket || !this.isConnected) {
// 靜默處理,避免控制台警告
return
}
if (this.jobSubscriptions.has(jobUuid)) {
return // 已經訂閱過
}
this.socket.emit('subscribe_job', { job_uuid: jobUuid })
this.jobSubscriptions.add(jobUuid)
}
/**
* 取消訂閱任務狀態更新
* @param {string} jobUuid - 任務 UUID
*/
unsubscribeFromJob(jobUuid) {
if (!this.socket || !this.isConnected) {
return
}
this.socket.emit('unsubscribe_job', { job_uuid: jobUuid })
this.jobSubscriptions.delete(jobUuid)
}
/**
* 重新訂閱所有任務
*/
resubscribeJobs() {
if (!this.isConnected) return
this.jobSubscriptions.forEach(jobUuid => {
this.socket.emit('subscribe_job', { job_uuid: jobUuid })
})
}
/**
* 批量訂閱任務
* @param {string[]} jobUuids - 任務 UUID 陣列
*/
subscribeToJobs(jobUuids) {
jobUuids.forEach(jobUuid => {
this.subscribeToJob(jobUuid)
})
}
/**
* 批量取消訂閱任務
* @param {string[]} jobUuids - 任務 UUID 陣列
*/
unsubscribeFromJobs(jobUuids) {
jobUuids.forEach(jobUuid => {
this.unsubscribeFromJob(jobUuid)
})
}
/**
* 發送自定義事件
* @param {string} event - 事件名稱
* @param {Object} data - 事件資料
*/
emit(event, data) {
if (this.socket && this.isConnected) {
this.socket.emit(event, data)
}
}
/**
* 監聽自定義事件
* @param {string} event - 事件名稱
* @param {Function} callback - 回調函數
*/
on(event, callback) {
if (this.socket) {
this.socket.on(event, callback)
}
}
/**
* 取消監聽事件
* @param {string} event - 事件名稱
* @param {Function} callback - 回調函數
*/
off(event, callback) {
if (this.socket) {
this.socket.off(event, callback)
}
}
/**
* 斷開連接
*/
disconnect() {
if (this.socket) {
this.jobSubscriptions.clear()
this.socket.disconnect()
this.socket = null
this.isConnected = false
console.log('WebSocket 已主動斷開')
}
}
/**
* 重新連接
*/
reconnect() {
this.disconnect()
setTimeout(() => {
this.connect()
}, 1000)
}
/**
* 取得連接狀態
*/
getConnectionStatus() {
return {
isConnected: this.isConnected,
socket: this.socket,
subscriptions: Array.from(this.jobSubscriptions)
}
}
}
// 創建全局實例
export const websocketService = new WebSocketService()
// 自動連接(在需要時)
export const initWebSocket = () => {
websocketService.connect()
}
// 清理連接(在登出時)
export const cleanupWebSocket = () => {
websocketService.disconnect()
}
export default websocketService

View File

@@ -0,0 +1,799 @@
<template>
<div class="admin-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">管理後台</h1>
<div class="page-actions">
<el-dropdown @command="handleExportCommand">
<el-button>
<el-icon><Download /></el-icon>
匯出報表
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="usage">使用統計報表</el-dropdown-item>
<el-dropdown-item command="cost">成本分析報表</el-dropdown-item>
<el-dropdown-item command="jobs">任務清單報表</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button @click="refreshData" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新資料
</el-button>
</div>
</div>
<!-- 系統概覽 -->
<div class="overview-section">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><DataBoard /></el-icon>
</div>
<div class="stat-value">{{ overviewStats.total_jobs || 0 }}</div>
<div class="stat-label">總任務數</div>
<div class="stat-change positive" v-if="dailyStats.length > 0">
+{{ dailyStats[dailyStats.length - 1]?.jobs || 0 }} 今日
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<el-icon><SuccessFilled /></el-icon>
</div>
<div class="stat-value">{{ overviewStats.completed_jobs || 0 }}</div>
<div class="stat-label">已完成</div>
<div class="stat-change positive" v-if="dailyStats.length > 0">
+{{ dailyStats[dailyStats.length - 1]?.completed || 0 }} 今日
</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><User /></el-icon>
</div>
<div class="stat-value">{{ overviewStats.active_users_today || 0 }}</div>
<div class="stat-label">今日活躍用戶</div>
<div class="stat-total">總用戶: {{ overviewStats.total_users || 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-icon info">
<el-icon><Money /></el-icon>
</div>
<div class="stat-value">${{ (overviewStats.total_cost || 0).toFixed(4) }}</div>
<div class="stat-label">總成本</div>
<div class="stat-change positive" v-if="dailyStats.length > 0">
+${{ (dailyStats[dailyStats.length - 1]?.cost || 0).toFixed(4) }} 今日
</div>
</div>
</div>
</div>
<!-- 圖表區域 -->
<div class="charts-section">
<div class="chart-row">
<!-- 每日任務統計圖表 -->
<div class="content-card chart-card">
<div class="card-header">
<h3 class="card-title">每日任務統計</h3>
<div class="card-actions">
<el-select v-model="chartPeriod" @change="handlePeriodChange" size="small">
<el-option label="最近 7 天" value="week" />
<el-option label="最近 30 天" value="month" />
<el-option label="最近 90 天" value="quarter" />
</el-select>
</div>
</div>
<div class="card-body">
<div ref="dailyChartRef" class="chart-container"></div>
</div>
</div>
<!-- 成本趨勢圖表 -->
<div class="content-card chart-card">
<div class="card-header">
<h3 class="card-title">成本趨勢</h3>
</div>
<div class="card-body">
<div ref="costChartRef" class="chart-container"></div>
</div>
</div>
</div>
</div>
<!-- 用戶排行與系統狀態 -->
<div class="info-section">
<div class="info-row">
<!-- 用戶使用排行 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">用戶使用排行</h3>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="userRankings.length === 0" class="empty-state">
<el-icon class="empty-icon"><User /></el-icon>
<div class="empty-title">暫無用戶資料</div>
</div>
<div v-else class="user-rankings">
<div
v-for="(user, index) in userRankings"
:key="user.user_id"
class="ranking-item"
>
<div class="ranking-position">
<div class="position-number" :class="{
gold: index === 0,
silver: index === 1,
bronze: index === 2
}">
{{ index + 1 }}
</div>
</div>
<div class="user-info">
<div class="user-name">{{ user.display_name }}</div>
<div class="user-stats">
<span>{{ user.job_count }} 個任務</span>
<span>${{ user.total_cost.toFixed(4) }}</span>
</div>
</div>
<div class="ranking-progress">
<el-progress
:percentage="Math.round((user.job_count / userRankings[0]?.job_count) * 100)"
:stroke-width="6"
:show-text="false"
:color="index < 3 ? '#409eff' : '#e6e6e6'"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 系統狀態 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">系統狀態</h3>
<div class="card-actions">
<el-button
type="text"
size="small"
@click="cleanupOldFiles"
:loading="cleanupLoading"
>
<el-icon><Delete /></el-icon>
清理檔案
</el-button>
</div>
</div>
<div class="card-body">
<div class="system-health">
<div class="health-item">
<div class="health-label">系統狀態</div>
<div class="health-value">
<el-tag
:type="isSystemHealthy ? 'success' : 'danger'"
size="small"
>
{{ isSystemHealthy ? '正常' : '異常' }}
</el-tag>
</div>
</div>
<div class="health-item" v-if="systemMetrics">
<div class="health-label">排隊任務</div>
<div class="health-value">
{{ systemMetrics.jobs?.pending || 0 }}
</div>
</div>
<div class="health-item" v-if="systemMetrics">
<div class="health-label">處理中任務</div>
<div class="health-value">
{{ systemMetrics.jobs?.processing || 0 }}
</div>
</div>
<div class="health-item">
<div class="health-label">上次更新</div>
<div class="health-value">
{{ formatTime(new Date().toISOString()) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 最新任務 -->
<div class="recent-jobs-section">
<div class="content-card">
<div class="card-header">
<h3 class="card-title">最新任務</h3>
<div class="card-actions">
<el-button type="text" @click="$router.push('/admin/jobs')">
查看全部任務
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="recentJobs.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="recentJobs" 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="user_name" label="用戶" width="120" />
<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="created_at" label="建立時間" width="120">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
type="text"
size="small"
@click="viewJobDetail(row.job_uuid)"
>
查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import {
Download, ArrowDown, Refresh, DataBoard, SuccessFilled,
User, Money, Delete, ArrowRight, Document
} from '@element-plus/icons-vue'
// Router 和 Store
const router = useRouter()
const adminStore = useAdminStore()
// 圖表引用
const dailyChartRef = ref()
const costChartRef = ref()
// 響應式數據
const loading = ref(false)
const cleanupLoading = ref(false)
const chartPeriod = ref('month')
const dailyChart = ref(null)
const costChart = ref(null)
// 計算屬性
const overviewStats = computed(() => adminStore.overviewStats)
const dailyStats = computed(() => adminStore.dailyStats)
const userRankings = computed(() => adminStore.userRankings.slice(0, 10))
const isSystemHealthy = computed(() => adminStore.isSystemHealthy)
const systemMetrics = computed(() => adminStore.systemMetrics)
const recentJobs = computed(() => {
return adminStore.allJobs.slice(0, 10)
})
// 語言映射
const languageMap = {
'zh-TW': '繁中',
'zh-CN': '簡中',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越文'
}
// 方法
const refreshData = async () => {
loading.value = true
try {
await Promise.all([
adminStore.fetchStats(chartPeriod.value),
adminStore.fetchAllJobs({ per_page: 10 }),
adminStore.fetchSystemHealth(),
adminStore.fetchSystemMetrics()
])
await nextTick()
initCharts()
} catch (error) {
console.error('刷新資料失敗:', error)
ElMessage.error('刷新資料失敗')
} finally {
loading.value = false
}
}
const handlePeriodChange = async () => {
await refreshData()
}
const handleExportCommand = async (command) => {
try {
await adminStore.exportReport(command)
} catch (error) {
console.error('匯出報表失敗:', error)
ElMessage.error('匯出報表失敗')
}
}
const cleanupOldFiles = async () => {
try {
cleanupLoading.value = true
await adminStore.cleanupOldFiles()
ElMessage.success('檔案清理完成')
} catch (error) {
console.error('檔案清理失敗:', error)
} finally {
cleanupLoading.value = false
}
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const initCharts = () => {
initDailyChart()
initCostChart()
}
const initDailyChart = () => {
if (!dailyChartRef.value || dailyStats.value.length === 0) return
if (dailyChart.value) {
dailyChart.value.dispose()
}
dailyChart.value = echarts.init(dailyChartRef.value)
const dates = dailyStats.value.map(stat => stat.date)
const jobs = dailyStats.value.map(stat => stat.jobs)
const completed = dailyStats.value.map(stat => stat.completed)
const failed = dailyStats.value.map(stat => stat.failed)
const option = {
title: {
text: '每日任務統計',
textStyle: { fontSize: 14, fontWeight: 'normal' }
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
legend: {
data: ['總任務', '已完成', '失敗']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
formatter: (value) => {
const date = new Date(value)
return `${date.getMonth() + 1}/${date.getDate()}`
}
}
},
yAxis: {
type: 'value'
},
series: [
{
name: '總任務',
type: 'bar',
data: jobs,
itemStyle: { color: '#409eff' }
},
{
name: '已完成',
type: 'bar',
data: completed,
itemStyle: { color: '#67c23a' }
},
{
name: '失敗',
type: 'bar',
data: failed,
itemStyle: { color: '#f56c6c' }
}
]
}
dailyChart.value.setOption(option)
}
const initCostChart = () => {
if (!costChartRef.value || dailyStats.value.length === 0) return
if (costChart.value) {
costChart.value.dispose()
}
costChart.value = echarts.init(costChartRef.value)
const dates = dailyStats.value.map(stat => stat.date)
const costs = dailyStats.value.map(stat => stat.cost)
const option = {
title: {
text: '每日成本趨勢',
textStyle: { fontSize: 14, fontWeight: 'normal' }
},
tooltip: {
trigger: 'axis',
formatter: (params) => {
const data = params[0]
return `${data.name}<br/>成本: $${data.value.toFixed(4)}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
axisLabel: {
formatter: (value) => {
const date = new Date(value)
return `${date.getMonth() + 1}/${date.getDate()}`
}
}
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value) => `$${value.toFixed(4)}`
}
},
series: [
{
name: '成本',
type: 'line',
data: costs,
smooth: true,
itemStyle: { color: '#e6a23c' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(230, 162, 60, 0.3)' },
{ offset: 1, color: 'rgba(230, 162, 60, 0.1)' }
])
}
}
]
}
costChart.value.setOption(option)
}
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')
}
// 視窗大小調整處理
const handleResize = () => {
if (dailyChart.value) {
dailyChart.value.resize()
}
if (costChart.value) {
costChart.value.resize()
}
}
// 生命週期
onMounted(async () => {
await refreshData()
// 監聽視窗大小變化
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (dailyChart.value) {
dailyChart.value.dispose()
}
if (costChart.value) {
costChart.value.dispose()
}
})
</script>
<style lang="scss" scoped>
.admin-view {
.overview-section {
margin-bottom: 24px;
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
.stat-total {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
}
.charts-section {
margin-bottom: 24px;
.chart-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
.chart-card {
.chart-container {
height: 300px;
width: 100%;
}
}
}
}
.info-section {
margin-bottom: 24px;
.info-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.user-rankings {
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.ranking-position {
margin-right: 16px;
.position-number {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
background-color: var(--el-color-info);
&.gold {
background: linear-gradient(45deg, #ffd700, #ffed4e);
color: #8b4513;
}
&.silver {
background: linear-gradient(45deg, #c0c0c0, #e8e8e8);
color: #666;
}
&.bronze {
background: linear-gradient(45deg, #cd7f32, #daa520);
color: white;
}
}
}
.user-info {
flex: 1;
min-width: 0;
.user-name {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.user-stats {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.ranking-progress {
width: 80px;
margin-left: 16px;
}
}
}
.system-health {
.health-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.health-label {
color: var(--el-text-color-regular);
}
.health-value {
font-weight: 500;
color: var(--el-text-color-primary);
}
}
}
}
.recent-jobs-section {
.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;
}
}
}
.loading-state {
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,840 @@
<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 ext = getFileExtension(job.original_filename)
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${ext}`
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 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>

View File

@@ -0,0 +1,652 @@
<template>
<div class="home-view">
<!-- 歡迎區域 -->
<div class="welcome-section">
<div class="welcome-card content-card">
<div class="welcome-content">
<div class="welcome-text">
<h1 class="welcome-title">
歡迎使用 PANJIT 文件翻譯系統
<el-tag v-if="authStore.isAdmin" type="warning" size="small">管理員</el-tag>
</h1>
<p class="welcome-subtitle">
歡迎回來{{ authStore.userName }}
今天是個適合處理翻譯任務的好日子
</p>
</div>
<div class="welcome-actions">
<el-button type="primary" size="large" @click="$router.push('/upload')">
<el-icon><Upload /></el-icon>
開始上傳檔案
</el-button>
<el-button size="large" @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看我的任務
</el-button>
</div>
</div>
</div>
</div>
<!-- 統計概覽 -->
<div class="stats-section">
<div class="section-title">
<h2>任務統計</h2>
<el-button type="text" @click="refreshStats">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Files /></el-icon>
</div>
<div class="stat-value">{{ jobStats.total }}</div>
<div class="stat-label">總任務數</div>
</div>
<div class="stat-card">
<div class="stat-icon warning">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-value">{{ jobStats.pending + jobStats.processing }}</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">{{ jobStats.completed }}</div>
<div class="stat-label">已完成</div>
</div>
<div class="stat-card">
<div class="stat-icon danger">
<el-icon><CircleCloseFilled /></el-icon>
</div>
<div class="stat-value">{{ jobStats.failed }}</div>
<div class="stat-label">失敗</div>
</div>
</div>
</div>
<!-- 最近任務 -->
<div class="recent-jobs-section">
<div class="content-card">
<div class="card-header">
<h3 class="card-title">最近任務</h3>
<div class="card-actions">
<el-button type="text" @click="$router.push('/jobs')">
查看全部
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading-state">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="recentJobs.length === 0" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<div class="empty-title">暫無任務記錄</div>
<div class="empty-description">
開始上傳您的第一個檔案進行翻譯吧
</div>
<el-button type="primary" @click="$router.push('/upload')">
立即上傳
</el-button>
</div>
<div v-else class="job-list">
<div
v-for="job in recentJobs"
:key="job.job_uuid"
class="job-item"
@click="viewJobDetail(job.job_uuid)"
>
<div class="job-icon">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
</div>
<div class="job-info">
<div class="job-name">{{ job.original_filename }}</div>
<div class="job-details">
<span class="job-size">{{ formatFileSize(job.file_size) }}</span>
<span class="job-languages">
{{ job.target_languages.join(', ') }}
</span>
</div>
</div>
<div class="job-status">
<div class="status-badge" :class="job.status.toLowerCase()">
{{ getStatusText(job.status) }}
</div>
<div v-if="job.progress > 0 && job.status === 'PROCESSING'" class="job-progress">
<el-progress
:percentage="job.progress"
:stroke-width="4"
:show-text="false"
color="#409eff"
/>
</div>
</div>
<div class="job-time">
{{ formatTime(job.created_at) }}
</div>
<div class="job-actions" @click.stop>
<el-dropdown trigger="click" @command="handleJobAction($event, job)">
<el-button type="text" size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">查看詳情</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'COMPLETED'"
command="download"
>
下載檔案
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'FAILED'"
command="retry"
>
重新翻譯
</el-dropdown-item>
<el-dropdown-item command="delete" divided>刪除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 系統公告 -->
<div class="announcements-section" v-if="announcements.length > 0">
<div class="content-card">
<div class="card-header">
<h3 class="card-title">系統公告</h3>
</div>
<div class="card-body">
<div class="announcements-list">
<div
v-for="announcement in announcements"
:key="announcement.id"
class="announcement-item"
:class="announcement.type"
>
<div class="announcement-icon">
<el-icon>
<component :is="getAnnouncementIcon(announcement.type)" />
</el-icon>
</div>
<div class="announcement-content">
<div class="announcement-title">{{ announcement.title }}</div>
<div class="announcement-message">{{ announcement.message }}</div>
<div class="announcement-time">{{ formatTime(announcement.created_at) }}</div>
</div>
<div class="announcement-actions" v-if="announcement.actionText">
<el-button type="text" size="small" @click="handleAnnouncementAction(announcement)">
{{ announcement.actionText }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Upload, List, Refresh, Files, Clock, SuccessFilled, CircleCloseFilled,
ArrowRight, Document, More, InfoFilled, WarningFilled, CircleCheckFilled
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const authStore = useAuthStore()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const announcements = ref([
{
id: 1,
type: 'info',
title: '系統更新通知',
message: '系統已更新至最新版本,新增了批量下載功能。',
created_at: new Date().toISOString(),
actionText: '了解更多'
},
{
id: 2,
type: 'warning',
title: '維護通知',
message: '系統將於本週日凌晨 2:00-4:00 進行定期維護。',
created_at: new Date(Date.now() - 86400000).toISOString()
}
])
// 計算屬性
const jobStats = computed(() => jobsStore.jobStats)
const recentJobs = computed(() => {
return jobsStore.jobs.slice(0, 5)
})
// 方法
const refreshStats = async () => {
try {
await jobsStore.fetchJobs({ per_page: 10 })
ElMessage.success('統計資料已刷新')
} catch (error) {
console.error('刷新統計失敗:', error)
}
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const handleJobAction = async (action, job) => {
switch (action) {
case 'view':
viewJobDetail(job.job_uuid)
break
case 'download':
// 如果只有一個目標語言,直接下載
if (job.target_languages.length === 1) {
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${getFileExtension(job.original_filename)}`
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)
}
break
case 'retry':
try {
await ElMessageBox.confirm('確定要重新翻譯此檔案嗎?', '確認重試', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.retryJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('重試任務失敗:', error)
}
}
break
case 'delete':
try {
await ElMessageBox.confirm('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.deleteJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
}
}
break
}
}
const handleAnnouncementAction = (announcement) => {
if (announcement.actionUrl) {
window.open(announcement.actionUrl, '_blank')
} else {
ElMessage.info('功能開發中,敬請期待')
}
}
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 getStatusText = (status) => {
const statusMap = {
'PENDING': '等待中',
'PROCESSING': '處理中',
'COMPLETED': '已完成',
'FAILED': '失敗',
'RETRY': '重試中'
}
return statusMap[status] || status
}
const formatTime = (timestamp) => {
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')
}
const getAnnouncementIcon = (type) => {
const iconMap = {
info: 'InfoFilled',
warning: 'WarningFilled',
success: 'CircleCheckFilled',
error: 'CircleCloseFilled'
}
return iconMap[type] || 'InfoFilled'
}
// 生命週期
onMounted(async () => {
loading.value = true
console.log('🏠 [HomeView] onMounted 開始執行')
console.log('🏠 [HomeView] 當前認證狀態', {
isAuthenticated: authStore.isAuthenticated,
user: authStore.user,
token: authStore.token
})
try {
// 延遲載入任務列表,避免登入後立即請求造成認證問題
console.log('🏠 [HomeView] 等待 500ms 後載入任務列表')
await new Promise(resolve => setTimeout(resolve, 500))
console.log('🏠 [HomeView] 開始載入任務列表')
// 載入最近的任務
await jobsStore.fetchJobs({ per_page: 10 })
console.log('🏠 [HomeView] 任務列表載入成功')
} catch (error) {
console.error('❌ [HomeView] 載入任務失敗:', error)
// 如果是認證錯誤,不要顯示錯誤訊息,因為 request.js 會處理
if (!error.message?.includes('認證') && !error.response?.status === 401) {
ElMessage.error('載入任務失敗,請稍後重試')
}
} finally {
loading.value = false
console.log('🏠 [HomeView] onMounted 執行完畢')
}
})
</script>
<style lang="scss" scoped>
.home-view {
.welcome-section {
margin-bottom: 32px;
.welcome-card {
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: white;
border: none;
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
@media (max-width: 768px) {
flex-direction: column;
text-align: center;
gap: 24px;
}
.welcome-text {
.welcome-title {
font-size: 28px;
font-weight: bold;
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 12px;
@media (max-width: 768px) {
font-size: 24px;
justify-content: center;
flex-wrap: wrap;
}
}
.welcome-subtitle {
font-size: 16px;
opacity: 0.9;
margin: 0;
line-height: 1.5;
}
}
.welcome-actions {
display: flex;
gap: 12px;
@media (max-width: 480px) {
flex-direction: column;
width: 100%;
}
}
}
}
}
.stats-section {
margin-bottom: 32px;
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 20px;
font-weight: 600;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
}
.recent-jobs-section {
margin-bottom: 32px;
.job-list {
.job-item {
display: flex;
align-items: center;
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&:last-child {
margin-bottom: 0;
}
.job-icon {
margin-right: 16px;
}
.job-info {
flex: 1;
min-width: 0;
.job-name {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-details {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
.job-status {
margin: 0 16px;
text-align: center;
min-width: 80px;
.job-progress {
margin-top: 8px;
width: 60px;
}
}
.job-time {
min-width: 80px;
text-align: right;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 768px) {
display: none;
}
}
.job-actions {
margin-left: 16px;
}
}
}
}
.announcements-section {
.announcements-list {
.announcement-item {
display: flex;
align-items: flex-start;
padding: 16px;
margin-bottom: 12px;
border-radius: 8px;
border-left: 4px solid;
&.info {
background-color: var(--el-color-info-light-9);
border-left-color: var(--el-color-info);
}
&.warning {
background-color: var(--el-color-warning-light-9);
border-left-color: var(--el-color-warning);
}
&.success {
background-color: var(--el-color-success-light-9);
border-left-color: var(--el-color-success);
}
&.error {
background-color: var(--el-color-danger-light-9);
border-left-color: var(--el-color-danger);
}
&:last-child {
margin-bottom: 0;
}
.announcement-icon {
margin-right: 12px;
margin-top: 2px;
}
.announcement-content {
flex: 1;
.announcement-title {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.announcement-message {
color: var(--el-text-color-regular);
line-height: 1.5;
margin-bottom: 8px;
}
.announcement-time {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.announcement-actions {
margin-left: 12px;
}
}
}
}
}
.loading-state {
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,847 @@
<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 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' ? '原始檔案' : `翻譯檔案 (${getLanguageText(file.language_code)})` }}
</span>
</div>
</div>
<div class="file-actions">
<el-button
v-if="file.file_type === 'TRANSLATED'"
type="primary"
size="small"
@click="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 { 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)
// 方法
const loadJobDetail = async () => {
loading.value = true
try {
const response = await jobsStore.fetchJobDetail(jobUuid.value)
job.value = response.job
jobFiles.value = response.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 {
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 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>

View File

@@ -0,0 +1,894 @@
<template>
<div class="job-list-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">任務列表</h1>
<div class="page-actions">
<el-button type="primary" @click="$router.push('/upload')">
<el-icon><Upload /></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-select
v-model="filters.status"
@change="handleFilterChange"
style="width: 120px"
>
<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-group">
<label>檔案搜尋:</label>
<el-input
v-model="filters.search"
placeholder="請輸入檔案名稱"
style="width: 200px"
clearable
@input="handleSearchChange"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="filter-actions">
<el-button @click="refreshJobs" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button @click="clearFilters">
<el-icon><Close /></el-icon>
清除篩選
</el-button>
</div>
</div>
<!-- 統計資訊 -->
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">總計:</span>
<span class="stat-value">{{ jobStats.total }}</span>
</div>
<div class="stat-item">
<span class="stat-label">等待:</span>
<span class="stat-value stat-pending">{{ jobStats.pending }}</span>
</div>
<div class="stat-item">
<span class="stat-label">處理:</span>
<span class="stat-value stat-processing">{{ jobStats.processing }}</span>
</div>
<div class="stat-item">
<span class="stat-label">完成:</span>
<span class="stat-value stat-completed">{{ jobStats.completed }}</span>
</div>
<div class="stat-item">
<span class="stat-label">失敗:</span>
<span class="stat-value stat-failed">{{ jobStats.failed }}</span>
</div>
</div>
</div>
</div>
<!-- 任務列表 -->
<div class="content-card">
<div class="card-body">
<!-- 載入狀態 -->
<div v-if="loading && jobs.length === 0" 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 v-if="jobs.length === 0" />
<Search v-else />
</el-icon>
<div class="empty-title">
{{ jobs.length === 0 ? '暫無任務記錄' : '未找到符合條件的任務' }}
</div>
<div class="empty-description">
{{ jobs.length === 0
? '開始上傳您的第一個檔案進行翻譯吧!'
: '請嘗試調整篩選條件或搜尋關鍵字' }}
</div>
<el-button v-if="jobs.length === 0" type="primary" @click="$router.push('/upload')">
立即上傳
</el-button>
</div>
<!-- 任務列表 -->
<div v-else class="jobs-grid">
<div
v-for="job in filteredJobs"
:key="job.job_uuid"
class="job-card"
@click="viewJobDetail(job.job_uuid)"
>
<!-- 任務標題 -->
<div class="job-header">
<div class="job-title-section">
<div class="file-icon" :class="getFileExtension(job.original_filename)">
{{ getFileExtension(job.original_filename).toUpperCase() }}
</div>
<div class="job-title-info">
<div class="job-title" :title="job.original_filename">
{{ job.original_filename }}
</div>
<div class="job-meta">
<span class="file-size">{{ formatFileSize(job.file_size) }}</span>
<span class="upload-time">{{ formatTime(job.created_at) }}</span>
</div>
</div>
</div>
<div class="job-actions" @click.stop>
<el-dropdown
trigger="click"
@command="handleJobAction($event, job)"
placement="bottom-end"
>
<el-button type="text" size="small">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>
查看詳情
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'COMPLETED'"
command="download"
>
<el-icon><Download /></el-icon>
下載檔案
</el-dropdown-item>
<el-dropdown-item
v-if="job.status === 'FAILED'"
command="retry"
>
<el-icon><RefreshRight /></el-icon>
重新翻譯
</el-dropdown-item>
<el-dropdown-item
v-if="['PENDING', 'PROCESSING'].includes(job.status)"
command="cancel"
>
<el-icon><CircleClose /></el-icon>
取消任務
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>
刪除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 翻譯語言 -->
<div class="job-languages">
<div class="language-info">
<span class="language-label">來源:</span>
<span class="language-tag source">
{{ getLanguageText(job.source_language) }}
</span>
</div>
<div class="language-arrow">
<el-icon><ArrowRight /></el-icon>
</div>
<div class="language-info">
<span class="language-label">目標:</span>
<div class="language-tags">
<span
v-for="lang in job.target_languages"
:key="lang"
class="language-tag target"
>
{{ getLanguageText(lang) }}
</span>
</div>
</div>
</div>
<!-- 任務狀態 -->
<div class="job-status-section">
<div class="status-info">
<div class="status-badge" :class="job.status.toLowerCase()">
<el-icon>
<component :is="getStatusIcon(job.status)" />
</el-icon>
<span>{{ getStatusText(job.status) }}</span>
</div>
<div v-if="job.retry_count > 0" class="retry-count">
重試 {{ job.retry_count }}
</div>
</div>
<!-- 進度條 -->
<div v-if="job.status === 'PROCESSING' && job.progress > 0" class="job-progress">
<div class="progress-info">
<span>翻譯進度</span>
<span>{{ Math.round(job.progress) }}%</span>
</div>
<el-progress
:percentage="job.progress"
:stroke-width="6"
:show-text="false"
status="success"
/>
</div>
<!-- 錯誤訊息 -->
<div v-if="job.status === 'FAILED' && job.error_message" class="error-message">
<el-icon><WarningFilled /></el-icon>
<span>{{ job.error_message }}</span>
</div>
</div>
<!-- 任務資訊 -->
<div class="job-footer">
<div class="job-info-grid">
<div class="info-item" v-if="job.processing_started_at">
<span class="info-label">開始:</span>
<span class="info-value">{{ formatTime(job.processing_started_at) }}</span>
</div>
<div class="info-item" v-if="job.completed_at">
<span class="info-label">完成:</span>
<span class="info-value">{{ formatTime(job.completed_at) }}</span>
</div>
<div class="info-item" v-if="job.total_cost > 0">
<span class="info-label">成本:</span>
<span class="info-value">${{ job.total_cost.toFixed(4) }}</span>
</div>
<div class="info-item" v-if="job.total_tokens > 0">
<span class="info-label">Token:</span>
<span class="info-value">{{ job.total_tokens.toLocaleString() }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分頁 -->
<div class="pagination-section" v-if="pagination.pages > 1">
<el-pagination
v-model:current-page="pagination.page"
:page-size="pagination.per_page"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { debounce } from 'lodash-es'
import {
Upload, Search, Refresh, Close, Document, More, View, Download,
RefreshRight, CircleClose, Delete, ArrowRight, Clock, Loading,
SuccessFilled, CircleCloseFilled, WarningFilled, InfoFilled
} from '@element-plus/icons-vue'
import { initWebSocket } from '@/utils/websocket'
// Router 和 Store
const router = useRouter()
const jobsStore = useJobsStore()
// Debug: 檢查 store 方法
console.log('[DEBUG] JobsStore methods:', Object.getOwnPropertyNames(jobsStore))
console.log('[DEBUG] subscribeToJobUpdates exists:', typeof jobsStore.subscribeToJobUpdates)
console.log('[DEBUG] subscribeToJobUpdates is function:', typeof jobsStore.subscribeToJobUpdates === 'function')
// 響應式數據
const loading = ref(false)
const filters = ref({
status: 'all',
search: ''
})
// 計算屬性
const jobs = computed(() => jobsStore.jobs)
const pagination = computed(() => jobsStore.pagination)
const jobStats = computed(() => jobsStore.jobStats)
const filteredJobs = computed(() => {
let filtered = jobs.value
// 狀態篩選
if (filters.value.status !== 'all') {
filtered = filtered.filter(job => job.status === filters.value.status)
}
// 搜尋篩選
if (filters.value.search.trim()) {
const searchTerm = filters.value.search.toLowerCase().trim()
filtered = filtered.filter(job =>
job.original_filename.toLowerCase().includes(searchTerm)
)
}
return filtered
})
// 語言映射
const languageMap = {
'auto': '自動偵測',
'zh-TW': '繁中',
'zh-CN': '簡中',
'en': '英文',
'ja': '日文',
'ko': '韓文',
'vi': '越文',
'th': '泰文',
'id': '印尼文',
'ms': '馬來文'
}
// 方法
const refreshJobs = async () => {
loading.value = true
try {
await jobsStore.fetchJobs({
page: pagination.value.page,
per_page: pagination.value.per_page,
status: filters.value.status === 'all' ? undefined : filters.value.status
})
} catch (error) {
console.error('刷新任務列表失敗:', error)
} finally {
loading.value = false
}
}
const clearFilters = () => {
filters.value.status = 'all'
filters.value.search = ''
handleFilterChange()
}
const handleFilterChange = debounce(() => {
refreshJobs()
}, 300)
const handleSearchChange = debounce(() => {
// 搜尋是前端過濾,不需要重新請求 API
}, 300)
const handlePageChange = (page) => {
jobsStore.pagination.page = page
refreshJobs()
}
const handleSizeChange = (size) => {
jobsStore.pagination.per_page = size
jobsStore.pagination.page = 1
refreshJobs()
}
const viewJobDetail = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const handleJobAction = async (action, job) => {
switch (action) {
case 'view':
viewJobDetail(job.job_uuid)
break
case 'download':
try {
if (job.target_languages.length === 1) {
// 單一語言直接下載
const ext = getFileExtension(job.original_filename)
const filename = `${job.original_filename.replace(/\.[^/.]+$/, '')}_${job.target_languages[0]}_translated.${ext}`
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)
}
break
case 'retry':
try {
await ElMessageBox.confirm('確定要重新翻譯此檔案嗎?', '確認重試', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await jobsStore.retryJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('重試任務失敗:', error)
}
}
break
case 'cancel':
try {
await ElMessageBox.confirm('確定要取消此任務嗎?', '確認取消', {
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('確定要刪除此任務嗎?此操作無法撤銷。', '確認刪除', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: true,
message: `<strong>檔案:</strong> ${job.original_filename}<br/><strong>注意:</strong> 刪除後無法恢復`
})
await jobsStore.deleteJob(job.job_uuid)
} catch (error) {
if (error !== 'cancel') {
console.error('刪除任務失敗:', error)
}
}
break
}
}
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 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', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getLanguageText = (langCode) => {
return languageMap[langCode] || langCode
}
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待中',
'PROCESSING': '處理中',
'COMPLETED': '已完成',
'FAILED': '失敗',
'RETRY': '重試中'
}
return statusMap[status] || status
}
const getStatusIcon = (status) => {
const iconMap = {
'PENDING': 'Clock',
'PROCESSING': 'Loading',
'COMPLETED': 'SuccessFilled',
'FAILED': 'CircleCloseFilled',
'RETRY': 'RefreshRight'
}
return iconMap[status] || 'InfoFilled'
}
// 生命週期
onMounted(async () => {
// 暫時禁用 WebSocket 避免連接錯誤
// initWebSocket()
// 載入任務列表
await refreshJobs()
// 為所有處理中的任務訂閱狀態更新
const processingJobs = jobs.value.filter(job =>
['PENDING', 'PROCESSING', 'RETRY'].includes(job.status)
)
processingJobs.forEach(job => {
console.log(`[DEBUG] Attempting to subscribe to job: ${job.job_uuid}`)
if (typeof jobsStore.subscribeToJobUpdates === 'function') {
jobsStore.subscribeToJobUpdates(job.job_uuid)
} else {
console.error('[ERROR] subscribeToJobUpdates is not a function:', typeof jobsStore.subscribeToJobUpdates)
}
})
})
// 監聽任務列表變化,自動訂閱新的處理中任務
watch(jobs, (newJobs, oldJobs) => {
const oldUuids = oldJobs?.map(job => job.job_uuid) || []
const newProcessingJobs = newJobs.filter(job =>
['PENDING', 'PROCESSING', 'RETRY'].includes(job.status) &&
!oldUuids.includes(job.job_uuid)
)
newProcessingJobs.forEach(job => {
console.log(`[DEBUG] Attempting to subscribe to new job: ${job.job_uuid}`)
if (typeof jobsStore.subscribeToJobUpdates === 'function') {
jobsStore.subscribeToJobUpdates(job.job_uuid)
} else {
console.error('[ERROR] subscribeToJobUpdates is not a function in watch:', typeof jobsStore.subscribeToJobUpdates)
}
})
}, { deep: true })
// 組件卸載時清理輪詢
onUnmounted(() => {
console.log('[DEBUG] 組件卸載,停止所有輪詢')
jobsStore.stopAllPolling()
})
</script>
<style lang="scss" scoped>
.job-list-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;
display: flex;
gap: 8px;
@media (max-width: 768px) {
margin-left: 0;
width: 100%;
}
}
}
.stats-row {
display: flex;
gap: 24px;
padding: 12px 16px;
background-color: var(--el-fill-color-lighter);
border-radius: 6px;
flex-wrap: wrap;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.stat-label {
color: var(--el-text-color-regular);
}
.stat-value {
font-weight: 600;
color: var(--el-text-color-primary);
&.stat-pending { color: var(--el-color-info); }
&.stat-processing { color: var(--el-color-primary); }
&.stat-completed { color: var(--el-color-success); }
&.stat-failed { color: var(--el-color-danger); }
}
}
}
}
.jobs-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.job-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);
}
.job-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
.job-title-section {
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; }
}
.job-title-info {
flex: 1;
min-width: 0;
.job-title {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
.job-languages {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
.language-info {
display: flex;
align-items: center;
gap: 4px;
.language-label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
.language-arrow {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.language-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
&.source {
background-color: var(--el-color-info-light-8);
color: var(--el-color-info);
border: 1px solid var(--el-color-info-light-5);
}
&.target {
background-color: var(--el-color-primary-light-8);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-5);
margin-right: 4px;
&:last-child {
margin-right: 0;
}
}
}
.language-tags {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
}
.job-status-section {
margin-bottom: 12px;
.status-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.status-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.pending {
background-color: var(--el-color-info-light-9);
color: var(--el-color-info);
border: 1px solid var(--el-color-info-light-5);
}
&.processing {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-5);
}
&.completed {
background-color: var(--el-color-success-light-9);
color: var(--el-color-success);
border: 1px solid var(--el-color-success-light-5);
}
&.failed {
background-color: var(--el-color-danger-light-9);
color: var(--el-color-danger);
border: 1px solid var(--el-color-danger-light-5);
}
&.retry {
background-color: var(--el-color-warning-light-9);
color: var(--el-color-warning);
border: 1px solid var(--el-color-warning-light-5);
}
}
.retry-count {
font-size: 11px;
color: var(--el-text-color-secondary);
}
}
.job-progress {
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
font-size: 12px;
color: var(--el-text-color-regular);
}
}
.error-message {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background-color: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-5);
border-radius: 4px;
color: var(--el-color-danger);
font-size: 12px;
margin-top: 6px;
}
}
.job-footer {
.job-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
font-size: 12px;
.info-item {
display: flex;
justify-content: space-between;
.info-label {
color: var(--el-text-color-secondary);
}
.info-value {
color: var(--el-text-color-primary);
font-weight: 500;
}
}
}
}
}
}
.pagination-section {
margin-top: 24px;
display: flex;
justify-content: center;
}
}
.loading-state {
padding: 24px 0;
}
</style>

View File

@@ -0,0 +1,348 @@
<template>
<div class="login-layout">
<div class="login-container">
<div class="login-header">
<div class="login-logo">
<el-icon><Document /></el-icon>
</div>
<h1 class="login-title">PANJIT 翻譯系統</h1>
<p class="login-subtitle">企業級文件批量翻譯管理系統</p>
</div>
<div class="login-body">
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
@keyup.enter="handleLogin"
label-position="top"
size="large"
>
<el-form-item label="AD 帳號" prop="username">
<el-input
v-model="loginForm.username"
placeholder="請輸入您的 AD 帳號"
:prefix-icon="User"
clearable
:disabled="loading"
/>
</el-form-item>
<el-form-item label="密碼" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="請輸入密碼"
:prefix-icon="Lock"
show-password
clearable
:disabled="loading"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="rememberMe" :disabled="loading">
記住登入狀態
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="!loginForm.username || !loginForm.password"
@click="handleLogin"
style="width: 100%"
>
{{ loading ? '登入中...' : '登入' }}
</el-button>
</el-form-item>
</el-form>
<!-- 錯誤訊息顯示 -->
<div v-if="errorMessage" class="error-message">
<el-alert
:title="errorMessage"
type="error"
:closable="false"
show-icon
/>
</div>
<!-- 登入提示 -->
<div class="login-tips">
<el-alert
title="登入說明"
type="info"
:closable="false"
show-icon
>
<p>請使用您的 PANJIT AD 域帳號登入系統</p>
<p>如果您忘記密碼或遇到登入問題請聯繫 IT 部門協助</p>
</el-alert>
</div>
</div>
<div class="login-footer">
<p>&copy; 2024 PANJIT Group. All rights reserved.</p>
<p>Powered by PANJIT IT Team</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { Document, User, Lock } from '@element-plus/icons-vue'
// Router 和 Store
const router = useRouter()
const authStore = useAuthStore()
// 響應式數據
const loginFormRef = ref()
const loading = ref(false)
const rememberMe = ref(false)
const errorMessage = ref('')
// 登入表單數據
const loginForm = reactive({
username: '',
password: ''
})
// 表單驗證規則
const loginRules = {
username: [
{ required: true, message: '請輸入 AD 帳號', trigger: 'blur' },
{ min: 3, message: '帳號長度不能少於3個字元', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9._@-]+$/,
message: '帳號格式不正確,只能包含字母、數字、點、下劃線、@符號和連字符',
trigger: 'blur'
}
],
password: [
{ required: true, message: '請輸入密碼', trigger: 'blur' },
{ min: 1, message: '密碼不能為空', trigger: 'blur' }
]
}
// 方法
const handleLogin = async () => {
try {
// 清除之前的錯誤訊息
errorMessage.value = ''
// 驗證表單
const valid = await loginFormRef.value.validate()
if (!valid) {
return
}
loading.value = true
// 準備登入資料
const credentials = {
username: loginForm.username.trim(),
password: loginForm.password
}
// 如果帳號不包含 @,自動添加域名
if (!credentials.username.includes('@')) {
credentials.username = `${credentials.username}@panjit.com.tw`
}
// 執行登入
await authStore.login(credentials)
// 如果選擇記住登入狀態,可以在這裡處理
if (rememberMe.value) {
localStorage.setItem('rememberLogin', 'true')
}
// 登入成功,跳轉到首頁
router.push('/')
} catch (error) {
console.error('登入失敗:', error)
// 處理不同的錯誤類型
if (error.response?.status === 401) {
errorMessage.value = '帳號或密碼錯誤,請重新輸入'
} else if (error.response?.status === 403) {
errorMessage.value = '您的帳號沒有權限存取此系統'
} else if (error.response?.status === 500) {
errorMessage.value = '伺服器錯誤,請稍後再試'
} else if (error.message?.includes('LDAP')) {
errorMessage.value = 'AD 伺服器連接失敗,請聯繫 IT 部門'
} else if (error.message?.includes('network')) {
errorMessage.value = '網路連接失敗,請檢查網路設定'
} else {
errorMessage.value = error.message || '登入失敗,請重試'
}
// 清空密碼欄位
loginForm.password = ''
// 3秒後自動清除錯誤訊息
setTimeout(() => {
errorMessage.value = ''
}, 5000)
} finally {
loading.value = false
}
}
const clearError = () => {
errorMessage.value = ''
}
// 生命週期
onMounted(() => {
// 如果已經登入,直接跳轉
if (authStore.isAuthenticated) {
router.push('/')
return
}
// 檢查是否記住了登入狀態
const rememberLogin = localStorage.getItem('rememberLogin')
if (rememberLogin === 'true') {
rememberMe.value = true
}
// 僅在頁面載入時檢查認證狀態不調用API
const authUser = localStorage.getItem('auth_user')
const authAuthenticated = localStorage.getItem('auth_authenticated')
if (authUser && authAuthenticated === 'true') {
// 如果已經有認證資訊,直接跳轉
router.push('/')
}
// 監聽表單變化,清除錯誤訊息
const unwatchForm = watch([() => loginForm.username, () => loginForm.password], () => {
if (errorMessage.value) {
clearError()
}
})
// 頁面卸載時取消監聽
onUnmounted(() => {
unwatchForm()
})
})
</script>
<style lang="scss" scoped>
.error-message {
margin-top: 16px;
}
.login-tips {
margin-top: 24px;
:deep(.el-alert__content) {
p {
margin: 4px 0;
font-size: 13px;
line-height: 1.4;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
// 響應式設計
@media (max-width: 480px) {
.login-layout {
padding: 16px;
.login-container {
max-width: 100%;
.login-header {
padding: 24px;
.login-logo {
width: 48px;
height: 48px;
margin-bottom: 16px;
}
.login-title {
font-size: 20px;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 13px;
}
}
.login-body {
padding: 24px;
}
.login-footer {
padding: 16px 24px;
font-size: 12px;
}
}
}
}
// 加載狀態下的樣式
.loading {
pointer-events: none;
opacity: 0.7;
}
// 動畫效果
.login-container {
animation: slideInUp 0.5s ease-out;
}
@keyframes slideInUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// Element Plus 組件樣式覆蓋
:deep(.el-form-item__label) {
color: var(--el-text-color-primary);
font-weight: 500;
}
:deep(.el-input__inner) {
border-radius: 6px;
}
:deep(.el-button) {
border-radius: 6px;
font-weight: 500;
}
:deep(.el-checkbox__label) {
font-size: 14px;
color: var(--el-text-color-regular);
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="not-found-view">
<div class="not-found-container">
<div class="not-found-illustration">
<div class="error-code">404</div>
<div class="error-icon">
<el-icon><QuestionFilled /></el-icon>
</div>
</div>
<div class="not-found-content">
<h1 class="error-title">頁面不存在</h1>
<p class="error-description">
抱歉您訪問的頁面不存在或已被移除
</p>
<div class="error-actions">
<el-button type="primary" size="large" @click="goHome">
<el-icon><House /></el-icon>
回到首頁
</el-button>
<el-button size="large" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回上頁
</el-button>
</div>
</div>
<div class="helpful-links">
<h3>您可能在尋找</h3>
<div class="links-grid">
<router-link to="/upload" class="link-card">
<div class="link-icon">
<el-icon><Upload /></el-icon>
</div>
<div class="link-content">
<div class="link-title">檔案上傳</div>
<div class="link-desc">上傳新的檔案進行翻譯</div>
</div>
</router-link>
<router-link to="/jobs" class="link-card">
<div class="link-icon">
<el-icon><List /></el-icon>
</div>
<div class="link-content">
<div class="link-title">任務列表</div>
<div class="link-desc">查看您的翻譯任務</div>
</div>
</router-link>
<router-link to="/history" class="link-card">
<div class="link-icon">
<el-icon><Clock /></el-icon>
</div>
<div class="link-content">
<div class="link-title">歷史記錄</div>
<div class="link-desc">瀏覽過往的翻譯記錄</div>
</div>
</router-link>
<router-link to="/profile" class="link-card">
<div class="link-icon">
<el-icon><User /></el-icon>
</div>
<div class="link-content">
<div class="link-title">個人設定</div>
<div class="link-desc">管理您的個人資料</div>
</div>
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import {
QuestionFilled, House, ArrowLeft, Upload, List, Clock, User
} from '@element-plus/icons-vue'
const router = useRouter()
const goHome = () => {
router.push('/')
}
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
</script>
<style lang="scss" scoped>
.not-found-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
.not-found-container {
max-width: 800px;
width: 100%;
text-align: center;
.not-found-illustration {
position: relative;
margin-bottom: 40px;
.error-code {
font-size: 120px;
font-weight: bold;
color: var(--el-color-primary);
line-height: 1;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
@media (max-width: 480px) {
font-size: 80px;
}
}
.error-icon {
font-size: 60px;
color: var(--el-color-info);
opacity: 0.6;
@media (max-width: 480px) {
font-size: 40px;
}
}
}
.not-found-content {
margin-bottom: 50px;
.error-title {
font-size: 32px;
font-weight: bold;
color: var(--el-text-color-primary);
margin: 0 0 16px 0;
@media (max-width: 480px) {
font-size: 24px;
}
}
.error-description {
font-size: 16px;
color: var(--el-text-color-regular);
line-height: 1.6;
margin: 0 0 32px 0;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
.error-actions {
display: flex;
justify-content: center;
gap: 16px;
@media (max-width: 480px) {
flex-direction: column;
align-items: center;
}
}
}
.helpful-links {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
h3 {
font-size: 18px;
color: var(--el-text-color-primary);
margin: 0 0 24px 0;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
.link-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 12px;
text-decoration: none;
transition: all 0.3s ease;
&:hover {
background: var(--el-color-primary-light-9);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.2);
}
.link-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--el-color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.link-content {
text-align: left;
.link-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.link-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
}
}
}
}
}
}
// 動畫效果
.not-found-container {
animation: fadeInUp 0.8s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.error-code {
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
</style>

View File

@@ -0,0 +1,562 @@
<template>
<div class="profile-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">個人設定</h1>
</div>
<!-- 用戶資訊 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">基本資訊</h3>
</div>
<div class="card-body">
<div class="user-profile">
<div class="avatar-section">
<div class="user-avatar">
<div class="avatar-circle">
{{ userInitials }}
</div>
</div>
<div class="user-basic-info">
<h3 class="user-name">{{ authStore.userName }}</h3>
<p class="user-email">{{ authStore.userEmail }}</p>
<el-tag v-if="authStore.isAdmin" type="warning" size="small">
管理員
</el-tag>
</div>
</div>
<div class="user-details">
<div class="detail-row">
<div class="detail-item">
<div class="detail-label">AD 帳號</div>
<div class="detail-value">{{ authStore.user?.username }}</div>
</div>
<div class="detail-item">
<div class="detail-label">部門</div>
<div class="detail-value">{{ authStore.department || '未設定' }}</div>
</div>
</div>
<div class="detail-row">
<div class="detail-item">
<div class="detail-label">最後登入</div>
<div class="detail-value">{{ formatTime(authStore.user?.last_login) }}</div>
</div>
<div class="detail-item">
<div class="detail-label">權限等級</div>
<div class="detail-value">{{ authStore.isAdmin ? '管理員' : '一般使用者' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 使用統計 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">使用統計</h3>
<div class="card-actions">
<el-button type="text" @click="refreshStats" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<div class="card-body">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<el-icon><Files /></el-icon>
</div>
<div class="stat-value">{{ userStats.totalJobs }}</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">{{ userStats.completedJobs }}</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">${{ userStats.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">{{ userStats.avgProcessingTime }}</div>
<div class="stat-label">平均處理時間</div>
</div>
</div>
</div>
</div>
<!-- 偏好設定 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">偏好設定</h3>
</div>
<div class="card-body">
<el-form :model="preferences" label-width="120px" size="default">
<el-form-item label="預設來源語言">
<el-select v-model="preferences.defaultSourceLang" style="width: 200px">
<el-option label="自動偵測" value="auto" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="越南文" value="vi" />
</el-select>
</el-form-item>
<el-form-item label="預設目標語言">
<el-select
v-model="preferences.defaultTargetLangs"
multiple
style="width: 300px"
placeholder="請選擇常用的目標語言"
>
<el-option label="英文" value="en" />
<el-option label="越南文" value="vi" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
</el-select>
</el-form-item>
<el-form-item label="通知設定">
<el-checkbox-group v-model="preferences.notifications">
<el-checkbox label="email" name="notifications">
翻譯完成時發送郵件通知
</el-checkbox>
<el-checkbox label="browser" name="notifications">
瀏覽器桌面通知
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="介面語言">
<el-select v-model="preferences.interfaceLang" style="width: 150px">
<el-option label="繁體中文" value="zh-TW" />
<el-option label="English" value="en" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="savePreferences" :loading="saving">
儲存設定
</el-button>
<el-button @click="resetPreferences">
重置為預設值
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 快速操作 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">快速操作</h3>
</div>
<div class="card-body">
<div class="quick-actions">
<el-button @click="$router.push('/upload')">
<el-icon><Upload /></el-icon>
上傳新檔案
</el-button>
<el-button @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看我的任務
</el-button>
<el-button @click="$router.push('/history')">
<el-icon><Clock /></el-icon>
瀏覽歷史記錄
</el-button>
<el-button @click="downloadUserData" :loading="downloading">
<el-icon><Download /></el-icon>
匯出我的資料
</el-button>
</div>
</div>
</div>
<!-- 帳號安全 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">帳號安全</h3>
</div>
<div class="card-body">
<div class="security-info">
<div class="security-item">
<div class="security-icon">
<el-icon><Key /></el-icon>
</div>
<div class="security-content">
<div class="security-title">密碼管理</div>
<div class="security-description">
本系統使用公司 AD 帳號認證如需變更密碼請聯繫 IT 部門
</div>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<el-icon><Lock /></el-icon>
</div>
<div class="security-content">
<div class="security-title">登入記錄</div>
<div class="security-description">
最後登入時間: {{ formatTime(authStore.user?.last_login) }}
</div>
<el-button type="text" @click="viewLoginHistory">
查看詳細記錄
</el-button>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<el-icon><UserFilled /></el-icon>
</div>
<div class="security-content">
<div class="security-title">權限說明</div>
<div class="security-description">
{{ authStore.isAdmin
? '您擁有系統管理員權限,可以查看所有用戶的任務和系統統計'
: '您為一般使用者,只能查看和管理自己的翻譯任務' }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage } from 'element-plus'
import {
Refresh, Files, SuccessFilled, Money, Clock, Upload, List,
Download, Key, Lock, UserFilled
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const authStore = useAuthStore()
const jobsStore = useJobsStore()
// 響應式數據
const loading = ref(false)
const saving = ref(false)
const downloading = ref(false)
// 偏好設定
const preferences = reactive({
defaultSourceLang: 'auto',
defaultTargetLangs: ['en'],
notifications: ['email'],
interfaceLang: 'zh-TW'
})
// 計算屬性
const userInitials = computed(() => {
const name = authStore.userName || authStore.user?.username || 'U'
return name.charAt(0).toUpperCase()
})
const userStats = computed(() => {
const jobs = jobsStore.jobs
const completedJobs = jobs.filter(job => job.status === 'COMPLETED')
const totalCost = jobs.reduce((sum, job) => sum + (job.total_cost || 0), 0)
// 計算平均處理時間
const processingTimes = completedJobs
.filter(job => job.processing_started_at && job.completed_at)
.map(job => {
const start = new Date(job.processing_started_at)
const end = new Date(job.completed_at)
return end - start
})
let avgProcessingTime = '無資料'
if (processingTimes.length > 0) {
const avgMs = processingTimes.reduce((sum, time) => sum + time, 0) / processingTimes.length
const minutes = Math.floor(avgMs / 60000)
const seconds = Math.floor((avgMs % 60000) / 1000)
avgProcessingTime = `${minutes}${seconds}`
}
return {
totalJobs: jobs.length,
completedJobs: completedJobs.length,
totalCost,
avgProcessingTime
}
})
// 方法
const refreshStats = async () => {
loading.value = true
try {
await jobsStore.fetchJobs({ per_page: 100 })
ElMessage.success('統計資料已刷新')
} catch (error) {
console.error('刷新統計失敗:', error)
ElMessage.error('刷新統計失敗')
} finally {
loading.value = false
}
}
const savePreferences = async () => {
saving.value = true
try {
// 儲存到本地存儲
localStorage.setItem('userPreferences', JSON.stringify(preferences))
// 同時更新翻譯設定的預設值
localStorage.setItem('translation_settings', JSON.stringify({
sourceLanguage: preferences.defaultSourceLang,
targetLanguages: preferences.defaultTargetLangs
}))
ElMessage.success('設定已儲存')
} catch (error) {
console.error('儲存設定失敗:', error)
ElMessage.error('儲存設定失敗')
} finally {
saving.value = false
}
}
const resetPreferences = () => {
Object.assign(preferences, {
defaultSourceLang: 'auto',
defaultTargetLangs: ['en'],
notifications: ['email'],
interfaceLang: 'zh-TW'
})
ElMessage.info('設定已重置為預設值')
}
const downloadUserData = async () => {
downloading.value = true
try {
const userData = {
userInfo: {
username: authStore.user?.username,
displayName: authStore.userName,
email: authStore.userEmail,
department: authStore.department,
isAdmin: authStore.isAdmin
},
jobs: jobsStore.jobs,
statistics: userStats.value,
preferences: preferences,
exportTime: new Date().toISOString()
}
const dataStr = JSON.stringify(userData, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `我的翻譯資料_${new Date().toISOString().slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('資料匯出完成')
} catch (error) {
console.error('匯出資料失敗:', error)
ElMessage.error('匯出資料失敗')
} finally {
downloading.value = false
}
}
const viewLoginHistory = () => {
ElMessage.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', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 生命週期
onMounted(async () => {
// 載入用戶偏好設定
try {
const savedPreferences = localStorage.getItem('userPreferences')
if (savedPreferences) {
Object.assign(preferences, JSON.parse(savedPreferences))
}
} catch (error) {
console.error('載入偏好設定失敗:', error)
}
// 載入統計資料
await refreshStats()
})
</script>
<style lang="scss" scoped>
.profile-view {
.user-profile {
.avatar-section {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
.user-avatar {
.avatar-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(45deg, var(--el-color-primary), var(--el-color-primary-light-3));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
}
}
.user-basic-info {
.user-name {
margin: 0 0 8px 0;
color: var(--el-text-color-primary);
font-size: 20px;
}
.user-email {
margin: 0 0 8px 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
}
}
.user-details {
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
margin-bottom: 16px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 16px;
}
.detail-item {
.detail-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.detail-value {
font-size: 14px;
color: var(--el-text-color-primary);
font-weight: 500;
}
}
}
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.security-info {
.security-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
.security-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.security-content {
flex: 1;
.security-title {
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.security-description {
color: var(--el-text-color-regular);
line-height: 1.5;
margin-bottom: 8px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,865 @@
<template>
<div class="upload-view">
<!-- 頁面標題 -->
<div class="page-header">
<h1 class="page-title">檔案上傳</h1>
<div class="page-actions">
<el-button @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看任務列表
</el-button>
</div>
</div>
<div class="upload-content">
<!-- 上傳區域 -->
<div class="content-card">
<div class="card-header">
<h3 class="card-title">選擇要翻譯的檔案</h3>
<div class="card-subtitle">
支援 DOCXDOCPPTXXLSXXLSPDF 格式單檔最大 25MB
</div>
</div>
<div class="card-body">
<!-- 檔案上傳器 -->
<el-upload
ref="uploadRef"
class="upload-dragger"
:class="{ disabled: uploading }"
drag
:multiple="true"
:show-file-list="false"
:before-upload="handleBeforeUpload"
:http-request="() => {}"
:disabled="uploading"
>
<div class="upload-content-inner">
<el-icon class="upload-icon">
<UploadFilled />
</el-icon>
<div class="upload-text">
<div class="upload-title">拖拽檔案至此或點擊選擇檔案</div>
<div class="upload-hint">
支援 .docx, .doc, .pptx, .xlsx, .xls, .pdf 格式
</div>
</div>
</div>
</el-upload>
<!-- 已選擇的檔案列表 -->
<div v-if="selectedFiles.length > 0" class="selected-files">
<div class="files-header">
<h4>已選擇的檔案 ({{ selectedFiles.length }})</h4>
<el-button type="text" @click="clearFiles" :disabled="uploading">
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
<div class="files-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="file-item"
>
<div class="file-icon">
<div class="file-type" :class="getFileExtension(file.name)">
{{ getFileExtension(file.name).toUpperCase() }}
</div>
</div>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-details">
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<span class="file-type-text">{{ getFileTypeText(file.name) }}</span>
</div>
</div>
<div class="file-actions">
<el-button
type="text"
size="small"
@click="removeFile(index)"
:disabled="uploading"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 翻譯設定 -->
<div class="content-card" v-if="selectedFiles.length > 0">
<div class="card-header">
<h3 class="card-title">翻譯設定</h3>
</div>
<div class="card-body">
<el-form
ref="translationFormRef"
:model="translationForm"
:rules="translationRules"
label-width="120px"
size="large"
>
<el-form-item label="來源語言" prop="sourceLanguage">
<el-select
v-model="translationForm.sourceLanguage"
placeholder="請選擇來源語言"
style="width: 100%"
:disabled="uploading"
>
<el-option label="自動偵測" value="auto" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="越南文" value="vi" />
</el-select>
</el-form-item>
<el-form-item label="目標語言" prop="targetLanguages">
<el-select
v-model="translationForm.targetLanguages"
multiple
placeholder="請選擇目標語言可多選"
style="width: 100%"
:disabled="uploading"
collapse-tags
collapse-tags-tooltip
>
<el-option label="英文" value="en" />
<el-option label="越南文" value="vi" />
<el-option label="繁體中文" value="zh-TW" />
<el-option label="簡體中文" value="zh-CN" />
<el-option label="日文" value="ja" />
<el-option label="韓文" value="ko" />
<el-option label="泰文" value="th" />
<el-option label="印尼文" value="id" />
<el-option label="馬來文" value="ms" />
</el-select>
<div class="form-tip">
<el-icon><InfoFilled /></el-icon>
可以同時選擇多個目標語言,系統會分別生成對應的翻譯檔案
</div>
</el-form-item>
<el-form-item>
<div class="translation-actions">
<el-button
type="primary"
size="large"
:loading="uploading"
:disabled="selectedFiles.length === 0 || translationForm.targetLanguages.length === 0"
@click="startTranslation"
>
<el-icon><Upload /></el-icon>
{{ uploading ? '上傳中...' : `開始翻譯 (${selectedFiles.length} 個檔案)` }}
</el-button>
<el-button size="large" @click="resetForm" :disabled="uploading">
重置
</el-button>
</div>
</el-form-item>
</el-form>
</div>
</div>
<!-- 上傳進度 -->
<div class="content-card" v-if="uploading || uploadResults.length > 0">
<div class="card-header">
<h3 class="card-title">上傳進度</h3>
</div>
<div class="card-body">
<div class="upload-progress">
<!-- 總體進度 -->
<div class="overall-progress" v-if="uploading">
<div class="progress-info">
<span>整體進度: {{ currentFileIndex + 1 }} / {{ selectedFiles.length }}</span>
<span>{{ Math.round(overallProgress) }}%</span>
</div>
<el-progress
:percentage="overallProgress"
:stroke-width="8"
:show-text="false"
status="success"
/>
</div>
<!-- 個別檔案進度 -->
<div class="files-progress">
<div
v-for="(result, index) in uploadResults"
:key="index"
class="file-progress-item"
:class="result.status"
>
<div class="file-info">
<div class="file-icon">
<div class="file-type" :class="getFileExtension(result.filename)">
{{ getFileExtension(result.filename).toUpperCase() }}
</div>
</div>
<div class="file-details">
<div class="file-name">{{ result.filename }}</div>
<div class="file-status">
<el-icon v-if="result.status === 'success'">
<SuccessFilled />
</el-icon>
<el-icon v-else-if="result.status === 'error'">
<CircleCloseFilled />
</el-icon>
<el-icon v-else>
<Loading />
</el-icon>
<span>{{ getUploadStatusText(result.status) }}</span>
</div>
</div>
</div>
<div class="file-progress" v-if="result.status === 'uploading'">
<el-progress
:percentage="result.progress || 0"
:stroke-width="4"
:show-text="false"
/>
</div>
<div class="file-actions" v-if="result.status === 'success'">
<el-button
type="text"
size="small"
@click="viewJob(result.jobUuid)"
>
查看任務
</el-button>
</div>
</div>
</div>
<!-- 完成後的操作 -->
<div class="upload-complete-actions" v-if="!uploading && uploadResults.length > 0">
<el-button type="primary" @click="$router.push('/jobs')">
<el-icon><List /></el-icon>
查看所有任務
</el-button>
<el-button @click="resetUpload">
<el-icon><RefreshRight /></el-icon>
重新上傳
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useJobsStore } from '@/stores/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
List, UploadFilled, Delete, Close, Upload, InfoFilled,
SuccessFilled, CircleCloseFilled, Loading, RefreshRight
} from '@element-plus/icons-vue'
// Router 和 Stores
const router = useRouter()
const jobsStore = useJobsStore()
// 組件引用
const uploadRef = ref()
const translationFormRef = ref()
// 響應式數據
const selectedFiles = ref([])
const uploading = ref(false)
const currentFileIndex = ref(0)
const uploadResults = ref([])
// 表單數據
const translationForm = reactive({
sourceLanguage: 'auto',
targetLanguages: []
})
// 表單驗證規則
const translationRules = {
targetLanguages: [
{ required: true, message: '請至少選擇一個目標語言', trigger: 'change' },
{
type: 'array',
min: 1,
message: '請至少選擇一個目標語言',
trigger: 'change'
}
]
}
// 支援的檔案類型
const supportedTypes = {
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc': 'application/msword',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'ppt': 'application/vnd.ms-powerpoint',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel',
'pdf': 'application/pdf'
}
// 計算屬性
const overallProgress = computed(() => {
if (uploadResults.value.length === 0) return 0
const totalProgress = uploadResults.value.reduce((sum, result) => {
if (result.status === 'success') return sum + 100
if (result.status === 'error') return sum + 100
return sum + (result.progress || 0)
}, 0)
return (totalProgress / selectedFiles.value.length)
})
// 方法
const handleBeforeUpload = (file) => {
// 檢查檔案類型
const extension = getFileExtension(file.name)
if (!supportedTypes[extension]) {
ElMessage.error(`不支援的檔案類型: ${extension}`)
return false
}
// 檢查檔案大小
const maxSize = 25 * 1024 * 1024 // 25MB
if (file.size > maxSize) {
ElMessage.error(`檔案大小不能超過 25MB當前檔案: ${formatFileSize(file.size)}`)
return false
}
// 檢查是否已存在
const exists = selectedFiles.value.some(f => f.name === file.name)
if (exists) {
ElMessage.warning('檔案已存在於列表中')
return false
}
// 添加到選擇列表
selectedFiles.value.push(file)
ElMessage.success(`已添加檔案: ${file.name}`)
return false // 阻止自動上傳
}
const removeFile = (index) => {
const filename = selectedFiles.value[index].name
selectedFiles.value.splice(index, 1)
ElMessage.info(`已移除檔案: ${filename}`)
}
const clearFiles = async () => {
try {
await ElMessageBox.confirm('確定要清空所有已選檔案嗎?', '確認清空', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
selectedFiles.value = []
ElMessage.success('已清空檔案列表')
} catch (error) {
// 用戶取消
}
}
const startTranslation = async () => {
try {
// 驗證表單
const valid = await translationFormRef.value.validate()
if (!valid) {
return
}
if (selectedFiles.value.length === 0) {
ElMessage.warning('請先選擇要翻譯的檔案')
return
}
// 開始上傳
uploading.value = true
currentFileIndex.value = 0
uploadResults.value = []
// 為每個檔案創建上傳記錄
selectedFiles.value.forEach(file => {
uploadResults.value.push({
filename: file.name,
status: 'waiting',
progress: 0,
jobUuid: null,
error: null
})
})
// 逐個上傳檔案
for (let i = 0; i < selectedFiles.value.length; i++) {
currentFileIndex.value = i
const file = selectedFiles.value[i]
const resultIndex = i
try {
// 更新狀態為上傳中
uploadResults.value[resultIndex].status = 'uploading'
// 創建 FormData
const formData = new FormData()
formData.append('file', file)
formData.append('source_language', translationForm.sourceLanguage)
formData.append('target_languages', JSON.stringify(translationForm.targetLanguages))
// 上傳檔案
const result = await jobsStore.uploadFile(formData, (progress) => {
uploadResults.value[resultIndex].progress = progress
})
// 上傳成功
uploadResults.value[resultIndex].status = 'success'
uploadResults.value[resultIndex].progress = 100
uploadResults.value[resultIndex].jobUuid = result.job_uuid
} catch (error) {
console.error(`檔案 ${file.name} 上傳失敗:`, error)
uploadResults.value[resultIndex].status = 'error'
uploadResults.value[resultIndex].error = error.message || '上傳失敗'
ElMessage.error(`檔案 ${file.name} 上傳失敗: ${error.message || '未知錯誤'}`)
}
}
// 檢查上傳結果
const successCount = uploadResults.value.filter(r => r.status === 'success').length
const failCount = uploadResults.value.filter(r => r.status === 'error').length
if (successCount > 0) {
ElMessage.success(`成功上傳 ${successCount} 個檔案`)
}
if (failCount > 0) {
ElMessage.error(`${failCount} 個檔案上傳失敗`)
}
} catch (error) {
console.error('批量上傳失敗:', error)
ElMessage.error('批量上傳失敗')
} finally {
uploading.value = false
}
}
const resetForm = () => {
selectedFiles.value = []
translationForm.sourceLanguage = 'auto'
translationForm.targetLanguages = []
uploadResults.value = []
translationFormRef.value?.resetFields()
}
const resetUpload = () => {
uploadResults.value = []
currentFileIndex.value = 0
}
const viewJob = (jobUuid) => {
router.push(`/job/${jobUuid}`)
}
const getFileExtension = (filename) => {
return filename.split('.').pop().toLowerCase()
}
const getFileTypeText = (filename) => {
const ext = getFileExtension(filename)
const typeMap = {
'docx': 'Word 文件',
'doc': 'Word 文件',
'pptx': 'PowerPoint 簡報',
'ppt': 'PowerPoint 簡報',
'xlsx': 'Excel 試算表',
'xls': 'Excel 試算表',
'pdf': 'PDF 文件'
}
return typeMap[ext] || ext.toUpperCase()
}
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 getUploadStatusText = (status) => {
const statusMap = {
'waiting': '等待中',
'uploading': '上傳中',
'success': '上傳成功',
'error': '上傳失敗'
}
return statusMap[status] || status
}
// 生命週期
onMounted(() => {
// 載入使用者偏好設定(如果有的話)
const savedSettings = localStorage.getItem('translation_settings')
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings)
translationForm.sourceLanguage = settings.sourceLanguage || 'auto'
translationForm.targetLanguages = settings.targetLanguages || []
} catch (error) {
console.error('載入設定失敗:', error)
}
}
})
// 監聽表單變化,保存設定
watch([() => translationForm.sourceLanguage, () => translationForm.targetLanguages], () => {
const settings = {
sourceLanguage: translationForm.sourceLanguage,
targetLanguages: translationForm.targetLanguages
}
localStorage.setItem('translation_settings', JSON.stringify(settings))
}, { deep: true })
</script>
<style lang="scss" scoped>
.upload-view {
.upload-content {
.content-card {
&:not(:last-child) {
margin-bottom: 24px;
}
.card-subtitle {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
}
.upload-dragger {
:deep(.el-upload-dragger) {
border: 2px dashed var(--el-border-color);
border-radius: 8px;
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
&:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
&.is-dragover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-8);
}
}
&.disabled :deep(.el-upload-dragger) {
cursor: not-allowed;
opacity: 0.6;
&:hover {
border-color: var(--el-border-color);
background-color: transparent;
}
}
.upload-content-inner {
text-align: center;
.upload-icon {
font-size: 48px;
color: var(--el-color-primary);
margin-bottom: 16px;
}
.upload-title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.upload-hint {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
}
.selected-files {
margin-top: 24px;
.files-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h4 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 16px;
}
}
.files-list {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
.file-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background-color 0.3s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: var(--el-fill-color-light);
}
.file-icon {
margin-right: 12px;
.file-type {
width: 32px;
height: 32px;
border-radius: 4px;
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;
}
}
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-details {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
@media (max-width: 480px) {
flex-direction: column;
gap: 2px;
}
}
}
}
}
}
.form-tip {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background-color: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 13px;
color: var(--el-color-info);
line-height: 1.4;
.el-icon {
margin-top: 1px;
flex-shrink: 0;
}
}
.translation-actions {
display: flex;
gap: 12px;
@media (max-width: 480px) {
flex-direction: column;
.el-button {
width: 100%;
}
}
}
.upload-progress {
.overall-progress {
margin-bottom: 24px;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
}
.files-progress {
.file-progress-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
&.success {
.file-status {
color: var(--el-color-success);
}
}
&.error {
.file-status {
color: var(--el-color-danger);
}
}
&.uploading {
.file-status {
color: var(--el-color-primary);
}
}
.file-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
.file-icon {
margin-right: 12px;
.file-type {
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: white;
&.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: 500;
color: var(--el-text-color-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
.el-icon {
font-size: 14px;
}
}
}
}
.file-progress {
width: 120px;
margin: 0 16px;
}
.file-actions {
margin-left: 16px;
}
}
}
.upload-complete-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
@media (max-width: 480px) {
flex-direction: column;
}
}
}
}
</style>