oa更新项目总览
This commit is contained in:
@@ -98,6 +98,22 @@ public class SysOaProjectController extends BaseController {
|
|||||||
return data == null ? R.fail("项目不存在") : R.ok(data);
|
return data == null ? R.fail("项目不存在") : R.ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目总览统计卡片:总数 / 完成 / 未完成 / 逾期(仅含已绑定进度的项目)
|
||||||
|
*/
|
||||||
|
@GetMapping("/overview/stats")
|
||||||
|
public R<java.util.Map<String, Long>> overviewStats(SysOaProjectBo bo) {
|
||||||
|
return R.ok(iSysOaProjectService.getOverviewStats(bo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目总览列表:仅返回已绑定进度的项目
|
||||||
|
*/
|
||||||
|
@GetMapping("/overview/list")
|
||||||
|
public TableDataInfo<SysOaProjectVo> overviewList(SysOaProjectBo bo, PageQuery pageQuery) {
|
||||||
|
return iSysOaProjectService.queryOverviewPageList(bo, pageQuery);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取项目管理详细信息
|
* 获取项目管理详细信息
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.math.BigDecimal;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目管理Service接口
|
* 项目管理Service接口
|
||||||
@@ -108,4 +109,14 @@ public interface ISysOaProjectService {
|
|||||||
* 综合看板:项目详情 + 任务 + 进度主表 + 进度步骤(一次返回)
|
* 综合看板:项目详情 + 任务 + 进度主表 + 进度步骤(一次返回)
|
||||||
*/
|
*/
|
||||||
OaProjectDashboardVo getProjectDashboard(Long projectId);
|
OaProjectDashboardVo getProjectDashboard(Long projectId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目总览统计:总数 / 完成 / 未完成 / 逾期(仅统计已绑定进度的项目)
|
||||||
|
*/
|
||||||
|
Map<String, Long> getOverviewStats(SysOaProjectBo bo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目总览列表:仅返回已绑定进度(oa_project_schedule 有记录)的项目,分页
|
||||||
|
*/
|
||||||
|
TableDataInfo<SysOaProjectVo> queryOverviewPageList(SysOaProjectBo bo, PageQuery pageQuery);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -675,4 +675,58 @@ public class SysOaProjectServiceImpl implements ISysOaProjectService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目总览统计:总数 / 完成 / 未完成 / 逾期
|
||||||
|
* 仅统计已绑定进度(oa_project_schedule 有记录)的项目
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Map<String, Long> getOverviewStats(SysOaProjectBo bo) {
|
||||||
|
List<SysOaProjectVo> raw = queryList(bo == null ? new SysOaProjectBo() : bo);
|
||||||
|
Set<Long> withSchedule = findProjectIdsWithSchedule();
|
||||||
|
List<SysOaProjectVo> all = raw.stream()
|
||||||
|
.filter(p -> p.getProjectId() != null && withSchedule.contains(p.getProjectId()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
long total = all.size();
|
||||||
|
long completed = 0L, undone = 0L, overdue = 0L;
|
||||||
|
Date today = new Date();
|
||||||
|
for (SysOaProjectVo p : all) {
|
||||||
|
boolean done = "1".equals(p.getProjectStatus());
|
||||||
|
if (done) {
|
||||||
|
completed++;
|
||||||
|
} else {
|
||||||
|
undone++;
|
||||||
|
if (p.getFinishTime() != null && p.getFinishTime().before(today)) {
|
||||||
|
overdue++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Long> r = new LinkedHashMap<>();
|
||||||
|
r.put("total", total);
|
||||||
|
r.put("completed", completed);
|
||||||
|
r.put("undone", undone);
|
||||||
|
r.put("overdue", overdue);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目总览列表:在常规分页查询基础上加 EXISTS 子查询,
|
||||||
|
* 仅返回已经在 oa_project_schedule 中建立进度的项目。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public TableDataInfo<SysOaProjectVo> queryOverviewPageList(SysOaProjectBo bo, PageQuery pageQuery) {
|
||||||
|
QueryWrapper<SysOaProject> qw = buildAliasPQueryWrapper(bo);
|
||||||
|
qw.exists("SELECT 1 FROM oa_project_schedule sch WHERE sch.project_id = p.project_id AND sch.del_flag = '0'");
|
||||||
|
Page<SysOaProjectVo> result = baseMapper.selectVoPlus(pageQuery.build(), qw);
|
||||||
|
return TableDataInfo.build(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Long> findProjectIdsWithSchedule() {
|
||||||
|
List<OaProjectScheduleVo> all = oaProjectScheduleService.queryList(new OaProjectScheduleBo());
|
||||||
|
if (all == null) return Collections.emptySet();
|
||||||
|
return all.stream()
|
||||||
|
.map(OaProjectScheduleVo::getProjectId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,3 +162,21 @@ export function getMaxCode (codeType) {
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 项目总览:4 项统计卡片(总数/完成/未完成/逾期) */
|
||||||
|
export function getProjectOverviewStats (query) {
|
||||||
|
return request({
|
||||||
|
url: '/oa/project/overview/stats',
|
||||||
|
method: 'get',
|
||||||
|
params: query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目总览:仅返回已绑定进度的项目分页列表 */
|
||||||
|
export function listOverviewProject (query) {
|
||||||
|
return request({
|
||||||
|
url: '/oa/project/overview/list',
|
||||||
|
method: 'get',
|
||||||
|
params: query
|
||||||
|
})
|
||||||
|
}
|
||||||
701
ruoyi-ui/src/views/oa/project/overview/index.vue
Normal file
701
ruoyi-ui/src/views/oa/project/overview/index.vue
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overview-page">
|
||||||
|
<!-- 顶部统计卡片 -->
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-card stat-blue">
|
||||||
|
<div class="stat-num">{{ stats.total || 0 }}</div>
|
||||||
|
<div class="stat-label">项目总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-green">
|
||||||
|
<div class="stat-num">{{ stats.completed || 0 }}</div>
|
||||||
|
<div class="stat-label">完成</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-orange">
|
||||||
|
<div class="stat-num">{{ stats.undone || 0 }}</div>
|
||||||
|
<div class="stat-label">未完成</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-red">
|
||||||
|
<div class="stat-num">{{ stats.overdue || 0 }}</div>
|
||||||
|
<div class="stat-label">逾期</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 项目列表 -->
|
||||||
|
<el-card class="project-card" shadow="never">
|
||||||
|
<div slot="header" class="card-header">
|
||||||
|
<div class="title"><i class="el-icon-tickets" /> 项目列表</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-row">
|
||||||
|
<el-input
|
||||||
|
v-model="query.projectName"
|
||||||
|
placeholder="搜索项目编号、名称..."
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="filter-search"
|
||||||
|
@keyup.enter.native="handleQuery"
|
||||||
|
@clear="handleQuery"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="query.projectStatus"
|
||||||
|
placeholder="全部状态"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="filter-status"
|
||||||
|
@change="handleQuery"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="d in dict.type.sys_project_status"
|
||||||
|
:key="d.value"
|
||||||
|
:label="d.label"
|
||||||
|
:value="d.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="projectList"
|
||||||
|
size="small"
|
||||||
|
stripe
|
||||||
|
highlight-current-row
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<el-table-column label="编号" prop="projectNum" min-width="120">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<span class="link-cell">{{ scope.row.projectNum || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="名称" prop="projectName" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column label="阶段" min-width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag size="mini" type="info" effect="plain">
|
||||||
|
{{ currentStageOf(scope.row) || '—' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="进度" width="170" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-progress
|
||||||
|
:percentage="progressOf(scope.row)"
|
||||||
|
:stroke-width="10"
|
||||||
|
:color="progressColor(scope.row)"
|
||||||
|
:format="p => p + '%'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="负责人" prop="functionary" width="110" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<span>{{ scope.row.functionary || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="更新" prop="updateTime" width="120" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<span>{{ parseTime(scope.row.updateTime || scope.row.createTime, '{y}-{m}-{d}') }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="projectStatus" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<dict-tag :options="dict.type.sys_project_status" :value="scope.row.projectStatus" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
:page.sync="query.pageNum"
|
||||||
|
:limit.sync="query.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 项目详情面板 -->
|
||||||
|
<el-card v-if="activeProject" class="detail-card" shadow="never">
|
||||||
|
<div slot="header" class="card-header">
|
||||||
|
<div class="title">
|
||||||
|
<span class="proj-name">{{ activeProject.projectName }}</span>
|
||||||
|
<span class="proj-num"> · {{ activeProject.projectNum }}</span>
|
||||||
|
<span class="proj-tail"> — 详情</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="text" size="small" @click="closeDetail">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-if="tabNames.length" v-model="activeTab" v-loading="detailLoading">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="t in tabNames"
|
||||||
|
:key="t.key"
|
||||||
|
:name="t.key"
|
||||||
|
:label="t.label"
|
||||||
|
>
|
||||||
|
<!-- 项目文件 tab -->
|
||||||
|
<template v-if="t.key === '__files__'">
|
||||||
|
<div v-if="allFiles.length === 0" class="empty-block">暂无文件</div>
|
||||||
|
<div v-for="(f, idx) in allFiles" :key="idx" class="file-row">
|
||||||
|
<i class="el-icon-document file-icon" />
|
||||||
|
<a v-if="f.url" :href="f.url" target="_blank" class="file-name">{{ f.name }}</a>
|
||||||
|
<span v-else class="file-name no-url">{{ f.name }}</span>
|
||||||
|
<el-tag size="mini" :type="f.sourceType" effect="plain" class="file-source">
|
||||||
|
{{ f.sourceLabel }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="f.detail" class="file-detail">{{ f.detail }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 其他任务 tab -->
|
||||||
|
<template v-else-if="t.key === '__other__'">
|
||||||
|
<div v-if="otherTasks.length === 0" class="empty-block">暂无未关联进度的任务</div>
|
||||||
|
<div v-for="tk in otherTasks" :key="tk.taskId" class="task-card">
|
||||||
|
<div class="task-line-1">
|
||||||
|
<span class="task-title">{{ tk.taskTitle || '无标题任务' }}</span>
|
||||||
|
<el-tag size="mini" :type="taskStateType(tk.state)" effect="plain">
|
||||||
|
{{ taskStateLabel(tk.state) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="tk.taskGrade" size="mini" type="warning" effect="plain">{{ priorityLabel(tk.taskGrade) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="task-line-2">
|
||||||
|
<span v-if="tk.workerNickName"><i class="el-icon-user-solid" /> 执行:{{ tk.workerNickName }}</span>
|
||||||
|
<span v-if="tk.createUserNickName"><i class="el-icon-edit-outline" /> 创建:{{ tk.createUserNickName }}</span>
|
||||||
|
<span v-if="tk.beginTime || tk.finishTime">
|
||||||
|
<i class="el-icon-date" />
|
||||||
|
{{ formatRange('', tk.beginTime, tk.finishTime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tk.content" class="task-content">{{ tk.content }}</div>
|
||||||
|
<div v-if="taskFiles(tk).length" class="task-files">
|
||||||
|
<a v-for="(f, i) in taskFiles(tk)" :key="i"
|
||||||
|
:href="f.url" target="_blank" class="task-file-chip">
|
||||||
|
<i class="el-icon-paperclip" /> {{ f.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 分类 tab:列出步骤 + 步骤下的任务 -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="!stepsByTab[t.key] || stepsByTab[t.key].length === 0" class="empty-block">
|
||||||
|
此分类暂无进度记录
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="step in stepsByTab[t.key] || []"
|
||||||
|
:key="step.trackId"
|
||||||
|
class="step-block"
|
||||||
|
>
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="dot" :class="stepDotClass(step.status)" />
|
||||||
|
<span class="step-name">{{ step.secondLevelNode || step.stepName || '—' }}</span>
|
||||||
|
<el-tag size="mini" :type="stepTagType(step.status)" effect="plain" class="step-status">
|
||||||
|
{{ stepStatusLabel(step.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="step-meta">
|
||||||
|
<span v-if="step.tabNode" class="meta-chip"><i class="el-icon-collection-tag" /> {{ step.tabNode }}</span>
|
||||||
|
<span v-if="step.nodeHeader || step.header" class="meta-chip"><i class="el-icon-user" /> {{ step.nodeHeader || step.header }}</span>
|
||||||
|
<span v-if="step.planStart || step.planEnd" class="meta-chip">
|
||||||
|
<i class="el-icon-date" />
|
||||||
|
{{ formatRange('计划', step.planStart, step.planEnd) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="step.actualStart || step.actualEnd" class="meta-chip">
|
||||||
|
<i class="el-icon-check" />
|
||||||
|
{{ formatRange('实际', step.actualStart, step.actualEnd) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="step.supplierName" class="meta-chip"><i class="el-icon-office-building" /> {{ step.supplierName }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="step.specification" class="step-spec">{{ step.specification }}</div>
|
||||||
|
<div v-if="stepFiles(step).length" class="step-files">
|
||||||
|
<a v-for="(f, i) in stepFiles(step)" :key="i"
|
||||||
|
:href="f.url" target="_blank" class="task-file-chip">
|
||||||
|
<i class="el-icon-paperclip" /> {{ f.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="(tasksByTrack[step.trackId] || []).length" class="task-list">
|
||||||
|
<div v-for="tk in tasksByTrack[step.trackId]" :key="tk.taskId" class="task-card">
|
||||||
|
<div class="task-line-1">
|
||||||
|
<span class="task-title">{{ tk.taskTitle || '无标题任务' }}</span>
|
||||||
|
<el-tag size="mini" :type="taskStateType(tk.state)" effect="plain">
|
||||||
|
{{ taskStateLabel(tk.state) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="tk.taskGrade" size="mini" type="warning" effect="plain">{{ priorityLabel(tk.taskGrade) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="task-line-2">
|
||||||
|
<span v-if="tk.workerNickName"><i class="el-icon-user-solid" /> 执行:{{ tk.workerNickName }}</span>
|
||||||
|
<span v-if="tk.createUserNickName"><i class="el-icon-edit-outline" /> 创建:{{ tk.createUserNickName }}</span>
|
||||||
|
<span v-if="tk.beginTime || tk.finishTime">
|
||||||
|
<i class="el-icon-date" />
|
||||||
|
{{ parseTime(tk.beginTime, '{y}-{m}-{d}') || '?' }} ~ {{ parseTime(tk.finishTime, '{y}-{m}-{d}') || '?' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="tk.content" class="task-content">{{ tk.content }}</div>
|
||||||
|
<div v-if="taskFiles(tk).length" class="task-files">
|
||||||
|
<a v-for="(f, i) in taskFiles(tk)" :key="i"
|
||||||
|
:href="f.url" target="_blank" class="task-file-chip">
|
||||||
|
<i class="el-icon-paperclip" /> {{ f.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<div v-else-if="!detailLoading" class="empty-block">此项目暂无进度数据</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { listOverviewProject, getProjectDashboard, getProjectOverviewStats } from '@/api/oa/project'
|
||||||
|
import { listByIds as listOssByIds } from '@/api/system/oss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ProjectOverview',
|
||||||
|
dicts: ['sys_project_status'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
detailLoading: false,
|
||||||
|
projectList: [],
|
||||||
|
total: 0,
|
||||||
|
query: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
projectName: undefined,
|
||||||
|
projectStatus: undefined
|
||||||
|
},
|
||||||
|
stats: { total: 0, completed: 0, undone: 0, overdue: 0 },
|
||||||
|
activeProject: null,
|
||||||
|
activeTab: '',
|
||||||
|
dashboard: null,
|
||||||
|
ossMap: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/** 当前项目所有 step(顺序保留) */
|
||||||
|
allSteps () {
|
||||||
|
const list = (this.dashboard && this.dashboard.steps) || []
|
||||||
|
return list.slice().sort((a, b) => (a.sortNum || 0) - (b.sortNum || 0))
|
||||||
|
},
|
||||||
|
allTasks () {
|
||||||
|
return (this.dashboard && this.dashboard.tasks) || []
|
||||||
|
},
|
||||||
|
/** step 按 firstLevelNode 分组(这才是用户口中的"分类") */
|
||||||
|
stepsByTab () {
|
||||||
|
const map = {}
|
||||||
|
for (const s of this.allSteps) {
|
||||||
|
const k = this.normalizeCategory(s.firstLevelNode) || '未分类'
|
||||||
|
if (!map[k]) map[k] = []
|
||||||
|
map[k].push(s)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
},
|
||||||
|
/** task 按 trackId 分组(仅有 trackId 的) */
|
||||||
|
tasksByTrack () {
|
||||||
|
const map = {}
|
||||||
|
for (const t of this.allTasks) {
|
||||||
|
if (t.trackId) {
|
||||||
|
if (!map[t.trackId]) map[t.trackId] = []
|
||||||
|
map[t.trackId].push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
},
|
||||||
|
/** 没有 trackId 的任务,归到「其他任务」 */
|
||||||
|
otherTasks () {
|
||||||
|
return this.allTasks.filter(t => !t.trackId)
|
||||||
|
},
|
||||||
|
/** 动态 tab:分类 + 其他任务 + 项目文件 */
|
||||||
|
tabNames () {
|
||||||
|
const keys = Object.keys(this.stepsByTab)
|
||||||
|
const tabs = keys.map(k => ({ key: k, label: k }))
|
||||||
|
tabs.push({ key: '__other__', label: `其他任务 (${this.otherTasks.length})` })
|
||||||
|
tabs.push({ key: '__files__', label: `项目文件 (${this.allFiles.length})` })
|
||||||
|
return tabs
|
||||||
|
},
|
||||||
|
/** 汇总所有文件,每条附带来源 */
|
||||||
|
allFiles () {
|
||||||
|
const out = []
|
||||||
|
const push = (raw, sourceType, sourceLabel, detail) => {
|
||||||
|
for (const f of this.parseFiles(raw)) {
|
||||||
|
out.push({ ...this.resolveFile(f), sourceType, sourceLabel, detail })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const proj = this.dashboard && this.dashboard.project
|
||||||
|
if (proj) {
|
||||||
|
push(proj.accessory, 'success', '项目附件', proj.projectName)
|
||||||
|
push(proj.closureFiles, 'success', '项目结项', proj.projectName)
|
||||||
|
}
|
||||||
|
for (const s of this.allSteps) {
|
||||||
|
const stepLabel = s.secondLevelNode || s.stepName || '步骤'
|
||||||
|
const stepCat = s.firstLevelNode ? this.normalizeCategory(s.firstLevelNode) : ''
|
||||||
|
const detail = stepCat ? `${stepCat} / ${stepLabel}` : stepLabel
|
||||||
|
push(s.accessory, 'primary', '步骤附件', detail)
|
||||||
|
push(s.relatedDocs, 'primary', '相关资料', detail)
|
||||||
|
push(s.relatedImages, 'primary', '相关图片', detail)
|
||||||
|
push(s.requirementFile, 'primary', '需求文件', detail)
|
||||||
|
push(s.other, 'primary', '其他', detail)
|
||||||
|
}
|
||||||
|
for (const t of this.allTasks) {
|
||||||
|
const detail = t.taskTitle || '任务'
|
||||||
|
push(t.accessory, 'warning', '任务附件', detail)
|
||||||
|
push(t.files, 'warning', '任务文件', detail)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.getList()
|
||||||
|
this.refreshStats()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleQuery () {
|
||||||
|
this.query.pageNum = 1
|
||||||
|
this.getList()
|
||||||
|
this.refreshStats()
|
||||||
|
},
|
||||||
|
refreshStats () {
|
||||||
|
getProjectOverviewStats({
|
||||||
|
projectName: this.query.projectName,
|
||||||
|
projectStatus: this.query.projectStatus
|
||||||
|
}).then(res => {
|
||||||
|
if (res && res.data) this.stats = res.data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getList () {
|
||||||
|
this.loading = true
|
||||||
|
listOverviewProject(this.query).then(res => {
|
||||||
|
this.projectList = res.rows || []
|
||||||
|
this.total = res.total || 0
|
||||||
|
}).finally(() => { this.loading = false })
|
||||||
|
},
|
||||||
|
onRowClick (row) {
|
||||||
|
if (!row || !row.projectId) return
|
||||||
|
this.activeProject = row
|
||||||
|
this.loadDashboard(row.projectId)
|
||||||
|
},
|
||||||
|
closeDetail () {
|
||||||
|
this.activeProject = null
|
||||||
|
this.dashboard = null
|
||||||
|
this.activeTab = ''
|
||||||
|
},
|
||||||
|
loadDashboard (projectId) {
|
||||||
|
this.detailLoading = true
|
||||||
|
this.dashboard = null
|
||||||
|
this.activeTab = ''
|
||||||
|
this.ossMap = {}
|
||||||
|
getProjectDashboard(projectId).then(res => {
|
||||||
|
this.dashboard = res.data || null
|
||||||
|
// 默认选中第一个 tab
|
||||||
|
const t = this.tabNames[0]
|
||||||
|
this.activeTab = t ? t.key : ''
|
||||||
|
// 异步拉所有 ossId 对应的真实文件名 / URL
|
||||||
|
this.loadOssInfo()
|
||||||
|
}).finally(() => { this.detailLoading = false })
|
||||||
|
},
|
||||||
|
/** 扫描 dashboard 里所有附件字段,集中调一次 listByIds 拉文件名/URL */
|
||||||
|
loadOssInfo () {
|
||||||
|
if (!this.dashboard) return
|
||||||
|
const ids = new Set()
|
||||||
|
const collect = raw => this.parseFiles(raw).forEach(f => { if (f.ossId) ids.add(f.ossId) })
|
||||||
|
const proj = this.dashboard.project
|
||||||
|
if (proj) { collect(proj.accessory); collect(proj.closureFiles) }
|
||||||
|
for (const s of (this.dashboard.steps || [])) {
|
||||||
|
collect(s.accessory); collect(s.relatedDocs); collect(s.relatedImages)
|
||||||
|
collect(s.requirementFile); collect(s.other)
|
||||||
|
}
|
||||||
|
for (const t of (this.dashboard.tasks || [])) {
|
||||||
|
collect(t.accessory); collect(t.files)
|
||||||
|
}
|
||||||
|
if (ids.size === 0) return
|
||||||
|
listOssByIds(Array.from(ids).join(',')).then(res => {
|
||||||
|
if (!res || !Array.isArray(res.data)) return
|
||||||
|
const m = {}
|
||||||
|
for (const f of res.data) m[String(f.ossId)] = f
|
||||||
|
this.ossMap = m
|
||||||
|
}).catch(err => { console.warn('加载文件信息失败', err) })
|
||||||
|
},
|
||||||
|
/** 把解析出的文件对象补全名字/URL(若仅有 ossId 则查 ossMap) */
|
||||||
|
resolveFile (f) {
|
||||||
|
const meta = f.ossId ? this.ossMap[String(f.ossId)] : null
|
||||||
|
const name = f.name && f.name !== f.ossId
|
||||||
|
? f.name
|
||||||
|
: (meta && (meta.originalName || meta.fileName)) || (f.ossId ? '加载中…' : (f.name || '附件'))
|
||||||
|
const url = f.url || (meta && meta.url) || ''
|
||||||
|
return { ...f, name, url }
|
||||||
|
},
|
||||||
|
/** 去掉 "一、" "二、" "1." "(一)" 等中/西文序号前缀 */
|
||||||
|
normalizeCategory (raw) {
|
||||||
|
if (!raw) return ''
|
||||||
|
// 先去首尾空白
|
||||||
|
let s = String(raw).trim()
|
||||||
|
// 匹配前缀:中文数字+、 / 阿拉伯数字+. / 括号包裹的序号 / 罗马数字
|
||||||
|
s = s.replace(/^[((]?\s*[一二三四五六七八九十百千万0-90-9IVXLCDM]+\s*[))]?\s*[、..::·\-—]?\s*/u, '')
|
||||||
|
return s.trim() || raw
|
||||||
|
},
|
||||||
|
rowClassName ({ row }) {
|
||||||
|
return this.activeProject && row.projectId === this.activeProject.projectId
|
||||||
|
? 'row-active' : ''
|
||||||
|
},
|
||||||
|
/** 当前阶段:取第一个未完成步骤的 tabNode/firstLevelNode 作为"阶段" */
|
||||||
|
currentStageOf (row) {
|
||||||
|
// 没拉过 dashboard 的项目直接拿不到,先用 projectType 或空
|
||||||
|
return row.projectType || ''
|
||||||
|
},
|
||||||
|
progressOf (row) {
|
||||||
|
// 简易:状态 1=100、0=可粗略按 currentStep / step 总数
|
||||||
|
if (row.projectStatus === '1') return 100
|
||||||
|
// 暂时按是否到期粗算
|
||||||
|
if (!row.beginTime || !row.finishTime) return 30
|
||||||
|
const start = new Date(row.beginTime).getTime()
|
||||||
|
const end = new Date(row.finishTime).getTime()
|
||||||
|
const now = Date.now()
|
||||||
|
if (now <= start) return 5
|
||||||
|
if (now >= end) return 95
|
||||||
|
return Math.max(5, Math.min(95, Math.round((now - start) / (end - start) * 100)))
|
||||||
|
},
|
||||||
|
progressColor (row) {
|
||||||
|
if (row.projectStatus === '1') return '#67c23a'
|
||||||
|
if (row.finishTime && new Date(row.finishTime).getTime() < Date.now()) return '#f56c6c'
|
||||||
|
return '#409eff'
|
||||||
|
},
|
||||||
|
stepDotClass (status) {
|
||||||
|
const s = Number(status)
|
||||||
|
if (s === 2) return 'dot-done'
|
||||||
|
if (s === 1) return 'dot-doing'
|
||||||
|
if (s === 3) return 'dot-pause'
|
||||||
|
return 'dot-todo'
|
||||||
|
},
|
||||||
|
stepTagType (status) {
|
||||||
|
const s = Number(status)
|
||||||
|
if (s === 2) return 'success'
|
||||||
|
if (s === 1) return 'primary'
|
||||||
|
if (s === 3) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
},
|
||||||
|
stepStatusLabel (status) {
|
||||||
|
const s = Number(status)
|
||||||
|
if (s === 2) return '已完成'
|
||||||
|
if (s === 1) return '进行中'
|
||||||
|
if (s === 3) return '暂停'
|
||||||
|
return '未开始'
|
||||||
|
},
|
||||||
|
taskStateLabel (state) {
|
||||||
|
const s = Number(state)
|
||||||
|
if (s === 2) return '已完成'
|
||||||
|
if (s === 1) return '进行中'
|
||||||
|
if (s === 3) return '已延期'
|
||||||
|
return '待开始'
|
||||||
|
},
|
||||||
|
taskStateType (state) {
|
||||||
|
const s = Number(state)
|
||||||
|
if (s === 2) return 'success'
|
||||||
|
if (s === 1) return 'primary'
|
||||||
|
if (s === 3) return 'danger'
|
||||||
|
return 'info'
|
||||||
|
},
|
||||||
|
/** 时间区间格式化:缺一边时显示"开始"/"完成",都缺则空 */
|
||||||
|
formatRange (prefix, start, end) {
|
||||||
|
const s = start ? this.parseTime(start, '{y}-{m}-{d}') : ''
|
||||||
|
const e = end ? this.parseTime(end, '{y}-{m}-{d}') : ''
|
||||||
|
const pfx = prefix ? prefix + ' ' : ''
|
||||||
|
if (s && e) return `${pfx}${s} ~ ${e}`
|
||||||
|
if (s && !e) return `${pfx}开始 ${s}`
|
||||||
|
if (!s && e) return `${pfx}完成 ${e}`
|
||||||
|
return ''
|
||||||
|
},
|
||||||
|
priorityLabel (g) {
|
||||||
|
const map = { 1: '低', 2: '中', 3: '高', 4: '紧急' }
|
||||||
|
return map[String(g)] || g
|
||||||
|
},
|
||||||
|
/** 解析后端附件字段
|
||||||
|
* 兼容 3 种存储格式:
|
||||||
|
* - `ossId|name|url,,ossId|name|url`
|
||||||
|
* - 逗号分隔的纯 ossId(数字串)
|
||||||
|
* - 逗号分隔的 url
|
||||||
|
*/
|
||||||
|
parseFiles (raw) {
|
||||||
|
if (!raw) return []
|
||||||
|
const str = String(raw).trim()
|
||||||
|
if (!str) return []
|
||||||
|
// 形式 1:ossId|name|url,,...
|
||||||
|
if (str.includes('|') || str.includes(',,')) {
|
||||||
|
return str.split(',,').map(s => {
|
||||||
|
const [ossId, name, url] = s.split('|')
|
||||||
|
return { ossId, name: name || '', url: url || '' }
|
||||||
|
}).filter(f => f.ossId || f.url || f.name)
|
||||||
|
}
|
||||||
|
// 形式 2/3:逗号分隔
|
||||||
|
return str.split(',').map(u => u.trim()).filter(Boolean).map(u => {
|
||||||
|
if (/^\d{6,}$/.test(u)) {
|
||||||
|
// 纯数字串:是 ossId
|
||||||
|
return { ossId: u, name: '', url: '' }
|
||||||
|
}
|
||||||
|
return { name: u.split('/').pop() || '附件', url: u }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
taskFiles (task) {
|
||||||
|
return this.parseFiles(task.accessory).concat(this.parseFiles(task.files)).map(this.resolveFile)
|
||||||
|
},
|
||||||
|
stepFiles (step) {
|
||||||
|
return [].concat(
|
||||||
|
this.parseFiles(step.accessory),
|
||||||
|
this.parseFiles(step.relatedDocs),
|
||||||
|
this.parseFiles(step.relatedImages),
|
||||||
|
this.parseFiles(step.requirementFile),
|
||||||
|
this.parseFiles(step.other)
|
||||||
|
).map(this.resolveFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.overview-page {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
|
border-left: 3px solid #409eff;
|
||||||
|
.stat-num { font-size: 18px; font-weight: 600; line-height: 1.2; color: #303133; }
|
||||||
|
.stat-label { margin-top: 2px; color: #909399; font-size: 12px; }
|
||||||
|
&.stat-blue { border-left-color: #409eff; }
|
||||||
|
&.stat-green { border-left-color: #67c23a; }
|
||||||
|
&.stat-orange { border-left-color: #e6a23c; }
|
||||||
|
&.stat-red { border-left-color: #f56c6c; }
|
||||||
|
}
|
||||||
|
.project-card, .detail-card {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
::v-deep .el-card__header { padding: 8px 12px; }
|
||||||
|
::v-deep .el-card__body { padding: 10px 12px; }
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
.title { font-weight: 600; color: #303133; }
|
||||||
|
.proj-num { color: #909399; font-weight: normal; }
|
||||||
|
.proj-tail { color: #909399; font-weight: normal; }
|
||||||
|
}
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
.filter-search { flex: 1; max-width: 360px; }
|
||||||
|
.filter-status { width: 140px; }
|
||||||
|
}
|
||||||
|
.link-cell { color: #409eff; }
|
||||||
|
|
||||||
|
::v-deep .row-active td { background: #ecf5ff !important; }
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
text-align: center;
|
||||||
|
color: #c0c4cc;
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
.step-block {
|
||||||
|
position: relative;
|
||||||
|
padding: 6px 8px 6px 18px;
|
||||||
|
border-left: 2px solid #ebeef5;
|
||||||
|
margin-left: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
.dot {
|
||||||
|
width: 10px; height: 10px; border-radius: 50%;
|
||||||
|
background: #c0c4cc;
|
||||||
|
margin-left: -23px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px #dcdfe6;
|
||||||
|
&.dot-done { background: #67c23a; }
|
||||||
|
&.dot-doing { background: #409eff; }
|
||||||
|
&.dot-pause { background: #e6a23c; }
|
||||||
|
&.dot-todo { background: #c0c4cc; }
|
||||||
|
}
|
||||||
|
.step-time { color: #909399; font-size: 12px; }
|
||||||
|
.step-name { font-weight: 500; color: #303133; }
|
||||||
|
.step-status { margin-left: auto; }
|
||||||
|
}
|
||||||
|
.step-sub { color: #909399; font-size: 12px; margin-top: 2px; padding-left: 0; }
|
||||||
|
.step-spec { color: #606266; font-size: 13px; margin-top: 4px; }
|
||||||
|
.step-meta {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
.meta-chip {
|
||||||
|
font-size: 12px; color: #606266;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
border-radius: 3px;
|
||||||
|
i { margin-right: 3px; color: #909399; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.step-files { margin-top: 4px; }
|
||||||
|
.task-list { margin-top: 6px; padding-left: 4px; }
|
||||||
|
.task-card {
|
||||||
|
background: #f7faff;
|
||||||
|
border-left: 3px solid #409eff;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.task-line-1 {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
.task-title { font-weight: 500; color: #303133; flex: 1; }
|
||||||
|
}
|
||||||
|
.task-line-2 {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 12px;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #909399; font-size: 12px;
|
||||||
|
i { margin-right: 2px; }
|
||||||
|
}
|
||||||
|
.task-content {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #606266; font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 60px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.task-files, .step-files {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.task-file-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover { background: #d9ecff; }
|
||||||
|
}
|
||||||
|
.file-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px dashed #ebeef5;
|
||||||
|
font-size: 13px;
|
||||||
|
&:hover { background: #fafafa; }
|
||||||
|
.file-icon { color: #909399; }
|
||||||
|
.file-name { color: #409eff; flex: 1; text-decoration: none;
|
||||||
|
&:hover { text-decoration: underline; }
|
||||||
|
&.no-url { color: #606266; }
|
||||||
|
}
|
||||||
|
.file-source { margin-left: auto; }
|
||||||
|
.file-detail { color: #909399; font-size: 12px; min-width: 0; max-width: 240px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user