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

View File

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