2ND
This commit is contained in:
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>
|
Reference in New Issue
Block a user