2ND
This commit is contained in:
95
frontend/src/App.vue
Normal file
95
frontend/src/App.vue
Normal 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>
|
431
frontend/src/layouts/MainLayout.vue
Normal file
431
frontend/src/layouts/MainLayout.vue
Normal 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
49
frontend/src/main.js
Normal 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
|
165
frontend/src/router/index.js
Normal file
165
frontend/src/router/index.js
Normal 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
|
114
frontend/src/services/admin.js
Normal file
114
frontend/src/services/admin.js
Normal 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')
|
||||
}
|
||||
}
|
44
frontend/src/services/auth.js
Normal file
44
frontend/src/services/auth.js
Normal 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')
|
||||
}
|
||||
}
|
103
frontend/src/services/jobs.js
Normal file
103
frontend/src/services/jobs.js
Normal 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`)
|
||||
}
|
||||
}
|
279
frontend/src/stores/admin.js
Normal file
279
frontend/src/stores/admin.js
Normal 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
182
frontend/src/stores/auth.js
Normal 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
403
frontend/src/stores/jobs.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
325
frontend/src/style/components.scss
Normal file
325
frontend/src/style/components.scss
Normal 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;
|
||||
}
|
||||
}
|
458
frontend/src/style/layouts.scss
Normal file
458
frontend/src/style/layouts.scss
Normal 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;
|
||||
}
|
||||
}
|
187
frontend/src/style/main.scss
Normal file
187
frontend/src/style/main.scss
Normal 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;
|
||||
}
|
272
frontend/src/style/mixins.scss
Normal file
272
frontend/src/style/mixins.scss
Normal 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); }
|
||||
}
|
106
frontend/src/style/variables.scss
Normal file
106
frontend/src/style/variables.scss
Normal 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
|
||||
);
|
196
frontend/src/utils/request.js
Normal file
196
frontend/src/utils/request.js
Normal 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
|
323
frontend/src/utils/websocket.js
Normal file
323
frontend/src/utils/websocket.js
Normal 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
|
799
frontend/src/views/AdminView.vue
Normal file
799
frontend/src/views/AdminView.vue
Normal 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>
|
840
frontend/src/views/HistoryView.vue
Normal file
840
frontend/src/views/HistoryView.vue
Normal 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>
|
652
frontend/src/views/HomeView.vue
Normal file
652
frontend/src/views/HomeView.vue
Normal 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>
|
847
frontend/src/views/JobDetailView.vue
Normal file
847
frontend/src/views/JobDetailView.vue
Normal 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>
|
894
frontend/src/views/JobListView.vue
Normal file
894
frontend/src/views/JobListView.vue
Normal 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>
|
348
frontend/src/views/LoginView.vue
Normal file
348
frontend/src/views/LoginView.vue
Normal 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>© 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>
|
278
frontend/src/views/NotFoundView.vue
Normal file
278
frontend/src/views/NotFoundView.vue
Normal 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>
|
562
frontend/src/views/ProfileView.vue
Normal file
562
frontend/src/views/ProfileView.vue
Normal 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>
|
865
frontend/src/views/UploadView.vue
Normal file
865
frontend/src/views/UploadView.vue
Normal 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">
|
||||
支援 DOCX、DOC、PPTX、XLSX、XLS、PDF 格式,單檔最大 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>
|
Reference in New Issue
Block a user