feat: 添加项目进度统计功能,支持在列表中显示各项目的进度步骤统计信息,以及跳转

This commit is contained in:
2026-04-23 12:47:23 +08:00
parent 335dc88a2a
commit db90e2a084
12 changed files with 480 additions and 18 deletions

View File

@@ -27,13 +27,18 @@
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
class="project-progress-row"
:title="'当前进度:' + projectListScheduleLine(p)"
>
<span class="project-progress-label">当前进度</span><span class="project-progress-value">{{ projectListScheduleLine(p) }}</span>
</div>
</div>
<div v-if="!projectList || projectList.length === 0" class="left-empty">
@@ -99,7 +104,11 @@
: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="taskTitle" title="任务主题" min-width="96">
<template #default="{ row }">
<span class="dashboard-link" @click.stop="goToTaskCenter(row)">{{ row.taskTitle || '-' }}</span>
</template>
</vxe-column>
<vxe-column field="scheduleProgress" title="对应进度" min-width="112">
<template #default="{ row }">
<div v-if="scheduleProgressUnlinked(row)" class="schedule-progress-wrap">
@@ -109,6 +118,7 @@
v-else
class="schedule-progress-wrap schedule-progress-wrap--linked"
:title="scheduleProgressTitle(row)"
@click.stop="goToPaceFromTaskRow(row)"
>
<el-tag :type="scheduleStepTagType(row)" size="mini" class="schedule-progress-status-tag">
{{ scheduleStepStatusLabel(row) }}
@@ -167,12 +177,7 @@
<el-col :span="12">
<div style="font-size: small;">
<span style="color:#d0d0d0 ">项目名</span>
<el-popover placement="bottom" trigger="hover" width="800">
<template slot="reference">
<span style="color: #409eff;">{{ projectDisplayName }}</span>
</template>
<ProjectInfo :info="projectDetail || {}" />
</el-popover>
<span style="color: #409eff;">{{ projectDisplayName }}</span>
</div>
</el-col>
<el-col :span="12">
@@ -217,7 +222,11 @@
>
<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 prop="stepName" label="步骤名称" min-width="128" show-overflow-tooltip>
<template slot-scope="{ row }">
<el-button type="text" size="mini" @click="goToPaceFromStepRow(row)">{{ row.stepName || '-' }}</el-button>
</template>
</el-table-column>
<el-table-column label="状态" width="92" align="center">
<template slot-scope="{ row }">
<el-tag :type="stepStatusTagType(row)" size="mini">{{ stepStatusLabel(row) }}</el-tag>
@@ -282,11 +291,10 @@
<script>
import { mapState } from 'vuex'
import Xmind from '@/views/oa/project/pace/components/xmind.vue'
import ProjectInfo from '@/components/fad-service/ProjectInfo/index.vue'
import { resolveOaPaceCenterPath, resolveOaTaskCenterPath } from '@/utils/oaMenuNavigate'
export default {
name: 'OaProjectDashboard2',
components: { Xmind, ProjectInfo },
components: { Xmind },
dicts: ['sys_work_type', 'sys_sort_grade'],
data () {
return {
@@ -497,6 +505,67 @@ export default {
await this.refreshCurrent()
},
goToTaskCenter (row) {
if (!row || row.taskId == null || row.taskId === '') {
return
}
const path = resolveOaTaskCenterPath(this)
this.$router.push({ path, query: { taskId: String(row.taskId) } })
},
resolveScheduleIdByTaskTrack (trackId) {
if (trackId == null || trackId === '') {
return null
}
const hit = (this.stepList || []).find((s) => String(s.trackId) === String(trackId))
return hit && hit.scheduleId != null ? hit.scheduleId : null
},
goToPaceFromTaskRow (row) {
if (this.scheduleProgressUnlinked(row)) {
this.$message.warning('该任务未关联进度')
return
}
const scheduleId = this.resolveScheduleIdByTaskTrack(row.trackId)
if (!scheduleId || !this.currentProjectId) {
this.$message.warning('未找到对应进度主表')
return
}
const path = resolveOaPaceCenterPath(this)
this.$router.push({
path,
query: {
projectId: String(this.currentProjectId),
scheduleId: String(scheduleId),
trackId: String(row.trackId),
tabNode: row.tabNode != null ? String(row.tabNode) : '',
firstLevelNode: row.firstLevelNode != null ? String(row.firstLevelNode) : ''
}
})
},
goToPaceFromStepRow (row) {
if (!row || row.scheduleId == null || row.scheduleId === '' || !this.currentProjectId) {
this.$message.warning('缺少进度或项目信息')
return
}
const query = {
projectId: String(this.currentProjectId),
scheduleId: String(row.scheduleId)
}
if (row.trackId != null && row.trackId !== '') {
query.trackId = String(row.trackId)
}
if (row.tabNode) {
query.tabNode = String(row.tabNode)
}
if (row.firstLevelNode) {
query.firstLevelNode = String(row.firstLevelNode)
}
const path = resolveOaPaceCenterPath(this)
this.$router.push({ path, query })
},
noop () {},
/** 无 track_id 或无步骤名称 → 未关联(后端联表 oa_project_schedule_stepuse_flag=1 */
@@ -542,6 +611,14 @@ export default {
return this.scheduleStepTagType({ scheduleStatus: row && row.status })
},
/** 左侧列表:与进度中心一致的「当前进度」文案(数据来自 list scheduleStats */
projectListScheduleLine (p) {
const total = Number(p && p.scheduleStepTotal != null ? p.scheduleStepTotal : 0)
const done = Number(p && p.scheduleStepCompleted != null ? p.scheduleStepCompleted : 0)
const pend = Number(p && p.scheduleStepPendingAccept != null ? p.scheduleStepPendingAccept : 0)
return `已完成(${done}+ 待验收(${pend} / 总节点数(${total}`
},
formatDate (val) {
if (!val) return '-'
const s = String(val)
@@ -631,13 +708,34 @@ export default {
border-bottom: 1px solid #eef0f3;
border-radius: 4px;
margin-bottom: 2px;
min-height: 42px;
min-height: 52px;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
transition: background 0.15s ease;
}
/* 单行展示:缩小字号 + 不换行,仍过长则省略号(悬停 title 看全文) */
.project-progress-row {
margin-top: 3px;
min-width: 0;
font-size: 10px;
line-height: 1.35;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-progress-label {
color: #909399;
}
.project-progress-value {
color: #303133;
}
.project-item:hover {
background: #f0f2f5;
}
@@ -883,6 +981,16 @@ export default {
.schedule-progress-wrap--linked {
min-width: 0;
cursor: pointer;
}
.dashboard-link {
color: #409eff;
cursor: pointer;
}
.dashboard-link:hover {
text-decoration: underline;
}
.schedule-progress-status-tag {

View File

@@ -87,6 +87,15 @@ export default {
tabNode: this.defaultTabNode,
firstLevelNode: this.defaultFirstLevelNode
});
},
/** 外部(路由深链等)同步选中进度类别与一级分类,并通知父级更新筛选 */
setSelection (tabNode, firstLevelNode) {
this.defaultTabNode = tabNode != null ? String(tabNode) : "";
this.defaultFirstLevelNode = firstLevelNode != null ? String(firstLevelNode) : "";
this.$emit("change", {
tabNode: this.defaultTabNode,
firstLevelNode: this.defaultFirstLevelNode
});
}
}
};

View File

@@ -107,6 +107,11 @@ export default {
type: Boolean | Number,
default: false
},
/** 打开进度详情时由路由传入定位左侧分类与表格筛选tabNode / firstLevelNode / trackId */
initialStepFocus: {
type: Object,
default: null
}
},
components: {
StepTable,
@@ -289,8 +294,44 @@ export default {
},
immediate: true
},
initialStepFocus: {
handler () {
this.$nextTick(() => this.applyInitialStepFocus());
},
deep: true
}
},
methods: {
applyInitialStepFocus () {
const hint = this.initialStepFocus;
if (!hint || !this.projectScheduleStepList.length) {
return;
}
let tabNode = hint.tabNode != null ? String(hint.tabNode) : "";
let firstLevelNode = hint.firstLevelNode != null ? String(hint.firstLevelNode) : "";
if ((!tabNode || !firstLevelNode) && hint.trackId != null && hint.trackId !== "") {
const st = this.projectScheduleStepList.find(
(s) => String(s.trackId) === String(hint.trackId)
);
if (st) {
tabNode = st.tabNode != null ? String(st.tabNode) : tabNode;
firstLevelNode = st.firstLevelNode != null ? String(st.firstLevelNode) : firstLevelNode;
}
}
if (!tabNode) {
return;
}
this.viewMode = "table";
this.$nextTick(() => {
const menu = this.$refs.menuSelectRef;
if (menu && typeof menu.setSelection === "function") {
menu.setSelection(tabNode, firstLevelNode);
} else {
this.defaultTabNode = tabNode;
this.defaultFirstLevelNode = firstLevelNode;
}
});
},
/** 查询项目进度步骤跟踪列表 */
getList () {
this.loading = true;
@@ -298,6 +339,7 @@ export default {
this.projectScheduleStepList = response.rows;
this.total = response.total;
this.loading = false;
this.$nextTick(() => this.applyInitialStepFocus());
});
},
handleOverview () {

View File

@@ -140,7 +140,8 @@
<div style="padding:0 20px">
<project-schedule-step :scheduleId="scheduleDetail.scheduleId" :master="scheduleDetail.functionary"
:projectName="scheduleDetail.projectName" :projectStatus="scheduleDetail.projectStatus"
:isTop="scheduleDetail.isTop" :projectId="scheduleDetail.projectId" />
:isTop="scheduleDetail.isTop" :projectId="scheduleDetail.projectId"
:initial-step-focus="scheduleStepFocusHint" />
</div>
</el-drawer>
@@ -153,7 +154,7 @@
</template>
<script>
import { listProject } from "@/api/oa/project";
import { addByProjectId, delProjectSchedule, listProjectSchedule, updateProjectSchedule } from "@/api/oa/projectSchedule";
import { addByProjectId, delProjectSchedule, getProjectSchedule, listProjectSchedule, updateProjectSchedule } from "@/api/oa/projectSchedule";
import { listUser } from "@/api/system/user";
import ProjectSelect from "@/components/fad-service/ProjectSelect/index.vue";
import UserSelect from "@/components/UserSelect/index.vue";
@@ -194,12 +195,34 @@ export default {
recentProjects: [],
scheduleDetail: {},
userList: [],
postponeDrawer: false
postponeDrawer: false,
/** 综合看板等深链:打开抽屉后传给 step用于选中进度类别/一级节点 */
scheduleStepFocusHint: null
};
},
watch: {
'$route.query': {
handler (newQ, oldQ) {
this.applyPaceRouteQueryBeforeFetch();
const n = newQ || {};
const o = oldQ || {};
if (n.scheduleId != null && n.scheduleId !== '') {
this.handleQuery();
return;
}
const np = n.projectId != null ? String(n.projectId) : '';
const op = o.projectId != null ? String(o.projectId) : '';
if (np !== '' && np !== op) {
this.handleQuery();
}
},
deep: true
}
},
mounted () {
this.currentUser = this.$store.state.user
this.applyPaceRouteQueryBeforeFetch();
this.getList();
this.getProjectList();
this.getAllUser();
@@ -209,8 +232,59 @@ export default {
}
},
methods: {
applyPaceRouteQueryBeforeFetch () {
const q = this.$route.query || {};
if (q.projectId != null && q.projectId !== '') {
this.queryParams.projectId = q.projectId;
}
},
clearPaceDeepLinkQuery () {
const q = { ...(this.$route.query || {}) };
delete q.scheduleId;
delete q.trackId;
delete q.tabNode;
delete q.firstLevelNode;
if (Object.keys(q).length) {
this.$router.replace({ path: this.$route.path, query: q });
} else {
this.$router.replace({ path: this.$route.path });
}
},
async maybeOpenScheduleFromRoute () {
const q = this.$route.query || {};
if (!q.scheduleId) {
return;
}
let row = this.scheduleList.find((s) => String(s.scheduleId) === String(q.scheduleId));
if (!row) {
try {
const res = await getProjectSchedule(q.scheduleId);
row = res.data;
} catch (e) {
this.$modal.msgError('未找到该进度或无权访问');
return;
}
}
if (!row) {
return;
}
const hasFocus =
(q.trackId != null && q.trackId !== '') ||
(q.tabNode != null && q.tabNode !== '') ||
(q.firstLevelNode != null && q.firstLevelNode !== '');
this.scheduleStepFocusHint = hasFocus
? {
trackId: q.trackId != null ? String(q.trackId) : '',
tabNode: q.tabNode != null ? String(q.tabNode) : '',
firstLevelNode: q.firstLevelNode != null ? String(q.firstLevelNode) : ''
}
: null;
this.getScheduleDetail(row);
this.$nextTick(() => this.clearPaceDeepLinkQuery());
},
// 关闭细节窗口
closeDetailShow (done) {
this.scheduleStepFocusHint = null;
this.getList();
done()
},
@@ -273,6 +347,7 @@ export default {
this.recentProjects = cache
/* 3. 结束 loading */
this.loading = false
this.maybeOpenScheduleFromRoute()
})
},
getProjectList () {
@@ -293,6 +368,7 @@ export default {
})
},
handleDetail (row) {
this.scheduleStepFocusHint = null;
// 把当前项目放到数组最前面,去重
const list = [row, ...this.recentProjects.filter(p => p.projectId !== row.projectId)];
// 只保留前 2 条