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

502 lines
19 KiB
Vue
Raw Normal View History

<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>