开发了权限分配功能:实现了一个可以在后台勾选页面的功能,通过给角色勾选菜单,就能直接控制不同身份的人登录后能看到哪些页面。 开发了实名认证功能:实现了企业可以提交营业执照认证,个人可以提交身份证件和技能认证的功能,管理员在后台可以进行审核。 开发了任务大厅功能:实现了企业可以发布需要做的任务,个人用户能在任务大厅里看到这些任务,并且可以点击申请接单,大家都能看到任务是“进行中”还是“已完成”状态。 开发了专家库与邀约功能:实现了企业可以去专家库里搜索合适的人才,并且可以直接给他们发送工作邀约。 开发了平台数据大屏展示功能:实现了在首页和各自的工作台页面,展示任务数量、收益金额等核心数据的概览面板。
404 lines
15 KiB
Vue
404 lines
15 KiB
Vue
<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>
|