oa更新项目总览

This commit is contained in:
2026-06-08 10:11:33 +08:00
parent 3334248847
commit 79e536aeca
5 changed files with 800 additions and 0 deletions

View File

@@ -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);
}
/** /**
* 获取项目管理详细信息 * 获取项目管理详细信息
* *

View File

@@ -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);
} }

View File

@@ -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());
}
} }

View File

@@ -161,4 +161,22 @@ export function getMaxCode (codeType) {
url: '/oa/project/maxCode/' + codeType, url: '/oa/project/maxCode/' + 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
})
} }

View 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-9-IVXLCDM]+\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 []
// 形式 1ossId|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>