Files
opc-web/src/views/enterprise/DashboardView.vue

335 lines
14 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import {
Users, ListTodo, ShieldCheck, ArrowRight, TrendingUp, Newspaper,
Calendar, PlusCircle, Search, Building2, Mail, Briefcase, FileText, Clock,
Star, MapPin, ChevronRight, Ban, CheckCircle, Eye
} from 'lucide-vue-next';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import api from '@/api';
import { getTasks } from '@/api/tasks';
import { getAnnouncements } from '@/api/system';
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/preview.css';
const router = useRouter();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
const tasks = ref<any[]>([]);
const members = ref<any[]>([]);
const enterprise = ref<any>(null);
const opcExperts = ref<any[]>([]);
// All 6 task status stats
const statusCards = computed(() => [
{ key: 'OPEN', label: '招募中', count: tasks.value.filter((t: any) => t.status === 'OPEN').length, color: '#3b82f6', bg: '#eff6ff' },
{ key: 'IN_PROGRESS', label: '进行中', count: tasks.value.filter((t: any) => t.status === 'IN_PROGRESS').length, color: '#f59e0b', bg: '#fffbeb' },
{ key: 'IN_REVIEW', label: '待验收', count: tasks.value.filter((t: any) => t.status === 'IN_REVIEW').length, color: '#8b5cf6', bg: '#f5f3ff' },
{ key: 'COMPLETED', label: '已完成', count: tasks.value.filter((t: any) => t.status === 'COMPLETED').length, color: '#10b981', bg: '#ecfdf5' },
{ key: 'CANCELLED', label: '已取消', count: tasks.value.filter((t: any) => t.status === 'CANCELLED').length, color: '#ef4444', bg: '#fef2f2' },
{ key: 'DRAFT', label: '草稿', count: tasks.value.filter((t: any) => t.status === 'DRAFT').length, color: '#9ca3af', bg: '#f9fafb' },
]);
const isLoading = ref(true);
const fetchData = async () => {
try {
isLoading.value = true;
const [tasksRes, membersRes, entRes, expertsRes]: any[] = await Promise.all([
api.get('/tasks/'),
api.get('/enterprise-members/'),
api.get('/enterprises/me/'),
api.get('/users/', { params: { role: 'OPC_USER' } }).catch(() => ({ results: [] })),
]);
tasks.value = tasksRes.results || [];
members.value = membersRes.results || membersRes;
enterprise.value = entRes as any;
opcExperts.value = (expertsRes.results || []).slice(0, 6);
} catch (e) {
console.error(e);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
fetchData();
});
const recentTasks = computed(() => tasks.value.slice(0, 4));
const getStatusType = (status: string) => {
switch (status) {
case 'OPEN': return 'primary';
case 'IN_PROGRESS': return 'warning';
case 'IN_REVIEW': return '';
case 'COMPLETED': return 'success';
case 'CANCELLED': return 'danger';
case 'DRAFT': return 'info';
default: return 'info';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'OPEN': return '招募中';
case 'IN_PROGRESS': return '进行中';
case 'IN_REVIEW': return '待验收';
case 'COMPLETED': return '已结案';
case 'CANCELLED': return '已取消';
case 'DRAFT': return '草稿';
default: return status;
}
};
const goToStatus = (status: string) => {
router.push(`/enterprise/tasks?status=${status}`);
};
</script>
<template>
<div class="dashboard-page" v-loading="isLoading">
<!-- Task Status Overview Full Width -->
<div class="section-card mb-6">
<div class="flex flex-col lg:flex-row gap-6">
<!-- Left: Stats -->
<div class="flex-1">
<div class="section-header">
<div class="section-badge" style="background: #eff6ff; color: #3b82f6;">
<ListTodo class="w-4 h-4" />
</div>
<h2 class="section-title">任务概览</h2>
<el-button text type="primary" size="small" @click="router.push('/enterprise/tasks')">全部任务 </el-button>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<div v-for="card in statusCards" :key="card.key"
class="stat-card" :style="{ '--accent': card.color, '--bg': card.bg }"
@click="goToStatus(card.key)"
>
<div class="flex items-center gap-1.5 mb-2">
<div class="w-2 h-2 rounded-full" :style="{ background: card.color }"></div>
<div class="text-xs font-medium text-gray-500">{{ card.label }}</div>
</div>
<div class="text-2xl font-black tracking-tight" :style="{ color: card.color }">{{ card.count }}</div>
</div>
</div>
</div>
<!-- Right: Quick Actions -->
<div class="w-full lg:w-64 lg:border-l lg:border-gray-100 lg:pl-6">
<div class="section-header mb-3">
<div class="section-badge" style="background: #fef3c7; color: #d97706; width: 28px; height: 28px;">
<Briefcase class="w-3.5 h-3.5" />
</div>
<h2 class="section-title text-[14px]">快捷操作</h2>
</div>
<div class="flex flex-col gap-2">
<div class="action-row !p-2" @click="enterprise?.status === 'VERIFIED' && router.push('/enterprise/tasks/create')">
<div class="action-icon !w-8 !h-8" style="background: #eff6ff; color: #3b82f6;"><PlusCircle class="w-3.5 h-3.5" /></div>
<div class="action-text">
<span class="action-name text-[13px]">发布新任务</span>
</div>
<ChevronRight class="w-3.5 h-3.5 text-gray-300 ml-auto" />
</div>
<div class="action-row !p-2" @click="router.push('/enterprise/invitations')">
<div class="action-icon !w-8 !h-8" style="background: #fce7f3; color: #db2777;"><Mail class="w-3.5 h-3.5" /></div>
<div class="action-text">
<span class="action-name text-[13px]">定向邀约记录</span>
</div>
<ChevronRight class="w-3.5 h-3.5 text-gray-300 ml-auto" />
</div>
</div>
</div>
</div>
</div>
<!-- Row 2: Three Columns -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Column 1: Recent Tasks -->
<div class="section-card flex flex-col">
<div class="section-header">
<div class="section-badge" style="background: #ede9fe; color: #7c3aed;">
<Clock class="w-4 h-4" />
</div>
<h2 class="section-title">最近任务</h2>
<el-button text type="primary" size="small" @click="router.push('/enterprise/tasks')">全部 </el-button>
</div>
<div v-if="recentTasks.length > 0" class="flex flex-col gap-3">
<div v-for="task in recentTasks" :key="task.id"
class="group bg-white border border-gray-100 hover:border-blue-200 rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5 hover:shadow-blue-50 flex flex-col justify-between"
@click="router.push(`/enterprise/tasks/${task.id}`)">
<div>
<div class="flex justify-between items-start mb-2 gap-2">
<div class="text-[15px] font-bold text-gray-800 line-clamp-2 group-hover:text-blue-600 transition-colors">{{ task.title }}</div>
<el-tag :type="getStatusType(task.status)" size="small" effect="light" round class="flex-shrink-0">{{ getStatusLabel(task.status) }}</el-tag>
</div>
<div class="text-[13px] text-gray-500 flex items-center gap-1.5 font-medium mb-1">
<span class="text-blue-600 bg-blue-50 px-2 py-0.5 rounded-md">¥{{ task.budget_min?.toLocaleString() || 0 }} - {{ task.budget_max?.toLocaleString() || '不限' }}</span>
</div>
</div>
<div class="mt-3 flex items-center justify-between text-xs text-gray-400 border-t border-gray-50 pt-3">
<div class="flex items-center gap-1">
<Clock class="w-3 h-3" /> {{ new Date(task.created_at).toLocaleDateString() }}
</div>
<div class="flex items-center gap-1" :class="task.task_applications?.length ? 'text-blue-500 font-bold' : ''">
<Users class="w-3 h-3" /> {{ task.task_applications?.length || 0 }} 人申请
</div>
</div>
</div>
</div>
<el-empty v-else description="暂无任务" :image-size="60" class="flex-1 flex flex-col justify-center">
<el-button type="primary" @click="router.push('/enterprise/tasks/create')">
<PlusCircle class="w-4 h-4 mr-2" />发布首个任务
</el-button>
</el-empty>
</div>
<!-- Column 2: Team Members -->
<div class="section-card flex flex-col">
<div class="section-header">
<div class="section-badge" style="background: #fdf2f8; color: #db2777;">
<Users class="w-4 h-4" />
</div>
<h2 class="section-title">团队成员</h2>
<el-button text type="primary" size="small" @click="router.push('/enterprise/team')">管理 </el-button>
</div>
<div v-if="members.length > 0" class="space-y-2">
<div v-for="member in members.slice(0, 5)" :key="member.id"
class="expert-row group" @click="router.push('/enterprise/team')">
<el-avatar :size="38" :src="member.user_detail?.avatar_url" class="bg-pink-100 text-pink-600 font-bold text-[13px] flex-shrink-0 group-hover:ring-2 ring-pink-100 transition-all">
{{ member.user_detail?.nickname?.[0] || member.user_detail?.username?.[0] || 'U' }}
</el-avatar>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-gray-800 truncate">{{ member.user_detail?.nickname || member.user_detail?.username || '未知成员' }}</div>
<div class="text-[11px] text-gray-400 truncate mt-0.5">
{{ member.roles?.map((r: any) => r.name).join(', ') || '成员' }}
</div>
</div>
<el-tag size="small" :type="member.is_active ? 'success' : 'info'" effect="light" round class="!border-none">{{ member.is_active ? '正常' : '禁用' }}</el-tag>
</div>
</div>
<el-empty v-else description="暂无成员" :image-size="40" class="flex-1 flex flex-col justify-center" />
</div>
<!-- Column 3: Quick Actions & Recommended Talent -->
<div class="flex flex-col gap-6">
<!-- OPC Talent Recommendations -->
<div class="section-card flex-1">
<div class="section-header">
<div class="section-badge" style="background: #ecfdf5; color: #059669;">
<Star class="w-4 h-4" />
</div>
<h2 class="section-title">推荐人才</h2>
<el-button text type="primary" size="small" @click="router.push('/enterprise/opc-users')">更多 </el-button>
</div>
<div v-if="opcExperts.length > 0" class="space-y-2">
<div v-for="expert in opcExperts.slice(0, 4)" :key="expert.id"
class="expert-row group" @click="router.push(`/enterprise/opc-users?open_drawer=${expert.id}`)">
<el-avatar :size="38" :src="expert.avatar_url" class="bg-gradient-to-br from-emerald-400 to-cyan-500 text-white font-bold text-[13px] flex-shrink-0 group-hover:ring-2 ring-emerald-100 transition-all shadow-sm">
{{ expert.nickname?.[0] || expert.username?.[0] || 'U' }}
</el-avatar>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold text-gray-800 truncate group-hover:text-emerald-600 transition-colors">{{ expert.nickname || expert.username }}</div>
<div class="text-[11px] text-gray-400 truncate mt-0.5">
{{ expert.bio || '暂无简介' }}
</div>
</div>
<div class="flex items-center gap-1 flex-shrink-0 bg-yellow-50 px-2 py-0.5 rounded-md">
<Star class="w-3 h-3 text-yellow-500 fill-yellow-500" />
<span class="text-[11px] font-bold text-yellow-600">{{ Number(expert.rating || 5).toFixed(1) }}</span>
</div>
</div>
</div>
<el-empty v-else description="暂无推荐" :image-size="40" class="flex-1 flex flex-col justify-center" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dashboard-page { max-width: 100%; }
.section-card {
background: #fff;
border-radius: 16px;
padding: 20px 24px;
border: 1px solid #f0f0f0;
}
.section-header {
display: flex; align-items: center; gap: 10px; margin-bottom: 16px;
}
.section-badge {
width: 32px; height: 32px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.section-title {
font-size: 15px; font-weight: 700; color: #1f2937; flex: 1;
}
/* Status stat cards */
.stat-card {
display: flex; flex-direction: column;
padding: 12px 14px;
border-radius: 12px;
background: var(--bg);
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
text-align: left;
}
.stat-card:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0,0,0,0.04);
}
/* Action rows */
.action-row {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 10px;
cursor: pointer; transition: all 0.15s;
}
.action-row:hover { background: #f9fafb; }
.action-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.action-text { flex: 1; min-width: 0; }
.action-name { font-size: 13px; font-weight: 600; color: #374151; display: block; }
.action-desc { font-size: 11px; color: #9ca3af; }
/* Task rows */
.task-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 10px;
cursor: pointer; transition: background 0.15s;
}
.task-row:hover { background: #f9fafb; }
/* Expert rows */
.expert-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
cursor: pointer; transition: background 0.15s;
}
.expert-row:hover { background: #f0fdf4; }
/* Announcement nav */
.ann-nav-btn {
width: 24px; height: 24px; border-radius: 6px;
border: 1px solid #e5e7eb; background: #fff; color: #6b7280;
display: flex; align-items: center; justify-content: center;
font-size: 14px; cursor: pointer; transition: all 0.15s;
}
.ann-nav-btn:hover { background: #f3f4f6; border-color: #d1d5db; }
</style>