feat(project): 重构甘特图为vue-ganttastic组件并更新项目统计卡片
Some checks failed
Lint Code / Lint Code (push) Failing after 1m35s

- 替换原有甘特图任务条样式计算,改用vue-ganttastic库渲染任务条和里程碑
- 根据任务状态和进度生成不同颜色,支持关键里程碑标识
- 实现甘特图日期范围计算及任务、里程碑数据格式转换
- 优化甘特图和里程碑的布局与样式,提升交互体验
- 移除右侧项目AI助手相关代码及样式
- 项目首页统计卡片文字和图标调整,展示项目总数、进行中、已完成等状态
- 平均进度条样式优化,补充高风险项目数量显示
This commit is contained in:
2026-03-28 19:22:56 +08:00
parent cfa3a57a57
commit 31627b95c0
2 changed files with 364 additions and 295 deletions

View File

@@ -11,6 +11,7 @@ import {
type ProjectResource, type ProjectResource,
type ProjectRisk type ProjectRisk
} from "@/api/project"; } from "@/api/project";
import { GGanttChart, GGanttRow } from "@infectoone/vue-ganttastic";
import { message } from "@/utils/message"; import { message } from "@/utils/message";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -195,23 +196,102 @@ function getMaterialStatusType(
} }
} }
// 计算甘特图任务条样式(使用真实任务数据) // 将任务数据转换为 vue-ganttastic 格式
function getTaskBarStyle(task: ProjectTask) { const ganttTasks = computed(() => {
if (!task.planStartDate || !task.planEndDate) return {}; return taskList.value.map(task => ({
const start = dayjs(task.planStartDate); id: task.id,
const end = dayjs(task.planEndDate); label: task.taskName,
const projectStart = dayjs(projectInfo.value.startDate); startDate: task.planStartDate || "",
const projectEnd = dayjs(projectInfo.value.endDate); endDate: task.planEndDate || "",
if (!projectStart.isValid() || !projectEnd.isValid()) return {}; progress: task.progress || 0,
const totalDays = projectEnd.diff(projectStart, "day"); assignee: task.assigneeName || "未分配",
const offsetDays = start.diff(projectStart, "day"); status: task.status || "pending",
const duration = end.diff(start, "day") + 1; ganttBarConfig: {
id: task.id,
return { label: `${task.taskName} ${task.assigneeName ? "负责人:" + task.assigneeName : ""}`,
left: `${(offsetDays / totalDays) * 100}%`, hasHandles: false,
width: `${(duration / totalDays) * 100}%` style: {
}; backgroundColor: getTaskColor(task.status, task.progress),
color: "#fff"
} }
}
}));
});
// 获取任务颜色
function getTaskColor(status?: string, progress?: number): string {
if (progress === 100) return "#67c23a"; // 已完成 - 绿色
if (status === "completed") return "#67c23a"; // 已完成 - 绿色
if (status === "in_progress" || status === "ongoing") return "#409eff"; // 进行中 - 蓝色
if (status === "pending") return "#e6a23c"; // 待开始 - 橙色
if (status === "delayed") return "#f56c6c"; // 延期 - 红色
if (status === "paused") return "#909399"; // 暂停 - 灰色
return "#409eff"; // 默认 - 蓝色
}
// 将里程碑数据转换为 vue-ganttastic 格式
const ganttMilestones = computed(() => {
return milestoneList.value.map(milestone => ({
id: milestone.id,
label: milestone.milestoneName,
startDate: milestone.planDate || "",
endDate: milestone.planDate || "",
status: milestone.status || "pending",
isKey: milestone.isKey === 1,
ganttBarConfig: {
id: milestone.id,
label: `${milestone.milestoneName} ${milestone.isKey === 1 ? "【关键】" : ""}`,
hasHandles: false,
style: {
backgroundColor: getMilestoneColor(milestone.status, milestone.isKey),
color: "#fff",
borderRadius: "50%",
width: "16px",
height: "16px",
minWidth: "16px"
}
}
}));
});
// 获取里程碑颜色
function getMilestoneColor(status?: string, isKey?: number): string {
if (status === "completed") return "#67c23a"; // 已完成 - 绿色
if (status === "in_progress") return "#409eff"; // 进行中 - 蓝色
if (isKey === 1) return "#f56c6c"; // 关键里程碑 - 红色
return "#e6a23c"; // 默认 - 橙色
}
// 获取里程碑状态文本
function getMilestoneStatusText(status?: string): string {
const statusMap: Record<string, string> = {
completed: "已完成",
in_progress: "进行中",
pending: "待开始",
delayed: "已延期"
};
return statusMap[status || ""] || "待开始";
}
// 按日期排序的里程碑列表
const sortedMilestones = computed(() => {
return [...milestoneList.value].sort((a, b) => {
const dateA = new Date(a.planDate || "").getTime();
const dateB = new Date(b.planDate || "").getTime();
return dateA - dateB;
});
});
// 计算甘特图日期范围
const ganttDateRange = computed(() => {
const start = projectInfo.value.startDate || dayjs().format("YYYY-MM-DD");
const end =
projectInfo.value.endDate || dayjs().add(6, "month").format("YYYY-MM-DD");
return {
start: dayjs(start).format("YYYY-MM-DD"),
end: dayjs(end).format("YYYY-MM-DD")
};
});
// 获取项目详情 // 获取项目详情
async function fetchProjectDetail() { async function fetchProjectDetail() {
@@ -410,7 +490,9 @@ onMounted(() => {
</div> </div>
<el-progress <el-progress
:percentage=" :percentage="
Math.round((projectInfo.cost / projectInfo.budget) * 100) projectInfo.budget > 0
? Math.round((projectInfo.cost / projectInfo.budget) * 100)
: 0
" "
status="warning" status="warning"
class="mt-3" class="mt-3"
@@ -480,7 +562,7 @@ onMounted(() => {
<!-- 主要内容区 --> <!-- 主要内容区 -->
<el-row :gutter="16"> <el-row :gutter="16">
<!-- 左侧甘特图和物料清单 --> <!-- 左侧甘特图和物料清单 -->
<el-col :xs="24" :lg="16"> <el-col :xs="24" :lg="24">
<!-- 甘特图 --> <!-- 甘特图 -->
<el-card shadow="hover" class="mb-4"> <el-card shadow="hover" class="mb-4">
<template #header> <template #header>
@@ -509,42 +591,115 @@ onMounted(() => {
</div> </div>
</div> </div>
<!-- 甘特图主体 --> <!-- 任务甘特图主体 - 使用 vue-ganttastic -->
<div class="gantt-chart"> <div v-if="taskList.length > 0" class="gantt-chart-container mb-6">
<!-- 时间轴 --> <g-gantt-chart
<div class="gantt-timeline"> :chart-start="ganttDateRange.start"
<div :chart-end="ganttDateRange.end"
v-for="month in 6" precision="day"
:key="month" date-format="YYYY-MM-DD"
class="timeline-item" bar-start="startDate"
:style="{ left: `${(month - 1) * (100 / 6)}%` }" bar-end="endDate"
grid
highlight-on-hover
> >
{{ <g-gantt-row
dayjs(projectInfo.startDate) v-for="task in ganttTasks"
.add(month - 1, "month")
.format("YYYY-MM")
}}
</div>
</div>
<!-- 任务列表 -->
<div class="gantt-tasks">
<div
v-for="task in taskList.slice().reverse()"
:key="task.id" :key="task.id"
class="gantt-task-row" :bars="[task]"
> :label="task.label"
<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}%` }"
/> />
</g-gantt-chart>
</div>
<el-empty v-else description="暂无任务数据" class="mb-6" />
<!-- 里程碑时间线 -->
<div v-if="milestoneList.length > 0" class="milestone-section">
<div class="flex items-center gap-2 mb-4">
<el-icon :size="18" color="#f56c6c">
<component :is="useRenderIcon('ri/flag-line')" />
</el-icon>
<span class="font-medium text-base">项目里程碑</span>
<el-tag size="small" type="info"
>{{ milestoneList.length }} </el-tag
>
</div>
<div class="milestone-timeline">
<div
v-for="(milestone, index) in sortedMilestones"
:key="milestone.id"
class="milestone-item"
:class="{
'is-completed': milestone.status === 'completed',
'is-key': milestone.isKey === 1
}"
>
<div class="milestone-marker">
<div
class="milestone-dot"
:style="{
backgroundColor: getMilestoneColor(
milestone.status,
milestone.isKey
)
}"
>
<el-icon
v-if="milestone.status === 'completed'"
:size="12"
color="#fff"
>
<component :is="useRenderIcon('ri/check-line')" />
</el-icon>
<el-icon
v-else-if="milestone.isKey === 1"
:size="12"
color="#fff"
>
<component :is="useRenderIcon('ri/star-fill')" />
</el-icon>
</div>
<div
v-if="index < sortedMilestones.length - 1"
class="milestone-line"
/>
</div>
<div class="milestone-content">
<div class="milestone-header">
<span class="milestone-name">{{
milestone.milestoneName
}}</span>
<el-tag
size="small"
:type="
milestone.status === 'completed'
? 'success'
: milestone.status === 'in_progress'
? 'primary'
: 'info'
"
>
{{ getMilestoneStatusText(milestone.status) }}
</el-tag>
<el-tag
v-if="milestone.isKey === 1"
size="small"
type="danger"
effect="dark"
>关键</el-tag
>
</div>
<div class="milestone-date">
<el-icon :size="12"
><component :is="useRenderIcon('ri/calendar-line')"
/></el-icon>
<span>计划日期: {{ milestone.planDate }}</span>
<span v-if="milestone.actualDate" class="actual-date">
(实际: {{ milestone.actualDate }})
</span>
</div>
<div v-if="milestone.description" class="milestone-desc">
{{ milestone.description }}
</div> </div>
</div> </div>
</div> </div>
@@ -618,78 +773,6 @@ onMounted(() => {
</el-table> </el-table>
</el-card> </el-card>
</el-col> </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> </el-row>
</div> </div>
</template> </template>
@@ -715,137 +798,21 @@ onMounted(() => {
min-height: 300px; min-height: 300px;
} }
.gantt-chart { .gantt-chart-container {
position: relative; :deep(.g-gantt-chart) {
background-color: #fafafa;
border-radius: 8px;
} }
.gantt-timeline { :deep(.g-gantt-row) {
position: relative; &:hover {
height: 30px; background-color: #f0f2f5;
margin-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
.timeline-item {
position: absolute;
font-size: 12px;
color: #909399;
transform: translateX(-50%);
} }
} }
.gantt-tasks { :deep(.g-gantt-bar) {
.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; border-radius: 4px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
&.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;
}
} }
} }
@@ -862,4 +829,92 @@ onMounted(() => {
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid #e4e7ed; border-top: 1px solid #e4e7ed;
} }
// 里程碑区域样式
.milestone-section {
padding-top: 16px;
border-top: 1px dashed #e4e7ed;
}
// 里程碑时间线样式
.milestone-timeline {
padding: 8px 0;
}
.milestone-item {
display: flex;
gap: 16px;
padding: 12px 0;
&.is-completed {
.milestone-content {
opacity: 0.8;
}
}
}
.milestone-marker {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
}
.milestone-dot {
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
}
.milestone-line {
flex: 1;
width: 2px;
min-height: 40px;
margin-top: 4px;
background: linear-gradient(to bottom, #dcdfe6, #e4e7ed);
}
.milestone-content {
flex: 1;
padding-bottom: 8px;
}
.milestone-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.milestone-name {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.milestone-date {
display: flex;
gap: 4px;
align-items: center;
margin-bottom: 4px;
font-size: 12px;
color: #606266;
.actual-date {
font-weight: 500;
color: #67c23a;
}
}
.milestone-desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: #909399;
}
</style> </style>

View File

@@ -128,38 +128,43 @@ function getRiskType(risk?: string): "success" | "warning" | "danger" {
<el-card shadow="hover" class="stat-card"> <el-card shadow="hover" class="stat-card">
<div class="flex-bc"> <div class="flex-bc">
<div> <div>
<p class="text-gray-500 text-sm">进行中项目</p> <p class="text-gray-500 text-sm">项目总数</p>
<p class="text-2xl font-bold mt-1">
{{ statistics.totalCount }}
</p>
<p class="text-xs text-blue-500 mt-1">
<el-icon
><component :is="useRenderIcon('ri/folder-line')"
/></el-icon>
包含各状态项目
</p>
</div>
<div class="stat-icon bg-blue-100">
<el-icon :size="24" color="#409eff">
<component :is="useRenderIcon('ri/folders-line')" />
</el-icon>
</div>
</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"> <p class="text-2xl font-bold mt-1">
{{ statistics.ongoingCount }} {{ statistics.ongoingCount }}
</p> </p>
<p class="text-xs text-green-500 mt-1"> <p class="text-xs text-green-500 mt-1">
<el-icon <el-icon
><component :is="useRenderIcon('ri/arrow-up-line')" ><component :is="useRenderIcon('ri/play-circle-line')"
/></el-icon> /></el-icon>
较上月增加2个 {{ statistics.planningCount }} 个规划中
</p> </p>
</div> </div>
<div class="stat-icon bg-blue-100">
<el-icon :size="24" color="#409eff">
<component :is="useRenderIcon('ri/folder-line')" />
</el-icon>
</div>
</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">
{{ statistics.completedCount }}
</p>
<p class="text-xs text-gray-400 mt-1">本年度累计完成</p>
</div>
<div class="stat-icon bg-green-100"> <div class="stat-icon bg-green-100">
<el-icon :size="24" color="#67c23a"> <el-icon :size="24" color="#67c23a">
<component :is="useRenderIcon('ri/check-line')" /> <component :is="useRenderIcon('ri/play-circle-line')" />
</el-icon> </el-icon>
</div> </div>
</div> </div>
@@ -169,41 +174,50 @@ function getRiskType(risk?: string): "success" | "warning" | "danger" {
<el-card shadow="hover" class="stat-card"> <el-card shadow="hover" class="stat-card">
<div class="flex-bc"> <div class="flex-bc">
<div> <div>
<p class="text-gray-500 text-sm">高风险项目</p> <p class="text-gray-500 text-sm">已完成</p>
<p class="text-2xl font-bold mt-1 text-orange-500">
{{ statistics.highRiskCount }}
</p>
<p class="text-xs text-orange-400 mt-1">需要重点关注</p>
</div>
<div class="stat-icon bg-orange-100">
<el-icon :size="24" color="#e6a23c">
<component :is="useRenderIcon('ri/alert-line')" />
</el-icon>
</div>
</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"> <p class="text-2xl font-bold mt-1">
{{ Math.round(statistics.averageProgress || 0) }}% {{ statistics.completedCount }}
</p>
<p class="text-xs text-gray-400 mt-1">
{{ statistics.pausedCount }} 个已暂停 |
{{ statistics.cancelledCount }} 个已取消
</p> </p>
<el-progress
:percentage="Math.round(statistics.averageProgress || 0)"
:show-text="false"
class="mt-2"
style="width: 100px"
/>
</div> </div>
<div class="stat-icon bg-purple-100"> <div class="stat-icon bg-purple-100">
<el-icon :size="24" color="#9b59b6"> <el-icon :size="24" color="#9b59b6">
<component :is="useRenderIcon('ri/check-double-line')" />
</el-icon>
</div>
</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">
{{ Math.round(statistics.averageProgress || 0) }}%
</p>
<p class="text-xs text-orange-400 mt-1">
<el-icon
><component :is="useRenderIcon('ri/alert-line')"
/></el-icon>
{{ statistics.highRiskCount }} 个高风险项目
</p>
</div>
<div class="stat-icon bg-orange-100">
<el-icon :size="24" color="#e6a23c">
<component :is="useRenderIcon('ri/bar-chart-line')" /> <component :is="useRenderIcon('ri/bar-chart-line')" />
</el-icon> </el-icon>
</div> </div>
</div> </div>
<el-progress
:percentage="Math.round(statistics.averageProgress || 0)"
:show-text="false"
class="mt-2"
style="width: 100%"
/>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>