Files
opc-web/src/layouts/EnterpriseLayout.vue

404 lines
15 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { RouterView, useRouter, useRoute } from 'vue-router';
import {
LayoutDashboard, PlusCircle, ListTodo, Users, Bell,
LogOut, Search, Building2, Mail, ShieldCheck, Settings,
Home, ArrowLeft, GripVertical, TrendingUp, Menu,
ChevronDown, ChevronRight
} from 'lucide-vue-next';
import { useAuthStore } from '@/stores/auth';
import { getTasks } from '@/api/tasks';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
// Sidebar visibility — controlled by toggle button in header
const sidebarVisible = ref(localStorage.getItem('sidebarVisible') === 'true');
const toggleSidebar = () => {
sidebarVisible.value = !sidebarVisible.value;
localStorage.setItem('sidebarVisible', String(sidebarVisible.value));
window.dispatchEvent(new Event('sidebar-visibility-change'));
};
const onVisibilityChange = () => {
sidebarVisible.value = localStorage.getItem('sidebarVisible') === 'true';
};
// Sidebar nav grouped by business domain
const navGroups = [
{ key: 'biz', label: '业务中心', items: [
{ name: '企业工作台', path: '/enterprise/dashboard', icon: LayoutDashboard, perm: 'menu:ent:dashboard' },
{ name: '需求发布', path: '/enterprise/tasks/create', icon: PlusCircle, perm: 'menu:ent:tasks:create' },
{ name: '项目管理', path: '/enterprise/tasks', icon: ListTodo, perm: 'menu:ent:tasks' },
]},
{ key: 'talent', label: '人才管理', items: [
{ name: '专家人才库', path: '/enterprise/opc-users', icon: Search, perm: 'menu:ent:opc_users' },
{ name: '我邀请的', path: '/enterprise/invitations', icon: Mail, perm: 'menu:ent:invitations' },
]},
{ key: 'org', label: '企业管理', items: [
{ name: '成员管理', path: '/enterprise/team', icon: Users, perm: 'menu:ent:team' },
{ name: '企业档案', path: '/enterprise/profile', icon: Building2, perm: 'menu:ent:profile' },
{ name: '企业资质', path: '/enterprise/verification', icon: ShieldCheck, perm: 'menu:ent:verification' },
{ name: '账号设置', path: '/enterprise/settings', icon: Settings, perm: 'menu:ent:settings' },
]},
];
const allNavItems = navGroups.flatMap(g => g.items);
// Collapse state per group (all expanded by default)
const collapsedGroups = ref<Record<string, boolean>>({});
const toggleGroup = (key: string) => {
collapsedGroups.value[key] = !collapsedGroups.value[key];
};
import api from '@/api';
const myRole = ref('MEMBER');
const enterprise = ref<any>(null);
const memberCount = ref(0);
const taskCount = ref(0);
const completedCount = ref(0);
const visibleNavGroups = computed(() => {
return navGroups.map(group => ({
...group,
items: group.items.filter(item => {
if (!item.perm) return true;
return authStore.hasPermission(item.perm);
})
})).filter(group => group.items.length > 0);
});
const activeMenu = computed(() => {
const path = route.path;
const exact = allNavItems.find(item => item.path === path);
if (exact) return exact.path;
const prefix = allNavItems
.filter(item => path.startsWith(item.path) && item.path !== '/enterprise/dashboard')
.sort((a, b) => b.path.length - a.path.length);
return prefix.length > 0 ? prefix[0].path : '/enterprise/dashboard';
});
const handleLogout = () => {
authStore.logout();
router.push('/login');
};
const handleMenuSelect = (path: string) => {
router.push(path);
};
const handleCommand = (cmd: string) => {
if (cmd === 'profile') router.push('/enterprise/profile');
else if (cmd === 'logout') handleLogout();
};
// Floating toolbar draggable logic
const fabPos = ref({ x: -1, y: -1 });
const isDragging = ref(false);
const dragOffset = ref({ x: 0, y: 0 });
onMounted(async () => {
fabPos.value = { x: window.innerWidth - 80, y: window.innerHeight - 180 };
window.addEventListener('sidebar-visibility-change', onVisibilityChange);
try {
const [entRes, membersRes, tasksRes]: any[] = await Promise.all([
api.get('/enterprises/me/'),
api.get('/enterprise-members/'),
getTasks(),
]);
enterprise.value = entRes;
if (entRes?.my_role) myRole.value = entRes.my_role;
const mList = membersRes.results || membersRes;
memberCount.value = Array.isArray(mList) ? mList.length : 0;
const tList = tasksRes.results || [];
taskCount.value = tList.length;
completedCount.value = tList.filter((t: any) => t.status === 'COMPLETED').length;
} catch (e) { console.error('Failed to fetch enterprise info'); }
});
const onFabDown = (e: MouseEvent | TouchEvent) => {
isDragging.value = true;
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
dragOffset.value = { x: clientX - fabPos.value.x, y: clientY - fabPos.value.y };
document.addEventListener('mousemove', onFabMove);
document.addEventListener('mouseup', onFabUp);
document.addEventListener('touchmove', onFabMove);
document.addEventListener('touchend', onFabUp);
};
const onFabMove = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return;
const clientX = 'touches' in e ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
const clientY = 'touches' in e ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
fabPos.value = {
x: Math.max(0, Math.min(window.innerWidth - 56, clientX - dragOffset.value.x)),
y: Math.max(0, Math.min(window.innerHeight - 140, clientY - dragOffset.value.y)),
};
};
const onFabUp = () => {
isDragging.value = false;
document.removeEventListener('mousemove', onFabMove);
document.removeEventListener('mouseup', onFabUp);
document.removeEventListener('touchmove', onFabMove);
document.removeEventListener('touchend', onFabUp);
};
onUnmounted(() => {
onFabUp();
window.removeEventListener('sidebar-visibility-change', onVisibilityChange);
});
const goHome = () => router.push('/enterprise/dashboard');
const goBack = () => router.back();
</script>
<template>
<el-container class="min-h-screen">
<!-- Sidebar -->
<transition name="sidebar-slide">
<div v-if="sidebarVisible" class="sidebar-rail">
<div class="sidebar-inner">
<!-- Logo -->
<div class="sidebar-header">
<div class="logo-icon">O</div>
<span class="logo-text">OPC 平台 <span class="logo-sub">企业端</span></span>
</div>
<!-- Grouped Navigation -->
<nav class="sidebar-nav">
<template v-for="group in visibleNavGroups" :key="group.key">
<div class="nav-group-header" @click="toggleGroup(group.key)">
<span class="nav-group-label">{{ group.label }}</span>
<ChevronDown v-if="!collapsedGroups[group.key]" class="w-3.5 h-3.5 text-gray-400" />
<ChevronRight v-else class="w-3.5 h-3.5 text-gray-400" />
</div>
<transition name="group-collapse">
<div v-show="!collapsedGroups[group.key]" class="nav-group-items">
<div
v-for="item in group.items" :key="item.path"
class="nav-item" :class="{ active: activeMenu === item.path }"
@click="handleMenuSelect(item.path)"
>
<component :is="item.icon" class="nav-icon" />
<span class="nav-label">{{ item.name }}</span>
</div>
</div>
</transition>
</template>
</nav>
</div>
</div>
</transition>
<!-- Main -->
<el-container class="main-container" :class="{ 'sidebar-on': sidebarVisible }">
<!-- Header -->
<el-header class="app-header">
<!-- Left: toggle + badges -->
<div class="header-left">
<button class="sidebar-toggle" @click="toggleSidebar" :title="sidebarVisible ? '收起侧栏' : '展开侧栏'">
<Menu class="w-5 h-5" />
</button>
<el-tag v-if="enterprise?.status" :type="enterprise.status === 'VERIFIED' ? 'success' : enterprise.status === 'REJECTED' ? 'danger' : 'warning'" effect="dark" size="small" round class="!font-semibold">
<ShieldCheck class="w-3 h-3 mr-0.5 inline" />{{ enterprise?.status === 'VERIFIED' ? '已认证' : enterprise?.status === 'REJECTED' ? '被拒绝' : '待核验' }}
</el-tag>
<span class="hdr-sep"></span>
<span class="hdr-pill"><ListTodo class="w-3.5 h-3.5 text-blue-500" /> {{ taskCount }} 个任务</span>
<span class="hdr-pill"><Users class="w-3.5 h-3.5 text-green-500" /> {{ memberCount }} 名成员</span>
<span class="hdr-pill"><TrendingUp class="w-3.5 h-3.5 text-amber-500" /> 历史完成 <strong style="color:#059669;margin:0 2px">{{ completedCount }}</strong> 项目</span>
</div>
<!-- Right: actions -->
<div class="header-right">
<router-link to="/enterprise/dashboard">
<el-badge :value="0" :hidden="true">
<Bell class="w-5 h-5 text-gray-400 hover:text-amber-500 transition-colors cursor-pointer" />
</el-badge>
</router-link>
<el-dropdown @command="handleCommand" trigger="click">
<div class="user-avatar-area">
<div class="hidden sm:block text-right mr-3">
<div class="text-sm font-semibold text-gray-800 leading-none mb-0.5">{{ user?.nickname || user?.username }}</div>
<div class="text-xs text-gray-400">{{ myRole === 'OWNER' ? '企业负责人' : '企业成员' }}</div>
</div>
<el-avatar :size="36" :src="user?.avatar_url" class="bg-amber-500 text-white font-bold">
{{ user?.nickname?.[0] || user?.username?.[0] || 'U' }}
</el-avatar>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<Building2 class="w-4 h-4 mr-2" />企业信息
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<LogOut class="w-4 h-4 mr-2" />退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- Content -->
<el-main class="app-main">
<RouterView />
</el-main>
</el-container>
<!-- Floating Toolbar -->
<div
class="fab-toolbar"
:style="{ left: fabPos.x + 'px', top: fabPos.y + 'px' }"
v-show="fabPos.x >= 0"
>
<div class="fab-handle" @mousedown.prevent="onFabDown" @touchstart.prevent="onFabDown">
<GripVertical class="w-3.5 h-3.5" />
</div>
<button class="fab-btn" @click="goHome" title="回到主页"><Home class="w-4 h-4" /></button>
<button class="fab-btn" @click="goBack" title="返回上页"><ArrowLeft class="w-4 h-4" /></button>
</div>
</el-container>
</template>
<style scoped>
/* Sidebar Rail */
.sidebar-rail {
position: fixed;
top: 0; left: 0; bottom: 0;
width: 220px;
background: #fff;
border-right: 1px solid #f0f0f0;
z-index: 200;
overflow: hidden;
}
.sidebar-inner {
display: flex; flex-direction: column; height: 100%; width: 220px;
}
/* Sidebar transition */
.sidebar-slide-enter-active,
.sidebar-slide-leave-active { transition: transform 0.25s cubic-bezier(0.4,0,0.2,1), opacity 0.2s; }
.sidebar-slide-enter-from,
.sidebar-slide-leave-to { transform: translateX(-100%); opacity: 0; }
/* Header / Logo */
.sidebar-header {
height: 64px; display: flex; align-items: center;
padding: 0 16px; gap: 10px;
border-bottom: 1px solid #f0f0f0;
background: #1d1d1f; flex-shrink: 0;
}
.logo-icon {
width: 32px; height: 32px; background: #fff; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-weight: 900; font-size: 14px; color: #1d1d1f; flex-shrink: 0;
}
.logo-text { font-weight: 800; font-size: 15px; color: #fff; white-space: nowrap; flex: 1; }
.logo-sub { color: #fbbf24; font-weight: 400; }
/* Grouped Navigation */
.sidebar-nav { flex: 1; padding: 8px; overflow-y: auto; overflow-x: hidden; }
.nav-group-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 12px 6px;
cursor: pointer; user-select: none;
}
.nav-group-label {
font-size: 11px; font-weight: 700;
color: #9ca3af; letter-spacing: 0.06em;
text-transform: uppercase;
}
.nav-group-header:hover .nav-group-label { color: #6b7280; }
.group-collapse-enter-active,
.group-collapse-leave-active { transition: all 0.2s ease; overflow: hidden; }
.group-collapse-enter-from,
.group-collapse-leave-to { opacity: 0; max-height: 0; }
.nav-item {
display: flex; align-items: center; gap: 12px;
height: 40px; padding: 0 12px;
border-radius: 10px; cursor: pointer;
transition: all 0.15s;
color: #6b7280; font-size: 13px;
white-space: nowrap; margin-bottom: 1px;
}
.nav-item:hover { background: #f3f4f6; color: #374151; }
.nav-item.active { background: #fffbeb; color: #d97706; font-weight: 600; }
.nav-icon { width: 18px; height: 18px; flex-shrink: 0; }
.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
/* Main container */
.main-container {
margin-left: 0;
transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.main-container.sidebar-on { margin-left: 220px; }
/* Header */
.app-header {
height: 56px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid #eef0f4;
background: #fff;
position: sticky; top: 0; z-index: 50;
}
.header-left {
display: flex; align-items: center; gap: 12px;
}
.sidebar-toggle {
width: 36px; height: 36px;
border-radius: 8px; border: 1px solid #e5e7eb;
background: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #6b7280;
transition: all 0.15s;
flex-shrink: 0;
}
.sidebar-toggle:hover { background: #f9fafb; color: #111827; border-color: #d1d5db; }
.hdr-sep {
width: 1px; height: 20px;
background: #e5e7eb;
}
.hdr-pill {
display: inline-flex; align-items: center; gap: 4px;
font-size: 12px; color: #6b7280; font-weight: 500;
}
.header-right { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
.user-avatar-area { display: flex; align-items: center; cursor: pointer; }
.app-main { background: #f5f7fa; min-height: calc(100vh - 56px); padding: 24px; }
/* Floating Toolbar */
.fab-toolbar {
position: fixed; z-index: 9999;
display: flex; flex-direction: column;
align-items: center; gap: 6px;
background: rgba(30, 30, 32, 0.55);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px; padding: 8px 6px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
border: 1px solid rgba(255,255,255,0.08);
}
.fab-handle {
cursor: grab; color: rgba(255,255,255,0.35);
padding: 2px 0; user-select: none; touch-action: none;
}
.fab-handle:active { cursor: grabbing; }
.fab-btn {
width: 36px; height: 36px; border-radius: 10px;
border: none; background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.7);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
}
.fab-btn:hover {
background: rgba(251,191,36,0.2);
color: #fbbf24;
}
</style>