feat(project): 重构甘特图为vue-ganttastic组件并更新项目统计卡片
Some checks failed
Lint Code / Lint Code (push) Failing after 1m35s
Some checks failed
Lint Code / Lint Code (push) Failing after 1m35s
- 替换原有甘特图任务条样式计算,改用vue-ganttastic库渲染任务条和里程碑 - 根据任务状态和进度生成不同颜色,支持关键里程碑标识 - 实现甘特图日期范围计算及任务、里程碑数据格式转换 - 优化甘特图和里程碑的布局与样式,提升交互体验 - 移除右侧项目AI助手相关代码及样式 - 项目首页统计卡片文字和图标调整,展示项目总数、进行中、已完成等状态 - 平均进度条样式优化,补充高风险项目数量显示
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
type ProjectResource,
|
||||
type ProjectRisk
|
||||
} from "@/api/project";
|
||||
import { GGanttChart, GGanttRow } from "@infectoone/vue-ganttastic";
|
||||
import { message } from "@/utils/message";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@@ -195,24 +196,103 @@ function getMaterialStatusType(
|
||||
}
|
||||
}
|
||||
|
||||
// 计算甘特图任务条样式(使用真实任务数据)
|
||||
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;
|
||||
// 将任务数据转换为 vue-ganttastic 格式
|
||||
const ganttTasks = computed(() => {
|
||||
return taskList.value.map(task => ({
|
||||
id: task.id,
|
||||
label: task.taskName,
|
||||
startDate: task.planStartDate || "",
|
||||
endDate: task.planEndDate || "",
|
||||
progress: task.progress || 0,
|
||||
assignee: task.assigneeName || "未分配",
|
||||
status: task.status || "pending",
|
||||
ganttBarConfig: {
|
||||
id: task.id,
|
||||
label: `${task.taskName} ${task.assigneeName ? "负责人:" + task.assigneeName : ""}`,
|
||||
hasHandles: false,
|
||||
style: {
|
||||
backgroundColor: getTaskColor(task.status, task.progress),
|
||||
color: "#fff"
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
left: `${(offsetDays / totalDays) * 100}%`,
|
||||
width: `${(duration / totalDays) * 100}%`
|
||||
};
|
||||
// 获取任务颜色
|
||||
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() {
|
||||
if (!projectId.value) return;
|
||||
@@ -410,7 +490,9 @@ onMounted(() => {
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="
|
||||
Math.round((projectInfo.cost / projectInfo.budget) * 100)
|
||||
projectInfo.budget > 0
|
||||
? Math.round((projectInfo.cost / projectInfo.budget) * 100)
|
||||
: 0
|
||||
"
|
||||
status="warning"
|
||||
class="mt-3"
|
||||
@@ -480,7 +562,7 @@ onMounted(() => {
|
||||
<!-- 主要内容区 -->
|
||||
<el-row :gutter="16">
|
||||
<!-- 左侧:甘特图和物料清单 -->
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-col :xs="24" :lg="24">
|
||||
<!-- 甘特图 -->
|
||||
<el-card shadow="hover" class="mb-4">
|
||||
<template #header>
|
||||
@@ -509,42 +591,115 @@ onMounted(() => {
|
||||
</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()"
|
||||
<!-- 任务甘特图主体 - 使用 vue-ganttastic -->
|
||||
<div v-if="taskList.length > 0" class="gantt-chart-container mb-6">
|
||||
<g-gantt-chart
|
||||
:chart-start="ganttDateRange.start"
|
||||
:chart-end="ganttDateRange.end"
|
||||
precision="day"
|
||||
date-format="YYYY-MM-DD"
|
||||
bar-start="startDate"
|
||||
bar-end="endDate"
|
||||
grid
|
||||
highlight-on-hover
|
||||
>
|
||||
<g-gantt-row
|
||||
v-for="task in ganttTasks"
|
||||
:key="task.id"
|
||||
class="gantt-task-row"
|
||||
:bars="[task]"
|
||||
:label="task.label"
|
||||
/>
|
||||
</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 class="task-name">{{ task.taskName }}</div>
|
||||
<div class="task-bar-container">
|
||||
</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="task-bar"
|
||||
:style="getTaskBarStyle(task)"
|
||||
:class="{ completed: task.progress === 100 }"
|
||||
class="milestone-dot"
|
||||
:style="{
|
||||
backgroundColor: getMilestoneColor(
|
||||
milestone.status,
|
||||
milestone.isKey
|
||||
)
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="task-progress"
|
||||
:style="{ width: `${task.progress || 0}%` }"
|
||||
/>
|
||||
<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>
|
||||
@@ -618,78 +773,6 @@ onMounted(() => {
|
||||
</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>
|
||||
@@ -715,137 +798,21 @@ onMounted(() => {
|
||||
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-chart-container {
|
||||
:deep(.g-gantt-chart) {
|
||||
background-color: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
:deep(.g-gantt-row) {
|
||||
&:hover {
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
:deep(.g-gantt-bar) {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,4 +829,92 @@ onMounted(() => {
|
||||
padding: 12px 16px;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user