feat(project): 支持项目详情页面及相关类型定义
Some checks failed
Lint Code / Lint Code (push) Failing after 23m5s

- 将项目相关ID类型统一由number改为string,增强一致性
- 新增项目成员、里程碑、任务、资源、风险、时间线节点等详细类型定义
- 添加获取项目详情接口方法getProjectDetail
- 在路由中新增项目详情页路由配置
- 实现项目详情页面,支持展示基本信息、成员、任务、风险及资源等数据
- 项目详情页面集成AI助手简易聊天交互功能展示
- 添加项目状态、风险等级及资源状态的辅助文本和样式方法
- 优化甘特图任务条样式计算,基于项目详情任务数据展现
This commit is contained in:
2026-03-28 18:37:03 +08:00
parent c4509b42fa
commit cfa3a57a57
6 changed files with 1641 additions and 15 deletions

View File

@@ -0,0 +1,865 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import {
getProjectDetail,
type ProjectDetail,
type ProjectMember,
type ProjectMilestone,
type ProjectTask,
type ProjectResource,
type ProjectRisk
} from "@/api/project";
import { message } from "@/utils/message";
import dayjs from "dayjs";
import ArrowLeftIcon from "~icons/ri/arrow-left-line";
import DownloadIcon from "~icons/ri/download-line";
import EditIcon from "~icons/ri/edit-line";
import UserIcon from "~icons/ri/user-line";
import AlertIcon from "~icons/ri/alert-line";
import RobotIcon from "~icons/ri/robot-2-line";
import RefreshIcon from "~icons/ri/refresh-line";
import FullscreenIcon from "~icons/ri/fullscreen-line";
import FileListIcon from "~icons/ri/file-list-line";
import ArrowRightIcon from "~icons/ri/arrow-right-s-line";
import CheckIcon from "~icons/ri/check-line";
defineOptions({
name: "ProjectDetail"
});
const route = useRoute();
const router = useRouter();
const projectId = ref<string>(route.params.id as string);
// 加载状态
const loading = ref(false);
const ganttLoading = ref(false);
// 项目详情数据
const projectDetail = ref<ProjectDetail | null>(null);
// 项目基本信息(计算属性)
const projectInfo = computed(() => {
const data = projectDetail.value;
return {
id: data?.id || projectId.value,
projectName: data?.projectName || "",
projectCode: data?.projectCode || "",
status: data?.status || "ongoing",
statusText: getStatusText(data?.status),
startDate: data?.planStartDate || "",
endDate: data?.planEndDate || "",
progress: data?.progress || 0,
progressChange: 0,
budget: data?.budget || 0,
cost: data?.cost || 0,
teamSize: data?.memberCount || 0,
riskLevel: data?.riskLevel || "low",
riskText: getRiskText(data?.riskLevel),
riskCount: data?.riskCount || 0,
description: data?.description || "",
objectives: data?.objectives || ""
};
});
// 甘特图数据(使用任务列表)
// AI助手消息
const aiMessages = ref([
{
type: "ai",
content:
"您好我是项目AI助手我可以帮您分析项目进度和流程卡点。请问有什么可以帮助您的",
time: "上午 9:30"
},
{
type: "user",
content: "当前项目进度如何?有没有什么风险点需要注意?",
time: "上午 9:32"
},
{
type: "ai",
content: `**项目进度分析:** 当前项目整体进度为68%比计划进度超前5%。
**关键风险点:**
• 玻璃幕墙材料尚未发货,可能影响后续安装工序
• 水电安装工序存在资源调配问题,可能导致延期
• 室内装修团队人力不足建议增加2名熟练工人
**建议措施:** 优先跟进玻璃幕墙供应商发货情况,协调水电安装资源,考虑从其他项目临时调配装修工人。`,
time: "上午 9:33"
}
]);
const aiInput = ref("");
// 资源清单数据从API获取
const resourceList = computed(() => {
return projectDetail.value?.resources || [];
});
// 风险列表数据从API获取
const riskList = computed(() => {
return projectDetail.value?.risks || [];
});
// 成员列表数据从API获取
const memberList = computed(() => {
return projectDetail.value?.members || [];
});
// 里程碑列表数据从API获取
const milestoneList = computed(() => {
return projectDetail.value?.milestones || [];
});
// 任务列表数据从API获取用于甘特图
const taskList = computed(() => {
return projectDetail.value?.tasks || [];
});
// 返回上一页
function goBack() {
router.push("/project");
}
// 获取甘特图数据(现在使用项目详情中的任务数据)
async function fetchGanttData() {
// 任务数据已从项目详情API获取无需单独请求
ganttLoading.value = true;
try {
// 模拟加载延迟
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error("获取甘特图数据失败:", error);
} finally {
ganttLoading.value = false;
}
}
// 发送AI消息
function sendAiMessage() {
if (!aiInput.value.trim()) return;
const userMsg = aiInput.value.trim();
aiMessages.value.push({
type: "user",
content: userMsg,
time: dayjs().format("HH:mm")
});
aiInput.value = "";
// 模拟AI回复
setTimeout(() => {
aiMessages.value.push({
type: "ai",
content: "收到您的问题,我正在分析项目数据,请稍候...",
time: dayjs().format("HH:mm")
});
}, 500);
}
// 获取状态标签类型
function getStatusType(
status?: string
): "success" | "warning" | "info" | "primary" | "danger" {
switch (status) {
case "completed":
return "success";
case "ongoing":
return "primary";
case "paused":
return "warning";
case "cancelled":
return "danger";
default:
return "info";
}
}
// 获取物料状态类型
function getMaterialStatusType(
status: string
): "success" | "warning" | "info" | "primary" | "danger" {
switch (status) {
case "arrived":
return "success";
case "pending":
return "info";
case "delayed":
return "danger";
default:
return "info";
}
}
// 计算甘特图任务条样式(使用真实任务数据)
function getTaskBarStyle(task: ProjectTask) {
if (!task.planStartDate || !task.planEndDate) return {};
const start = dayjs(task.planStartDate);
const end = dayjs(task.planEndDate);
const projectStart = dayjs(projectInfo.value.startDate);
const projectEnd = dayjs(projectInfo.value.endDate);
if (!projectStart.isValid() || !projectEnd.isValid()) return {};
const totalDays = projectEnd.diff(projectStart, "day");
const offsetDays = start.diff(projectStart, "day");
const duration = end.diff(start, "day") + 1;
return {
left: `${(offsetDays / totalDays) * 100}%`,
width: `${(duration / totalDays) * 100}%`
};
}
// 获取项目详情
async function fetchProjectDetail() {
if (!projectId.value) return;
loading.value = true;
try {
const res = await getProjectDetail(projectId.value);
const result = res as any;
if (result.code === 200 && result.data) {
projectDetail.value = result.data;
}
} catch (error) {
console.error("获取项目详情失败:", error);
message("获取项目详情失败", { type: "error" });
} finally {
loading.value = false;
}
}
// 获取状态文本
function getStatusText(status?: string): string {
const statusMap: Record<string, string> = {
ongoing: "进行中",
completed: "已完成",
paused: "已暂停",
cancelled: "已取消",
planning: "规划中",
draft: "草稿"
};
return statusMap[status || ""] || "未知";
}
// 获取风险文本
function getRiskText(risk?: string): string {
const riskMap: Record<string, string> = {
low: "低",
medium: "中等",
high: "高"
};
return riskMap[risk || ""] || "低";
}
// 获取角色文本
function getRoleText(roleCode?: string): string {
const roleMap: Record<string, string> = {
manager: "项目经理",
leader: "负责人",
member: "成员",
sponsor: "发起人"
};
return roleMap[roleCode || ""] || roleCode || "成员";
}
// 获取资源类型文本
function getResourceTypeText(type?: string): string {
const typeMap: Record<string, string> = {
equipment: "设备",
material: "物料",
human: "人力",
service: "服务"
};
return typeMap[type || ""] || type || "其他";
}
// 获取资源状态类型
function getResourceStatusType(
status?: string
): "success" | "warning" | "info" | "primary" | "danger" {
switch (status) {
case "arrived":
case "actual":
return "success";
case "planned":
return "info";
case "delayed":
return "danger";
default:
return "info";
}
}
// 获取资源状态文本
function getResourceStatusText(status?: string): string {
const statusMap: Record<string, string> = {
arrived: "已到货",
actual: "实际",
planned: "计划中",
delayed: "延期"
};
return statusMap[status || ""] || status || "计划中";
}
// 获取风险等级标签类型
function getRiskLevelType(
level?: string
): "success" | "warning" | "danger" | "info" {
switch (level) {
case "low":
return "success";
case "medium":
return "warning";
case "high":
return "danger";
default:
return "info";
}
}
onMounted(() => {
fetchProjectDetail();
fetchGanttData();
});
</script>
<template>
<div class="project-detail w-full">
<!-- 顶部导航 -->
<div class="flex-bc mb-4">
<div class="flex items-center gap-3">
<el-button link @click="goBack">
<component :is="useRenderIcon(ArrowLeftIcon)" />
</el-button>
<div>
<div class="flex items-center gap-2">
<h2 class="text-xl font-bold">{{ projectInfo.projectName }}</h2>
<el-tag :type="getStatusType(projectInfo.status)" size="small">
{{ projectInfo.statusText }}
</el-tag>
</div>
<p class="text-gray-500 text-sm mt-1">
项目编号: {{ projectInfo.projectCode }} | 开始日期:
{{ projectInfo.startDate }} | 预计结束日期:
{{ projectInfo.endDate }}
</p>
</div>
</div>
<div class="flex gap-2">
<el-button>
<template #icon>
<component :is="useRenderIcon(DownloadIcon)" />
</template>
导出报告
</el-button>
<el-button type="primary">
<template #icon>
<component :is="useRenderIcon(EditIcon)" />
</template>
编辑项目
</el-button>
</div>
</div>
<!-- 统计卡片 -->
<el-row :gutter="16" class="mb-4">
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<div class="flex-bc">
<div>
<p class="text-gray-500 text-sm">项目进度</p>
<p class="text-2xl font-bold mt-1">{{ projectInfo.progress }}%</p>
<p class="text-xs text-green-500 mt-1">
<el-icon
><component :is="useRenderIcon('ri/arrow-up-line')"
/></el-icon>
{{ projectInfo.progressChange }}%
</p>
</div>
<div class="stat-icon bg-blue-100">
<el-icon :size="24" color="#409eff">
<component :is="useRenderIcon('ri/bar-chart-line')" />
</el-icon>
</div>
</div>
<el-progress
:percentage="projectInfo.progress"
:status="projectInfo.progress === 100 ? 'success' : ''"
class="mt-3"
/>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<div class="flex-bc">
<div>
<p class="text-gray-500 text-sm">项目预算</p>
<p class="text-2xl font-bold mt-1">¥{{ projectInfo.cost }}</p>
<p class="text-xs text-gray-400 mt-1">
/ ¥{{ projectInfo.budget }}
</p>
</div>
<div class="stat-icon bg-orange-100">
<el-icon :size="24" color="#e6a23c">
<component :is="useRenderIcon('ri/money-cny-circle-line')" />
</el-icon>
</div>
</div>
<el-progress
:percentage="
Math.round((projectInfo.cost / projectInfo.budget) * 100)
"
status="warning"
class="mt-3"
/>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<div class="flex-bc">
<div>
<p class="text-gray-500 text-sm">团队成员</p>
<p class="text-2xl font-bold mt-1">
{{ projectInfo.teamSize }}
</p>
<p class="text-xs text-gray-400 mt-1">项目参与人员</p>
</div>
<div class="stat-icon bg-green-100">
<el-icon :size="24" color="#67c23a">
<component :is="useRenderIcon(UserIcon)" />
</el-icon>
</div>
</div>
<div class="flex -space-x-2 mt-3">
<el-avatar
v-for="member in memberList.slice(0, 4)"
:key="member.id"
:size="28"
:src="
member.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${member.id}`
"
:title="getRoleText(member.roleCode)"
/>
<el-avatar
v-if="memberList.length > 4"
:size="28"
class="bg-gray-200"
>
+{{ memberList.length - 4 }}
</el-avatar>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card shadow="hover" class="stat-card">
<div class="flex-bc">
<div>
<p class="text-gray-500 text-sm">风险等级</p>
<p class="text-2xl font-bold mt-1 text-orange-500">
{{ projectInfo.riskText }}
</p>
<p class="text-xs text-orange-400 mt-1">
<component :is="useRenderIcon(AlertIcon)" class="inline" />
{{ projectInfo.riskCount }}个潜在风险需要关注
</p>
</div>
<div class="stat-icon bg-orange-100">
<el-icon :size="24" color="#e6a23c">
<component :is="useRenderIcon(AlertIcon)" />
</el-icon>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 主要内容区 -->
<el-row :gutter="16">
<!-- 左侧甘特图和物料清单 -->
<el-col :xs="24" :lg="16">
<!-- 甘特图 -->
<el-card shadow="hover" class="mb-4">
<template #header>
<div class="flex-bc">
<span class="font-medium">项目进度甘特图</span>
<div class="flex gap-2">
<el-button link @click="fetchGanttData">
<component :is="useRenderIcon(RefreshIcon)" />
</el-button>
<el-button link>
<component :is="useRenderIcon(FullscreenIcon)" />
</el-button>
</div>
</div>
</template>
<div v-loading="ganttLoading" class="gantt-container">
<!-- 图例 -->
<div class="flex gap-4 mb-4 text-sm">
<div class="flex items-center gap-1">
<div class="w-4 h-3 bg-blue-400 rounded-sm" />
<span>计划进度</span>
</div>
<div class="flex items-center gap-1">
<div class="w-4 h-3 bg-green-400 rounded-sm" />
<span>实际进度</span>
</div>
</div>
<!-- 甘特图主体 -->
<div class="gantt-chart">
<!-- 时间轴 -->
<div class="gantt-timeline">
<div
v-for="month in 6"
:key="month"
class="timeline-item"
:style="{ left: `${(month - 1) * (100 / 6)}%` }"
>
{{
dayjs(projectInfo.startDate)
.add(month - 1, "month")
.format("YYYY-MM")
}}
</div>
</div>
<!-- 任务列表 -->
<div class="gantt-tasks">
<div
v-for="task in taskList.slice().reverse()"
:key="task.id"
class="gantt-task-row"
>
<div class="task-name">{{ task.taskName }}</div>
<div class="task-bar-container">
<div
class="task-bar"
:style="getTaskBarStyle(task)"
:class="{ completed: task.progress === 100 }"
>
<div
class="task-progress"
:style="{ width: `${task.progress || 0}%` }"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</el-card>
<!-- 物料清单 -->
<el-card shadow="hover">
<template #header>
<div class="flex-bc">
<div class="flex items-center gap-2">
<component :is="useRenderIcon(FileListIcon)" />
<span class="font-medium">项目物料清单</span>
</div>
<el-button link type="primary">
查看全部
<component :is="useRenderIcon(ArrowRightIcon)" />
</el-button>
</div>
</template>
<el-table :data="resourceList" style="width: 100%">
<el-table-column label="资源名称" min-width="200">
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-avatar :size="32" shape="square" class="bg-gray-100">
<component :is="useRenderIcon('ri/box-line')" />
</el-avatar>
<div>
<div class="font-medium">{{ row.resourceName }}</div>
<div class="text-xs text-gray-400">
{{ getResourceTypeText(row.resourceType) }}
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="规格" prop="specification" width="120" />
<el-table-column label="数量" width="100">
<template #default="{ row }">
{{ row.planQuantity }} {{ row.unit }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getResourceStatusType(row.status)" size="small">
{{ getResourceStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="预计到货" width="120">
<template #default="{ row }">
<div class="flex items-center gap-1">
<span>{{ row.planArriveDate || "--" }}</span>
<el-icon
v-if="row.status === 'arrived' || row.status === 'actual'"
color="#67c23a"
class="ml-1"
>
<component :is="useRenderIcon(CheckIcon)" />
</el-icon>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default>
<el-button link type="primary" size="small">详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- 右侧AI助手 -->
<el-col :xs="24" :lg="8">
<el-card shadow="hover" class="ai-card">
<template #header>
<div class="flex items-center gap-2">
<el-icon :size="20" color="#409eff">
<component :is="useRenderIcon(RobotIcon)" />
</el-icon>
<span class="font-medium">项目AI助手</span>
</div>
<p class="text-xs text-gray-400 mt-1">分析项目进度和流程卡点</p>
</template>
<!-- 消息列表 -->
<div class="ai-messages">
<div
v-for="(msg, index) in aiMessages"
:key="index"
class="message-item"
:class="msg.type"
>
<div class="message-avatar">
<el-avatar
v-if="msg.type === 'ai'"
:size="32"
class="bg-blue-500"
>
<component :is="useRenderIcon(RobotIcon)" />
</el-avatar>
<el-avatar
v-else
:size="32"
:src="'https://api.dicebear.com/7.x/avataaars/svg?seed=user'"
/>
</div>
<div class="message-content">
<div class="message-bubble" v-html="msg.content" />
<div class="message-time">{{ msg.time }}</div>
</div>
</div>
</div>
<!-- 快捷问题 -->
<div class="quick-questions">
<el-button
size="small"
@click="
aiInput = '帮我分析一下结构施工阶段的流程卡点';
sendAiMessage();
"
>
帮我分析一下结构施工阶段的流程卡点
</el-button>
</div>
<!-- 输入框 -->
<div class="ai-input-area">
<el-input
v-model="aiInput"
placeholder="请输入您的问题..."
@keyup.enter="sendAiMessage"
>
<template #append>
<el-button type="primary" @click="sendAiMessage">
发送
</el-button>
</template>
</el-input>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped lang="scss">
.project-detail {
padding: 16px;
}
.stat-card {
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 8px;
}
}
// 甘特图样式
.gantt-container {
min-height: 300px;
}
.gantt-chart {
position: relative;
}
.gantt-timeline {
position: relative;
height: 30px;
margin-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
.timeline-item {
position: absolute;
font-size: 12px;
color: #909399;
transform: translateX(-50%);
}
}
.gantt-tasks {
.gantt-task-row {
display: flex;
align-items: center;
height: 40px;
border-bottom: 1px solid #f0f2f5;
&:last-child {
border-bottom: none;
}
.task-name {
flex-shrink: 0;
width: 100px;
font-size: 13px;
color: #606266;
}
.task-bar-container {
position: relative;
display: flex;
flex: 1;
align-items: center;
height: 100%;
}
.task-bar {
position: absolute;
height: 20px;
overflow: hidden;
background-color: #e6f2ff;
border: 1px solid #409eff;
border-radius: 4px;
&.completed {
background-color: #e6f7e6;
border-color: #67c23a;
}
.task-progress {
height: 100%;
background-color: #409eff;
transition: width 0.3s ease;
.completed & {
background-color: #67c23a;
}
}
}
}
}
// AI助手样式
.ai-card {
display: flex;
flex-direction: column;
height: calc(100vh - 280px);
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
padding: 0;
}
}
.ai-messages {
flex: 1;
padding: 16px;
overflow-y: auto;
.message-item {
display: flex;
gap: 8px;
margin-bottom: 16px;
&.user {
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.message-bubble {
color: white;
background-color: #409eff;
}
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 80%;
}
.message-bubble {
padding: 10px 14px;
font-size: 13px;
line-height: 1.5;
overflow-wrap: break-word;
background-color: #f5f7fa;
border-radius: 12px;
:deep(strong) {
font-weight: 600;
}
}
.message-time {
font-size: 11px;
color: #909399;
}
}
}
.quick-questions {
padding: 8px 16px;
border-top: 1px solid #e4e7ed;
.el-button {
font-size: 12px;
}
}
.ai-input-area {
padding: 12px 16px;
border-top: 1px solid #e4e7ed;
}
</style>