Files
opc-web/src/views/user/DashboardView.vue
xujl 8e18e77747 开发了多角色登录功能:实现了普通用户、企业用户和管理员可以分别通过不同入口登录系统,并且支持用账号、邮箱或手机号登录。
开发了权限分配功能:实现了一个可以在后台勾选页面的功能,通过给角色勾选菜单,就能直接控制不同身份的人登录后能看到哪些页面。
开发了实名认证功能:实现了企业可以提交营业执照认证,个人可以提交身份证件和技能认证的功能,管理员在后台可以进行审核。
开发了任务大厅功能:实现了企业可以发布需要做的任务,个人用户能在任务大厅里看到这些任务,并且可以点击申请接单,大家都能看到任务是“进行中”还是“已完成”状态。
开发了专家库与邀约功能:实现了企业可以去专家库里搜索合适的人才,并且可以直接给他们发送工作邀约。
开发了平台数据大屏展示功能:实现了在首页和各自的工作台页面,展示任务数量、收益金额等核心数据的概览面板。
2026-04-28 16:22:50 +08:00

502 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import {
ClipboardList, ArrowRight, History, Calendar, Newspaper,
Briefcase, Star, ShieldCheck, CheckCircle, XCircle, Clock,
ShoppingBag, Mail, Send, TrendingUp, Sparkles
} from 'lucide-vue-next';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { getAnnouncements } from '@/api/system';
import { getApplications, getTasks } from '@/api/tasks';
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 pendingApps = ref(0);
const approvedApps = ref(0);
const rejectedApps = ref(0);
const withdrawnApps = ref(0);
const deliveredApps = ref(0);
const completedApps = ref(0);
const cancelledApps = ref(0);
const openTasks = ref(0);
const myProjectCount = ref(0);
const recentActivities = ref<any[]>([]);
const announcements = ref<any[]>([]);
const currentAnnIndex = ref(0);
const recommendedTasks = ref<any[]>([]);
let annTimer: any = null;
const annPageSize = 2;
const totalAnnPages = computed(() => Math.ceil(announcements.value.length / annPageSize));
const nextAnn = () => { if (totalAnnPages.value > 1) currentAnnIndex.value = (currentAnnIndex.value + 1) % totalAnnPages.value; };
const prevAnn = () => { if (totalAnnPages.value > 1) currentAnnIndex.value = (currentAnnIndex.value - 1 + totalAnnPages.value) % totalAnnPages.value; };
const visibleAnns = computed(() => {
const start = currentAnnIndex.value * annPageSize;
return announcements.value.slice(start, start + annPageSize);
});
const annDetailVisible = ref(false);
const selectedAnn = ref<any>(null);
const openAnnDetail = (ann: any) => { selectedAnn.value = ann; annDetailVisible.value = true; };
const isLoading = ref(true);
const fetchData = async () => {
try {
isLoading.value = true;
const [annRes, appRes, tasksRes] = await Promise.all([
getAnnouncements({ audience: 'USER' }), getApplications(), getTasks(),
]);
announcements.value = (annRes as any).results || [];
const apps = (appRes as any).results || [];
pendingApps.value = apps.filter((a: any) => a.status === 'PENDING').length;
approvedApps.value = apps.filter((a: any) => a.status === 'APPROVED').length;
rejectedApps.value = apps.filter((a: any) => a.status === 'REJECTED').length;
withdrawnApps.value = apps.filter((a: any) => a.status === 'WITHDRAWN').length;
deliveredApps.value = apps.filter((a: any) => a.status === 'DELIVERED').length;
const getAppStatus = (app: any) => {
if (app.task_detail?.status === 'CANCELLED') return 'CANCELLED';
if (app.task_detail?.status === 'COMPLETED' || app.status === 'COMPLETED') return 'COMPLETED';
return app.status;
};
completedApps.value = apps.filter((a: any) => getAppStatus(a) === 'COMPLETED').length;
cancelledApps.value = apps.filter((a: any) => getAppStatus(a) === 'CANCELLED').length;
myProjectCount.value = apps.filter((a: any) => {
const s = getAppStatus(a);
return ['APPROVED', 'DELIVERED', 'COMPLETED', 'CANCELLED'].includes(s);
}).length;
const allTasks = (tasksRes as any).results || (tasksRes as any) || [];
openTasks.value = allTasks.filter((t: any) => t.status === 'OPEN').length;
recommendedTasks.value = allTasks.filter((t: any) => t.status === 'OPEN' && t.is_recommended).slice(0, 3);
if (!recommendedTasks.value.length) recommendedTasks.value = allTasks.filter((t: any) => t.status === 'OPEN').slice(0, 3);
recentActivities.value = apps.slice(0, 5).map((a: any) => ({
id: a.id, taskId: a.task, status: a.status,
taskName: a.task_detail?.title || `任务 #${a.task}`,
timeAgo: getTimeAgo(a.created_at),
}));
} catch (e) { console.error(e); } finally { isLoading.value = false; }
};
const getTimeAgo = (d: string) => {
const m = Math.floor((Date.now() - new Date(d).getTime()) / 60000);
if (m < 60) return `${m}分钟前`;
const h = Math.floor(m / 60);
return h < 24 ? `${h}小时前` : `${Math.floor(h / 24)}天前`;
};
onMounted(() => { fetchData(); annTimer = setInterval(nextAnn, 6000); });
onUnmounted(() => { if (annTimer) clearInterval(annTimer); });
const statusType = (s: string) => ({ APPROVED: 'success', REJECTED: 'danger', PENDING: 'warning', DELIVERED: 'primary' }[s] || 'info');
const statusLabel = (s: string) => ({ APPROVED: '执行中', DELIVERED: '待验收', COMPLETED: '已结案', PENDING: '待审核', REJECTED: '被拒绝' }[s] || s);
</script>
<template>
<div class="dash" v-loading="isLoading">
<!-- ===== Stats Row ===== -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
<!-- 我的申请 -->
<div class="card">
<div class="card-head">
<div class="card-icon" style="--c:#3b82f6;--bg:#eff6ff"><Send class="w-4 h-4" /></div>
<h2 class="card-title">我的申请</h2>
<button class="card-link" @click="router.push('/user/applications')">查看全部 <ArrowRight class="w-3 h-3" /></button>
</div>
<div class="grid grid-cols-4 gap-3">
<div class="stat-chip" @click="router.push({ path: '/user/applications', query: { tab: 'PENDING' } })">
<div class="stat-chip-dot" style="background:#3b82f6"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">待审核</span>
<span class="stat-chip-num" style="color:#3b82f6">{{ pendingApps }}</span>
</div>
</div>
<div class="stat-chip" @click="router.push({ path: '/user/applications', query: { tab: 'APPROVED' } })">
<div class="stat-chip-dot" style="background:#10b981"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">已通过</span>
<span class="stat-chip-num" style="color:#10b981">{{ approvedApps }}</span>
</div>
</div>
<div class="stat-chip stat-chip--danger" @click="router.push({ path: '/user/applications', query: { tab: 'REJECTED' } })">
<div class="stat-chip-dot" style="background:#ef4444"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">被拒绝</span>
<span class="stat-chip-num" style="color:#ef4444">{{ rejectedApps }}</span>
</div>
</div>
<div class="stat-chip" @click="router.push({ path: '/user/applications', query: { tab: 'WITHDRAWN' } })">
<div class="stat-chip-dot" style="background:#9ca3af"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">已撤回</span>
<span class="stat-chip-num" style="color:#9ca3af">{{ withdrawnApps }}</span>
</div>
</div>
</div>
</div>
<!-- 项目管理 -->
<div class="card">
<div class="card-head">
<div class="card-icon" style="--c:#059669;--bg:#ecfdf5"><Briefcase class="w-4 h-4" /></div>
<h2 class="card-title">项目管理</h2>
<button class="card-link" @click="router.push('/user/tasks/my')">全部项目 <ArrowRight class="w-3 h-3" /></button>
</div>
<div class="grid grid-cols-4 gap-3">
<div class="stat-chip" @click="router.push({ path: '/user/tasks/my', query: { tab: 'APPROVED' } })">
<div class="stat-chip-dot" style="background:#10b981"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">执行中</span>
<span class="stat-chip-num" style="color:#10b981">{{ approvedApps }}</span>
</div>
</div>
<div class="stat-chip" @click="router.push({ path: '/user/tasks/my', query: { tab: 'DELIVERED' } })">
<div class="stat-chip-dot" style="background:#8b5cf6"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">待验收</span>
<span class="stat-chip-num" style="color:#8b5cf6">{{ deliveredApps }}</span>
</div>
</div>
<div class="stat-chip" @click="router.push({ path: '/user/tasks/my', query: { tab: 'COMPLETED' } })">
<div class="stat-chip-dot" style="background:#059669"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">已完成</span>
<span class="stat-chip-num" style="color:#059669">{{ completedApps }}</span>
</div>
</div>
<div class="stat-chip stat-chip--danger" @click="router.push({ path: '/user/tasks/my', query: { tab: 'CANCELLED' } })">
<div class="stat-chip-dot" style="background:#ef4444"></div>
<div class="stat-chip-body">
<span class="stat-chip-label">已取消</span>
<span class="stat-chip-num" style="color:#ef4444">{{ cancelledApps }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- ===== Quick Links + Recommended ===== -->
<div class="grid grid-cols-4 gap-4 mt-5">
<div class="qlink" @click="router.push('/user/tasks/market')">
<div class="qlink-icon" style="background:#fef3c7;color:#d97706"><ShoppingBag class="w-5 h-5" /></div>
<span class="qlink-name">接单大厅</span>
<span class="qlink-sub">{{ openTasks }} 个可接任务</span>
</div>
<div class="qlink" @click="router.push('/user/invitations')">
<div class="qlink-icon" style="background:#fce7f3;color:#db2777"><Mail class="w-5 h-5" /></div>
<span class="qlink-name">邀请我的</span>
<span class="qlink-sub">定向邀约</span>
</div>
<div class="qlink" @click="router.push('/user/tasks/my')">
<div class="qlink-icon" style="background:#ecfdf5;color:#059669"><ClipboardList class="w-5 h-5" /></div>
<span class="qlink-name">我的项目</span>
<span class="qlink-sub">{{ myProjectCount }} 个进行中</span>
</div>
<div class="qlink" @click="router.push('/user/applications')">
<div class="qlink-icon" style="background:#eff6ff;color:#3b82f6"><Send class="w-5 h-5" /></div>
<span class="qlink-name">我的申请</span>
<span class="qlink-sub">{{ pendingApps + approvedApps + rejectedApps }} 条记录</span>
</div>
</div>
<!-- ===== Bottom: Announcements + Recommended + Activity ===== -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-5 mt-5">
<!-- Announcements -->
<div class="lg:col-span-5 card">
<div class="card-head">
<div class="card-icon" style="--c:#7c3aed;--bg:#ede9fe"><Newspaper class="w-4 h-4" /></div>
<h2 class="card-title">平台公告</h2>
<div v-if="totalAnnPages > 1" class="flex items-center gap-1 ml-auto">
<button @click.stop="prevAnn" class="ann-btn"></button>
<span class="text-[11px] text-gray-400 tabular-nums w-9 text-center">{{ currentAnnIndex + 1 }}/{{ totalAnnPages }}</span>
<button @click.stop="nextAnn" class="ann-btn"></button>
</div>
</div>
<div v-if="visibleAnns.length" class="space-y-3">
<div v-for="ann in visibleAnns" :key="ann.id"
class="ann-card" @click="openAnnDetail(ann)">
<img v-if="ann.cover_url" :src="ann.cover_url" class="ann-cover" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<el-tag size="small" :type="ann.target_audience === 'USER' ? 'primary' : 'info'" effect="light" round>
{{ ann.target_audience === 'USER' ? '用户' : '全平台' }}
</el-tag>
<span class="text-[11px] text-gray-400">{{ new Date(ann.created_at).toLocaleDateString() }}</span>
</div>
<h3 class="text-sm font-bold text-gray-800 truncate">{{ ann.title }}</h3>
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ ann.content?.replace(/[#*`_~>\-\[\]()!]/g, '').substring(0, 60) }}</p>
</div>
<ArrowRight class="w-3.5 h-3.5 text-gray-300 flex-shrink-0 self-center" />
</div>
</div>
<el-empty v-else description="暂无公告" :image-size="50" />
</div>
<!-- Recommended Tasks -->
<div class="lg:col-span-4 card">
<div class="card-head">
<div class="card-icon" style="--c:#d97706;--bg:#fef3c7"><Sparkles class="w-4 h-4" /></div>
<h2 class="card-title">推荐任务</h2>
<button class="card-link" @click="router.push('/user/tasks/market')">更多 <ArrowRight class="w-3 h-3" /></button>
</div>
<div v-if="recommendedTasks.length" class="space-y-1">
<div v-for="task in recommendedTasks" :key="task.id"
class="rec-item" @click="router.push(`/user/tasks/${task.id}`)">
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-gray-700 truncate">{{ task.title }}</div>
<div class="text-xs text-gray-400 mt-0.5">¥{{ task.budget_min?.toLocaleString() || 0 }} - {{ task.budget_max?.toLocaleString() || 0 }}</div>
</div>
<ArrowRight class="w-3.5 h-3.5 text-gray-300 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<el-empty v-else description="暂无推荐" :image-size="50" />
</div>
<!-- Activity -->
<div class="lg:col-span-3 card">
<div class="card-head">
<div class="card-icon" style="--c:#d97706;--bg:#fef3c7"><History class="w-4 h-4" /></div>
<h2 class="card-title">最近动态</h2>
</div>
<div v-if="recentActivities.length" class="space-y-1">
<div v-for="act in recentActivities" :key="act.id"
class="act-item" @click="router.push(`/user/tasks/${act.taskId}`)">
<div class="act-dot"
:class="act.status === 'APPROVED' ? 'bg-emerald-400' : act.status === 'PENDING' ? 'bg-yellow-400' : act.status === 'REJECTED' ? 'bg-red-400' : 'bg-blue-400'">
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-gray-700 truncate">{{ act.taskName }}</div>
<div class="text-[10px] text-gray-400">{{ act.timeAgo }}</div>
</div>
<el-tag :type="(statusType(act.status) as any)" size="small" effect="light" round class="!text-[10px]">{{ statusLabel(act.status) }}</el-tag>
</div>
</div>
<el-empty v-else description="暂无动态" :image-size="50" />
</div>
</div>
<!-- Announcement Detail -->
<el-dialog v-model="annDetailVisible" :title="selectedAnn?.title" width="700px" top="5vh">
<div v-if="selectedAnn" class="space-y-4">
<div class="flex items-center gap-3 text-sm text-gray-400">
<el-tag size="small" type="primary">{{ selectedAnn.target_audience === 'USER' ? '用户专属' : '综合公告' }}</el-tag>
<span class="flex items-center gap-1"><Calendar class="w-3 h-3" />{{ new Date(selectedAnn.created_at).toLocaleDateString() }}</span>
</div>
<div v-if="selectedAnn.cover_url" class="rounded-lg overflow-hidden">
<img :src="selectedAnn.cover_url" class="w-full max-h-64 object-cover" />
</div>
<div class="bg-gray-50 rounded-xl p-6 border border-gray-100 min-h-[200px]">
<MdPreview :modelValue="selectedAnn.content" />
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.dash { max-width: 100%; }
/* Card */
.card {
background: #fff;
border-radius: 16px;
padding: 18px 22px;
border: 1px solid #f0f0f0;
transition: box-shadow 0.2s;
}
.card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.03); }
.card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.card-icon {
width: 30px; height: 30px;
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
background: var(--bg);
color: var(--c);
flex-shrink: 0;
}
.card-title {
font-size: 14px;
font-weight: 700;
color: #1f2937;
flex: 1;
}
.card-link {
font-size: 12px;
color: #3b82f6;
font-weight: 500;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
transition: color 0.15s;
flex-shrink: 0;
}
.card-link:hover { color: #2563eb; }
/* Stat Chip */
.stat-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 12px;
background: #fafbfc;
border: 1px solid #f0f1f3;
cursor: pointer;
transition: all 0.2s;
}
.stat-chip:hover {
background: #f0f7ff;
border-color: #c7dafb;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59,130,246,0.06);
}
.stat-chip--danger:hover {
background: #fef2f2;
border-color: #fecaca;
box-shadow: 0 4px 12px rgba(239,68,68,0.06);
}
.stat-chip-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.stat-chip-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-chip-label {
font-size: 11px;
color: #9ca3af;
font-weight: 500;
line-height: 1;
}
.stat-chip-num {
font-size: 20px;
font-weight: 900;
line-height: 1;
}
/* Quick Links */
.qlink {
display: flex;
flex-direction: column;
align-items: center;
padding: 18px 10px 14px;
border-radius: 14px;
background: #fff;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.qlink:hover {
border-color: #e0e7ff;
background: #fafbff;
transform: translateY(-2px);
box-shadow: 0 6px 20px -4px rgba(99,102,241,0.08);
}
.qlink-icon {
width: 42px; height: 42px;
border-radius: 13px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 10px;
}
.qlink-name {
font-size: 13px;
font-weight: 700;
color: #374151;
}
.qlink-sub {
font-size: 11px;
color: #9ca3af;
margin-top: 2px;
}
/* Recommended */
.rec-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
}
.rec-item:hover { background: #f9fafb; }
/* Activity */
.act-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 6px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.act-item:hover { background: #f9fafb; }
.act-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
/* Announcement nav */
.ann-btn {
width: 22px; height: 22px;
border-radius: 6px;
border: 1px solid #e5e7eb;
background: #fff;
color: #6b7280;
display: flex; align-items: center; justify-content: center;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.ann-btn:hover { background: #f3f4f6; border-color: #d1d5db; }
/* Announcement card */
.ann-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
}
.ann-card:hover {
background: #f9fafb;
border-color: #f0f0f0;
}
.ann-cover {
width: 72px;
height: 50px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
border: 1px solid #f0f0f0;
}
</style>