feat(项目看板): 新增项目综合看板功能

新增项目综合看板功能,聚合展示项目、任务、进度主表和步骤数据
- 新增后端聚合接口 GET /oa/project/dashboard/{projectId}
- 新增前端看板页面,包含项目列表、任务表格和进度导图
- 优化思维导图组件,支持看板模式下的紧凑展示
- 新增进度明细表格视图和状态图例
- 实现任务与进度步骤的关联展示
- 添加项目模糊搜索功能
This commit is contained in:
2026-04-15 17:19:56 +08:00
parent 5d4794c9bd
commit 50f3f15f48
24 changed files with 1623 additions and 115 deletions

View File

@@ -0,0 +1,894 @@
<!--
前端路径d:\RuoYI_workspace\fad_oa\ruoyi-ui\src\views\oa\project\dashboard2\index.vue
后端聚合接口GET /oa/project/dashboard/{projectId}
-->
<template>
<div class="app-container dashboard2" v-loading="pageLoading">
<div class="layout">
<!-- 左侧 20%项目列表 -->
<div class="left">
<div class="left-search">
<el-input
v-model="projectQuery.keyword"
size="small"
clearable
placeholder="名称 / 编号 / 代号"
class="left-keyword-input"
prefix-icon="el-icon-search"
@keyup.enter.native="getProjectList"
/>
<el-button type="primary" size="small" class="left-search-btn" @click="getProjectList">搜索</el-button>
</div>
<div class="project-list" v-loading="projectLoading">
<div
v-for="p in projectList"
:key="p.projectId"
class="project-item"
:class="{ active: String(p.projectId) === String(currentProjectId) }"
@click="handleSelectProject(p)"
:title="p.projectName"
>
<div class="project-name text-ellipsis">{{ p.projectName }}</div>
<div class="project-meta text-ellipsis">
<span v-if="p.projectCode" class="code">{{ p.projectCode }}</span>
<span class="time">{{ formatDate(p.beginTime) }} ~ {{ formatDate(p.finishTime) }}</span>
</div>
</div>
<div v-if="!projectList || projectList.length === 0" class="left-empty">
<el-empty :image-size="72" description="暂无项目"></el-empty>
</div>
</div>
<div class="left-pager">
<el-pagination
small
background
layout="total, prev, pager, next"
:total="projectTotal"
:current-page.sync="projectQuery.pageNum"
:page-size.sync="projectQuery.pageSize"
@current-change="getProjectList"
/>
</div>
</div>
<!-- 右侧 80% -->
<div class="right">
<div v-if="!currentProjectId" class="right-empty">
<el-empty description="请选择项目"></el-empty>
</div>
<template v-else>
<div class="panel task-panel">
<div class="panel-header task-panel__toolbar">
<div class="toolbar">
<el-input
v-model="taskQuery.projectCode"
size="small"
clearable
placeholder="项目代号"
style="width: 140px"
/>
<el-input
v-model="taskQuery.taskKeyword"
size="small"
clearable
placeholder="任务主题关键词"
style="width: 160px"
@keyup.enter.native="resetTaskPage"
/>
<el-button type="primary" size="mini" @click="resetTaskPage">搜索</el-button>
<el-button size="mini" @click="resetTaskFilters">重置</el-button>
<el-button size="mini" @click="refreshCurrent">刷新</el-button>
</div>
</div>
<div class="panel-body panel-body-flex">
<div class="task-table-wrap">
<vxe-table
ref="taskTable"
size="mini"
class="task-vxe"
border
stripe
show-overflow="tooltip"
height="auto"
:data="pagedTaskList"
:row-config="{ isHover: true }"
>
<vxe-column field="projectCode" title="代号" width="72"></vxe-column>
<vxe-column field="taskTitle" title="任务主题" min-width="96"></vxe-column>
<vxe-column field="scheduleProgress" title="对应进度" min-width="112">
<template #default="{ row }">
<div v-if="scheduleProgressUnlinked(row)" class="schedule-progress-wrap">
<el-tag type="info" effect="plain" size="mini">未关联进度</el-tag>
</div>
<div
v-else
class="schedule-progress-wrap schedule-progress-wrap--linked"
:title="scheduleProgressTitle(row)"
>
<el-tag :type="scheduleStepTagType(row)" size="mini" class="schedule-progress-status-tag">
{{ scheduleStepStatusLabel(row) }}
</el-tag>
<span class="schedule-progress-path">{{ scheduleProgressPath(row) }}</span>
</div>
</template>
</vxe-column>
<vxe-column field="taskType" title="工作类型" width="86">
<template #default="{ row }">
<dict-tag :options="dict.type.sys_work_type" :value="row.taskType" />
</template>
</vxe-column>
<vxe-column field="taskGrade" title="任务级别" width="78">
<template #default="{ row }">
<dict-tag :options="dict.type.sys_sort_grade" :value="row.taskGrade" />
</template>
</vxe-column>
<vxe-column field="workerNickName" title="执行人" width="76">
<template #default="{ row }">{{ row.workerNickName || '-' }}</template>
</vxe-column>
<vxe-column field="createUserNickName" title="创建人" width="76">
<template #default="{ row }">{{ row.createUserNickName || '-' }}</template>
</vxe-column>
<vxe-column field="state" title="状态" width="88">
<template #default="{ row }">
<el-tag :type="getStateTagType(row.state)" size="mini">{{ stateText(row.state) }}</el-tag>
</template>
</vxe-column>
<vxe-column field="finishTime" title="预期结束" width="100">
<template #default="{ row }">{{ formatDate(row.finishTime) }}</template>
</vxe-column>
</vxe-table>
</div>
<div class="task-pager">
<el-pagination
small
background
layout="total, prev, pager, next"
:total="filteredTaskTotal"
:current-page.sync="taskQuery.pageNum"
:page-size.sync="taskQuery.pageSize"
@current-change="noop"
/>
</div>
</div>
</div>
<!-- 进度板块 Tab1 进度明细表默认Tab2 原思维导图 + 图例 -->
<div class="panel schedule-panel">
<el-tabs v-model="scheduleViewTab" class="schedule-panel-tabs">
<el-tab-pane label="进度明细" name="list">
<!-- 进度数据表格自然撑开高度 .progress-table-scroll 单独承担纵向/横向滚动 -->
<div class="progress-table-pane" v-loading="xmindLoading">
<el-empty
v-if="!stepList || stepList.length === 0"
:image-size="72"
description="暂无进度步骤数据"
/>
<div v-else class="progress-table-scroll">
<el-table
:data="scheduleStepsForTable"
size="mini"
border
stripe
class="progress-step-el-table"
>
<el-table-column prop="firstLevelNode" label="一级节点" min-width="112" show-overflow-tooltip />
<el-table-column prop="secondLevelNode" label="二级节点" min-width="112" show-overflow-tooltip />
<el-table-column prop="stepName" label="步骤名称" min-width="128" show-overflow-tooltip />
<el-table-column label="状态" width="92" align="center">
<template slot-scope="{ row }">
<el-tag :type="stepStatusTagType(row)" size="mini">{{ stepStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="计划开始" width="108" align="center">
<template slot-scope="{ row }">{{ formatStepPlanStart(row) }}</template>
</el-table-column>
<el-table-column label="计划结束" width="108" align="center">
<template slot-scope="{ row }">{{ formatStepPlanEnd(row) }}</template>
</el-table-column>
<el-table-column label="负责人" min-width="96" show-overflow-tooltip>
<template slot-scope="{ row }">{{ formatStepResponsible(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="进度导图" name="mind">
<div class="schedule-tab-pane-inner schedule-tab-pane-inner--mind">
<div class="schedule-mind">
<div class="xmind-wrap" v-loading="xmindLoading">
<div v-if="xmindEmpty" class="xmind-empty">
<el-empty :image-size="80" description="暂无进度步骤数据"></el-empty>
</div>
<xmind
v-else-if="scheduleViewTab === 'mind'"
:list="stepList"
height="100%"
dashboard-mode
@refresh="onXmindRefresh"
/>
</div>
<aside v-if="scheduleViewTab === 'mind' && !xmindEmpty && stepList && stepList.length" class="mind-legend-aside">
<div class="mind-legend-title">状态图例</div>
<div class="mind-legend-row">
<i class="lg-dot lg-dot--done" />
<span>已完成</span>
</div>
<div class="mind-legend-row">
<i class="lg-dot lg-dot--doing" />
<span>进行中 / 待验收</span>
</div>
<div class="mind-legend-row">
<i class="lg-dot lg-dot--todo" />
<span>未开始 / 暂停</span>
</div>
<p class="mind-legend-tip">连线已完成节点为绿色可滚轮缩放拖动画布</p>
</aside>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Xmind from '@/views/oa/project/pace/components/xmind.vue'
export default {
name: 'OaProjectDashboard2',
components: { Xmind },
dicts: ['sys_work_type', 'sys_sort_grade'],
data () {
return {
pageLoading: false,
projectLoading: false,
xmindLoading: false,
/** 进度板块:默认进度明细表,切换为进度导图(原思维导图) */
scheduleViewTab: 'list'
}
},
computed: {
...mapState('oaProjectDashboard2', [
'currentProjectId',
'projectQuery',
'projectList',
'projectTotal',
'taskQuery',
'taskList',
'stepList'
]),
filteredTaskList () {
const list = this.taskList || []
const code = (this.taskQuery.projectCode || '').trim().toLowerCase()
const kw = (this.taskQuery.taskKeyword || '').trim().toLowerCase()
return list.filter(r => {
const okCode = !code || String(r.projectCode || '').toLowerCase().indexOf(code) !== -1
const title = String(r.taskTitle || '').toLowerCase()
const okKw = !kw || title.indexOf(kw) !== -1
return okCode && okKw
})
},
filteredTaskTotal () {
return (this.filteredTaskList || []).length
},
pagedTaskList () {
const pageNum = Number(this.taskQuery.pageNum || 1)
const pageSize = Number(this.taskQuery.pageSize || 10)
const start = (pageNum - 1) * pageSize
return (this.filteredTaskList || []).slice(start, start + pageSize)
},
xmindEmpty () {
return !this.stepList || this.stepList.length === 0
},
/** 进度明细表:按步骤序号排序,便于阅读 */
scheduleStepsForTable () {
const list = this.stepList || []
return [...list].sort((a, b) => {
const oa = Number(a.stepOrder != null ? a.stepOrder : 0)
const ob = Number(b.stepOrder != null ? b.stepOrder : 0)
return oa - ob
})
}
},
created () {
this.getProjectList()
},
methods: {
async getProjectList () {
this.projectLoading = true
try {
await this.$store.dispatch('oaProjectDashboard2/fetchProjectList', {
pageNum: this.projectQuery.pageNum,
pageSize: this.projectQuery.pageSize,
keyword: this.projectQuery.keyword
})
} catch (e) {
this.$message.error('项目列表加载失败,请稍后重试')
} finally {
this.projectLoading = false
}
},
async handleSelectProject (p) {
const projectId = p && p.projectId
if (!projectId) return
this.scheduleViewTab = 'list'
this.pageLoading = true
this.xmindLoading = true
try {
this.$store.commit('oaProjectDashboard2/SET_TASK_QUERY', { pageNum: 1 })
await this.$store.dispatch('oaProjectDashboard2/selectProject', projectId)
} catch (e) {
this.$message.error((e && e.message) || '加载项目数据失败,请稍后重试')
} finally {
this.pageLoading = false
this.xmindLoading = false
}
},
resetTaskPage () {
this.$store.commit('oaProjectDashboard2/SET_TASK_QUERY', { pageNum: 1 })
},
resetTaskFilters () {
this.$store.commit('oaProjectDashboard2/SET_TASK_QUERY', {
pageNum: 1,
pageSize: 10,
projectCode: '',
taskKeyword: ''
})
},
async refreshCurrent () {
if (!this.currentProjectId) return
this.pageLoading = true
this.xmindLoading = true
try {
await this.$store.dispatch('oaProjectDashboard2/selectProject', this.currentProjectId)
} catch (e) {
this.$message.error('刷新失败,请稍后重试')
} finally {
this.pageLoading = false
this.xmindLoading = false
}
},
/** 计划开始:库表 plan_start 可能为空,与进度页一致回退 start_time */
formatStepPlanStart (row) {
const raw = row && (row.planStart != null && row.planStart !== '' ? row.planStart : row.startTime)
return this.formatDateFlexible(raw)
},
formatStepPlanEnd (row) {
const raw = row && (row.planEnd != null && row.planEnd !== '' ? row.planEnd : row.endTime)
return this.formatDateFlexible(raw)
},
formatDateFlexible (val) {
if (val == null || val === '') return '-'
if (Array.isArray(val) && val.length >= 3) {
const y = val[0]
const mo = String(val[1]).padStart(2, '0')
const d = String(val[2]).padStart(2, '0')
return `${y}-${mo}-${d}`
}
if (typeof val === 'string') {
const s = val.trim()
if (!s) return '-'
if (s.length >= 10 && s[4] === '-' && s[7] === '-') return s.substring(0, 10)
const m = s.match(/^(\d{4}-\d{2}-\d{2})/)
return m ? m[1] : s
}
if (val instanceof Date && !isNaN(val.getTime())) {
const y = val.getFullYear()
const mo = String(val.getMonth() + 1).padStart(2, '0')
const d = String(val.getDate()).padStart(2, '0')
return `${y}-${mo}-${d}`
}
return '-'
},
/** 负责人:业务侧常用 node_header兼容 header */
formatStepResponsible (row) {
if (!row) return '-'
const h = row.header != null && String(row.header).trim() !== '' ? String(row.header).trim() : ''
if (h) return h
const n = row.nodeHeader != null && String(row.nodeHeader).trim() !== '' ? String(row.nodeHeader).trim() : ''
return n || '-'
},
async onXmindRefresh () {
await this.refreshCurrent()
},
noop () {},
/** 无 track_id 或无步骤名称 → 未关联(后端联表 oa_project_schedule_stepuse_flag=1 */
scheduleProgressUnlinked (row) {
if (!row || row.trackId == null || row.trackId === '') return true
const n = row.scheduleStepName
return n == null || String(n).trim() === ''
},
scheduleStepTagType (row) {
const s = row && row.scheduleStatus
if (s === null || s === undefined) return 'info'
const v = Number(s)
if (v === 2) return 'success'
if (v === 3) return 'warning'
if (v === 1) return 'primary'
if (v === 0) return 'info'
return 'info'
},
scheduleStepStatusLabel (row) {
const s = row && row.scheduleStatus
if (s === null || s === undefined) return '—'
const v = Number(s)
if (v === 2) return '已完成'
if (v === 1) return '进行中'
if (v === 3) return '暂停'
if (v === 0) return '未开始'
return '—'
},
scheduleProgressPath (row) {
if (!row) return ''
const n = row.scheduleStepName
return (n != null && String(n).trim() !== '') ? String(n).trim() : ''
},
scheduleProgressTitle (row) {
return `${this.scheduleStepStatusLabel(row)} ${this.scheduleProgressPath(row)}`.trim()
},
/** oa_project_schedule_step.status与任务表「对应进度」标签一致 */
stepStatusLabel (row) {
return this.scheduleStepStatusLabel({ scheduleStatus: row && row.status })
},
stepStatusTagType (row) {
return this.scheduleStepTagType({ scheduleStatus: row && row.status })
},
formatDate (val) {
if (!val) return '-'
const s = String(val)
return s.length >= 10 ? s.substring(0, 10) : s
},
stateText (val) {
if (val === null || val === undefined || val === '') return '-'
const v = Number(val)
if (v === 2) return '执行完成'
if (v === 1) return '待验收'
if (v === 15) return '延期申请中'
if (v === 0) return '进行中'
return '其他'
},
getStateTagType (val) {
if (val === null || val === undefined || val === '') return 'info'
const v = Number(val)
if (v === 2) return 'success'
if (v === 1) return 'warning'
if (v === 15) return 'warning'
if (v === 0) return 'info'
return 'info'
}
}
}
</script>
<style scoped>
.dashboard2 {
font-size: 13px;
color: #606266;
}
.layout {
display: flex;
gap: 8px;
align-items: stretch;
height: calc(100vh - 120px);
min-height: 0;
max-height: calc(100vh - 120px);
}
.left {
width: 20%;
flex: 0 0 20%;
min-width: 220px;
max-width: 300px;
border: 1px solid #e4e7ed;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 10px;
box-sizing: border-box;
background: #fafbfc;
}
.left-search {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.left-keyword-input {
flex: 1;
min-width: 0;
}
.left-search-btn {
flex-shrink: 0;
padding-left: 14px;
padding-right: 14px;
}
.project-list {
flex: 1;
overflow: auto;
min-height: 0;
}
.project-item {
padding: 6px 8px;
cursor: pointer;
border-bottom: 1px solid #eef0f3;
border-radius: 4px;
margin-bottom: 2px;
min-height: 42px;
display: flex;
flex-direction: column;
justify-content: center;
transition: background 0.15s ease;
}
.project-item:hover {
background: #f0f2f5;
}
.project-item.active {
background: #ecf5ff;
border-left: 3px solid #409eff;
padding-left: 5px;
box-shadow: inset 0 0 0 1px rgba(64, 158, 255, 0.15);
}
.project-name {
font-weight: 500;
font-size: 12px;
line-height: 1.35;
color: #303133;
}
.project-meta {
font-size: 11px;
color: #909399;
line-height: 1.35;
margin-top: 3px;
}
.project-meta .code {
margin-right: 8px;
color: #409eff;
}
.left-pager {
padding-top: 8px;
margin-top: 4px;
border-top: 1px solid #ebeef5;
}
.left-pager :deep(.el-pagination) {
justify-content: center;
}
.left-empty {
padding: 10px;
}
.right {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
.right-empty {
border: 1px dashed #dcdfe6;
border-radius: 4px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.panel {
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
background: #fff;
display: flex;
flex-direction: column;
min-height: 0;
}
.panel-header {
padding: 8px 12px;
border-bottom: 1px solid #ebeef5;
}
/* 原「任务列表」标题条去掉后,工具栏区沿用顶栏灰底,与下方表格白底区分不变 */
.task-panel__toolbar {
background: #fafafa;
}
.panel-body {
padding: 8px 12px;
}
.panel-body-flex {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.task-panel {
flex: 4 1 0%;
min-height: 0;
}
.schedule-panel {
flex: 6 1 0%;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
}
.schedule-panel-tabs {
flex: 1 1 0%;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Element Tabs 根节点参与纵向 flex才能把剩余高度交给内容区 */
.schedule-panel-tabs :deep(.el-tabs) {
flex: 1 1 0%;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.schedule-panel-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
padding: 0 12px;
flex-shrink: 0;
}
.schedule-panel-tabs :deep(.el-tabs__content) {
flex: 1 1 0%;
min-height: 0;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
/* 当前激活的 pane 占满内容区高度,子元素才能算出可滚动区域 */
.schedule-panel-tabs :deep(.el-tab-pane) {
flex: 1 1 0%;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.schedule-tab-pane-inner {
flex: 1 1 0%;
min-height: 0;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.schedule-tab-pane-inner--mind {
min-height: 280px;
overflow: hidden;
}
/* 进度明细 Tab占满下方板块内部仅滚动区参与滚动 */
.progress-table-pane {
flex: 1 1 0%;
min-height: 0;
width: 100%;
height:300px;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 8px 12px 10px;
}
.progress-table-scroll {
flex: 1 1 0%;
min-height: 0;
min-width: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.progress-step-el-table {
width: 100%;
}
.schedule-mind {
display: flex;
flex-direction: row;
align-items: stretch;
flex: 1;
min-height: 0;
}
.task-table-wrap {
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.task-table-wrap :deep(.vxe-table) {
font-size: 12px;
width: 100%;
}
.task-table-wrap :deep(.vxe-table--body-wrapper),
.task-table-wrap :deep(.vxe-table--header-wrapper) {
overflow-x: hidden;
}
.schedule-progress-wrap {
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
line-height: 1.35;
}
.schedule-progress-wrap--linked {
min-width: 0;
}
.schedule-progress-status-tag {
flex-shrink: 0;
}
.schedule-progress-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #606266;
}
.task-pager {
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.xmind-wrap {
flex: 1;
min-width: 0;
position: relative;
padding: 0;
display: flex;
flex-direction: column;
min-height: 320px;
}
.xmind-wrap :deep(.xmind-box--dashboard) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.xmind-wrap :deep(.xmind-container) {
flex: 1;
min-height: 300px !important;
}
.mind-legend-aside {
flex: 0 0 132px;
padding: 10px 12px;
border-left: 1px solid #ebeef5;
background: #fafafa;
font-size: 12px;
color: #606266;
line-height: 1.5;
}
.mind-legend-title {
font-weight: 600;
color: #303133;
margin-bottom: 10px;
font-size: 13px;
}
.mind-legend-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.lg-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.lg-dot--done {
background: #67c23a;
}
.lg-dot--doing {
background: #e6a23c;
}
.lg-dot--todo {
background: #909399;
}
.mind-legend-tip {
margin: 12px 0 0;
font-size: 11px;
color: #909399;
line-height: 1.45;
}
.xmind-empty {
height: 100%;
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #ebeef5;
border-radius: 4px;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,14 +1,13 @@
<template>
<div class="xmind-box">
<div class='action-panel'>
<!-- <el-button type="primary" icon="el-icon-plus" @click="handleAdd"></el-button> -->
<el-button type="primary" icon="el-icon-refresh" @click="handleRefresh">刷新</el-button>
<div class="xmind-box" :class="{ 'xmind-box--dashboard': dashboardMode }">
<div class="action-panel">
<el-button type="primary" :size="dashboardMode ? 'mini' : 'small'" icon="el-icon-refresh" @click="handleRefresh"></el-button>
<!-- <el-button type="primary" icon="el-icon-view" @click="handleRefresh">详情</el-button>
<el-button type="primary" icon="el-icon-edit" @click="handleRefresh">编辑</el-button>
<el-button type="primary" icon="el-icon-folder" @click="previewFiles(currentNode)">文件</el-button>
<el-button type="primary" icon="el-icon-picture" @click="previewImages(currentNode)">图片</el-button> -->
</div>
<div class="xmind-container" ref="chart" style="width: 100%; height: 800px;"></div>
<div class="xmind-container" ref="chart" :style="containerStyle"></div>
<!-- 新增三级节点点击弹窗-查看完整信息 -->
<el-dialog title="节点详情信息" :visible.sync="dialogVisible" width="1200px" center append-to-body>
<el-form>
@@ -90,6 +89,25 @@ export default {
list: {
type: Array,
default: () => []
},
/** 容器高度,综合看板等场景可传 100% 以撑满父级 */
height: {
type: String,
default: '800px'
},
/** 综合看板:紧凑、防重叠、三色状态、小圆点 */
dashboardMode: {
type: Boolean,
default: false
}
},
computed: {
containerStyle () {
return {
width: '100%',
height: this.height,
minHeight: this.dashboardMode ? '300px' : '240px'
}
}
},
data () {
@@ -115,7 +133,7 @@ export default {
},
mounted () {
this.initChart();
// 监听窗口大小变化,自适应重绘
this.$nextTick(() => this.resizeChart());
window.addEventListener('resize', this.resizeChart);
},
beforeDestroy () {
@@ -175,53 +193,97 @@ export default {
})
},
// 核心方法:把扁平数组 转为 ECharts树图需要的嵌套树形结构
/** 看板长标签按字折行(与折线图区域可读性一致) */
wrapLabelText (text, maxCharsPerLine) {
if (!text) return ''
const max = Math.max(4, maxCharsPerLine || 16)
if (text.length <= max) return text
const lines = []
for (let i = 0; i < text.length; i += max) {
lines.push(text.slice(i, i + max))
}
return lines.join('\n')
},
// Tab节点 → 一级节点 → 二级节点(叶子带业务数据)
transformToTreeData (list) {
if (!list.length) return { name: '暂无项目数据', children: [] };
// 1. 获取项目名称(所有数据是同一个项目,取第一条即可)
const dm = this.dashboardMode
const projectName = list[0].projectName || '项目进度树图';
// 2. 构建层级Map去重+归集子节点
const levelMap = new Map();
list.forEach(item => {
const firstLevel = item.firstLevelNode || '未分类一级节点';
const secondLevel = item.secondLevelNode || '未分类级节点';
// 状态映射0=未开始(蓝色) 2=已完成(绿色) 其他=进行中(橙色),可根据业务调整
const statusText = item.status === 0 ? '待开始' : item.status === 2 ? '✅已完成' : '🔵进行中';
const statusColor = item.status === 0 ? '#409EFF' : item.status === 2 ? '#67C23A' : '#E6A23C';
const tabMap = new Map();
list.forEach(item => {
const tab = item.tabNode || '默认分组';
const firstLevel = item.firstLevelNode || '未分类级节点';
const secondLevel = item.secondLevelNode || item.stepName || '未命名节点';
const st = Number(item.status);
let statusText = '进行中';
let statusColor = '#E6A23C';
let lineToNode = { color: '#dcdfe6', width: 1.2 };
if (st === 2) {
statusText = '已完成';
statusColor = '#67C23A';
lineToNode = { color: '#67C23A', width: 1.8 };
} else if (st === 0) {
statusText = '未开始';
statusColor = '#909399';
} else if (st === 1) {
statusText = '待验收/进行中';
statusColor = '#E6A23C';
} else if (st === 3) {
statusText = '暂停';
statusColor = '#909399';
}
// 组装节点数据:显示名称+业务信息+样式
const nodeData = {
name: secondLevel,
itemStyle: { color: statusColor },
/* 看板:小圆点 + 白边,贴近折线图主色风格 */
itemStyle: dm
? { color: statusColor, borderColor: '#ffffff', borderWidth: 1.25, shadowBlur: 3, shadowColor: 'rgba(0,0,0,0.12)' }
: { color: statusColor, borderColor: statusColor },
label: { color: statusColor },
// 自定义业务数据,鼠标悬浮时显示
lineStyle: lineToNode,
value: {
...item,
负责人: item.nodeHeader || '无',
状态: statusText,
计划完成: item.planEnd || '无',
说明: item.specification || '无'
statusLabel: statusText
}
};
// 归集一级节点和二级节点
if (!levelMap.has(firstLevel)) {
levelMap.set(firstLevel, []);
if (!tabMap.has(tab)) {
tabMap.set(tab, new Map());
}
levelMap.get(firstLevel).push(nodeData);
const firstMap = tabMap.get(tab);
if (!firstMap.has(firstLevel)) {
firstMap.set(firstLevel, []);
}
firstMap.get(firstLevel).push(nodeData);
});
// 3. 组装最终的树形结构
const treeChildren = Array.from(levelMap).map(([firstName, children]) => ({
name: firstName,
itemStyle: { color: '#303133' }, // 一级节点统一深灰色
children: children
const treeChildren = Array.from(tabMap).map(([tabName, firstMap]) => ({
name: tabName,
itemStyle: dm
? { color: '#606266', borderColor: '#ffffff', borderWidth: 1, shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.06)' }
: { color: '#606266', borderColor: '#dcdfe6' },
lineStyle: { color: '#c0c4cc', width: 1.2 },
children: Array.from(firstMap).map(([firstName, children]) => ({
name: firstName,
itemStyle: dm
? { color: '#303133', borderColor: '#ffffff', borderWidth: 1, shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.06)' }
: { color: '#303133', borderColor: '#dcdfe6' },
lineStyle: { color: '#c0c4cc', width: 1.2 },
children
}))
}));
return {
name: projectName,
itemStyle: { color: '#1890FF' }, // 根节点(项目名)蓝色高亮
symbolSize: dm ? 7 : undefined,
itemStyle: dm
? { color: '#409eff', borderColor: '#ffffff', borderWidth: 1.5, shadowBlur: 4, shadowColor: 'rgba(64,158,255,0.35)' }
: { color: '#409eff', borderColor: '#409eff' },
lineStyle: { color: '#a0cfff', width: 1.5 },
label: dm ? { distance: 8, fontSize: 11 } : undefined,
children: treeChildren
};
},
@@ -238,68 +300,136 @@ export default {
}
// 转换数据格式
const treeData = this.transformToTreeData(this.list);
// 设置图表配置项
const dm = this.dashboardMode;
const escapeHtml = (s) => String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const option = {
backgroundColor: dm ? 'transparent' : undefined,
tooltip: {
trigger: 'item',
formatter: ({ data }) => {
// 鼠标悬浮展示完整业务信息
let tip = `<div style="font-size:14px"><b>${data.name}</b></div>`;
if (data.value) {
// Object.keys(data.value).forEach(key => {
// tip += `<div>${key}${data.value[key]}</div>`;
// });
tip += `<div>负责人:${data.value.nodeHeader || '无'}</div>`;
tip += `<div>规格需求:${data.value.specification || '无'}</div>`;
tip += `<div>任务状态${data.value.statusText || '无'}</div>`;
tip += `<div>计划完成:${data.value.planEnd || '无'}</div>`;
enterable: true,
confine: true,
extraCssText: 'max-width:420px;white-space:normal;word-break:break-word;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.08);',
formatter: (params) => {
const data = params.data
const title = (data && data.name) != null ? data.name : (params.name || '')
let tip = `<div style="font-size:13px;font-weight:600;margin-bottom:6px;line-height:1.45;color:#303133">${escapeHtml(title)}</div>`
if (data && data.value) {
const v = data.value
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">负责人${v.nodeHeader != null && v.nodeHeader !== '' ? escapeHtml(v.nodeHeader) : '无'}</div>`
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">规格需求:${v.specification != null && v.specification !== '' ? escapeHtml(v.specification) : '无'}</div>`
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">状态:${v.statusLabel != null && v.statusLabel !== '' ? escapeHtml(v.statusLabel) : '无'}</div>`
tip += `<div style="font-size:12px;line-height:1.55;color:#606266">计划完成:${v.planEnd != null && v.planEnd !== '' ? escapeHtml(v.planEnd) : '无'}</div>`
}
return tip;
return tip
}
},
series: [
{
type: 'tree', // 树图核心类型
type: 'tree',
data: [treeData],
symbol: 'circle', // 节点形状:圆点
symbolSize: 6, // 节点大小
orient: 'LR', // 树图展开方向LR=从左到右(脑图样式),可选 TB(从上到下)
initialTreeDepth: 2, // 默认展开层级2级
roam: true, // 开启鼠标拖拽+滚轮缩放
/* 与折线图区域一致:留白、白底在容器上 */
...(dm ? { left: '1%', right: '5%', top: '2%', bottom: '2%' } : {}),
symbol: 'circle',
...(dm ? {} : { symbolSize: 6 }),
edgeShape: dm ? 'polyline' : 'curve',
edgeForkPosition: dm ? '74%' : '50%',
orient: 'LR',
initialTreeDepth: 4,
roam: true,
scaleLimit: dm ? { min: 0.22, max: 5 } : undefined,
label: {
show: true,
fontSize: 12,
fontWeight: 500,
position: 'left', // 文字在节点左侧
verticalAlign: 'middle'
fontSize: dm ? 11 : 12,
fontWeight: 400,
position: 'left',
verticalAlign: 'middle',
...(dm ? { align: 'right' } : {}),
distance: 8,
overflow: 'none',
lineHeight: dm ? 15 : 14,
color: dm ? '#606266' : undefined
},
/*
* levels[i]:根下一层起 Tab / 分类 / 叶;缩小状态圆点,叶节点标签放右侧防挤压
*/
levels: dm
? [
{
symbolSize: 6,
itemStyle: { borderWidth: 1, borderColor: '#fff' },
label: {
position: 'left',
distance: 8,
fontSize: 11,
width: 118,
overflow: 'break',
lineHeight: 14,
padding: [2, 6, 2, 6]
}
},
{
symbolSize: 5,
itemStyle: { borderWidth: 1, borderColor: '#fff' },
label: {
position: 'left',
distance: 10,
fontSize: 11,
width: 160,
overflow: 'break',
lineHeight: 14,
padding: [2, 8, 2, 6]
}
},
{
symbolSize: 4,
itemStyle: { borderWidth: 1, borderColor: '#fff', shadowBlur: 2, shadowColor: 'rgba(0,0,0,0.1)' },
label: {
position: 'right',
verticalAlign: 'middle',
align: 'left',
distance: 12,
fontSize: 11,
width: 232,
overflow: 'break',
lineHeight: 15,
padding: [2, 8, 2, 8],
formatter: (p) => this.wrapLabelText(p.name, 17)
}
}
]
: [
{ symbolSize: 10 },
{ symbolSize: 7 },
{ symbolSize: 5 },
{ symbolSize: 3 }
],
lineStyle: {
width: 1.2,
curveness: 0.3, // 连接线曲率0=直线0.3=轻微曲线
color: '#ccc'
width: dm ? 1 : 1.2,
curveness: dm ? 0.1 : 0.3,
color: dm ? '#e4e7ed' : '#ccc'
},
emphasis: {
focus: 'descendant' // 鼠标悬浮时高亮当前节点及子节点
focus: 'descendant',
lineStyle: { width: 2, color: '#409eff' },
itemStyle: dm ? { shadowBlur: 6, shadowColor: 'rgba(64,158,255,0.45)' } : undefined
},
expandAndCollapse: true, // 开启节点折叠/展开功能
animationDuration: 300 // 展开折叠动画时长
expandAndCollapse: true,
animationDuration: 280
}
]
};
// 渲染图表
this.chartInstance?.setOption(option, true);
// ========== 核心新增绑定ECharts点击事件只对三级节点生效 ==========
this.clickEvent = (params) => {
console.log(params);
const { data, treeAncestors } = params;
// ✅ 核心判断treeAncestors是当前节点的「所有上级节点数组」
// 根节点(项目名) → 一级节点 → 三级节点 treeAncestors.length = 2 → 精准匹配第三级节点
// 层级对应关系:根节点(0级) → 一级分类(1级) → 业务节点(3级/你要的三级)
if (treeAncestors.length === 4) {
console.log(data);
this.currentNode = { ...data.value }; // 深拷贝当前节点完整数据
this.dialogVisible = true; // 打开弹窗
const data = params.data;
if (data && data.value && data.value.trackId) {
this.currentNode = { ...data.value };
this.dialogVisible = true;
}
};
// 绑定点击事件
@@ -326,6 +456,23 @@ export default {
border-radius: 8px;
}
.xmind-box--dashboard {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* 与综合看板折线图区域一致:白底、细边框、轻圆角 */
.xmind-box--dashboard .xmind-container {
flex: 1;
min-height: 0;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
}
/* 新增:弹窗内部样式美化 */
:deep(.dialog-content) {
padding: 10px 0;