Files
projectSystem/ruoyi-ui/src/views/index.vue
2025-09-07 15:59:40 +08:00

839 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="dashboard-container">
<!-- 页面标题 -->
<div class="page-header">
<h1>数据分析仪表盘</h1>
<div class="header-actions">
<el-button type="primary" @click="refreshData">
<i class="el-icon-refresh"></i>
刷新数据
</el-button>
</div>
</div>
<!-- 数据概览卡片 -->
<div class="overview-cards">
<div class="card-item">
<div class="card-icon card-icon-blue">
<i class="el-icon-user"></i>
</div>
<div class="card-content">
<div class="card-title">总成员数</div>
<div class="card-value">{{ totalMembers }}</div>
</div>
</div>
<div class="card-item">
<div class="card-icon card-icon-green">
<i class="el-icon-folder"></i>
</div>
<div class="card-content">
<div class="card-title">总项目数</div>
<div class="card-value">{{ totalProjects }}</div>
</div>
</div>
<div class="card-item">
<div class="card-icon card-icon-orange">
<i class="el-icon-warning"></i>
</div>
<div class="card-content">
<div class="card-title">逾期项目模块</div>
<div class="card-value">{{ overdueProjects }}</div>
</div>
</div>
<div class="card-item">
<div class="card-icon card-icon-purple">
<i class="el-icon-checked"></i>
</div>
<div class="card-content">
<div class="card-title">完成率</div>
<div class="card-value">{{ completionRate }}%</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 左侧区域 -->
<div class="left-section">
<!-- 成员表格 -->
<div class="panel">
<div class="panel-header">
<h2>成员任务完成情况</h2>
<el-input v-model="searchMember" placeholder="搜索成员姓名" clearable @input="filterMembers" style="width: 200px;" />
<el-button type="text" @click="viewAllMembers">查看全部</el-button>
</div>
<div class="panel-body">
<el-table
:data="filteredMembersData"
height="280"
border
style="width: 100%"
@row-click="userClick"
>
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column prop="date" label="已完成" width="100" />
<el-table-column prop="address" label="未完成" />
<el-table-column label="完成率" width="120">
<template slot-scope="scope">
<el-progress
:percentage="calculateRate(scope.row.date, scope.row.address)"
:stroke-width="6"
:show-text="true"
:color="getRateColor(calculateRate(scope.row.date, scope.row.address))"
/>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 项目表格 -->
<div class="panel">
<div class="panel-header">
<h2>项目状态</h2>
<el-input v-model="searchProject" placeholder="搜索项目名称" clearable style="width: 200px; margin-right: 10px;" />
<el-tabs v-model="activeTab" type="card" size="small">
<el-tab-pane label="全部" name="all" />
<el-tab-pane label="已完成" name="completed" />
<el-tab-pane label="未完成" name="pending" />
</el-tabs>
</div>
<div class="panel-body">
<el-table
:data="getProjectData()"
height="280"
border
style="width: 100%"
@row-click="projectClick"
>
<el-table-column prop="name" label="项目" width="160" />
<el-table-column prop="ownerNick" label="负责人" width="120" />
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column prop="date" label="逾期(量)" />
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-tag
:type="scope.row.status === 'completed' ? 'success' : 'danger'"
size="small"
>
{{ scope.row.statusText || (scope.row.status === 'completed' ? '已完成' : '未完成') }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<!-- 中间区域 -->
<div class="center-section">
<!-- 流量来源柱状图 -->
<!-- <div class="panel"> -->
<!-- <div class="panel-header">
<h2>流量来源占比分析</h2>
</div> -->
<!-- <div class="panel-body">
<div id="trafficChart" style="width: 100%; height: 300px;"></div>
</div>
</div> -->
<!-- 任务完成趋势 -->
<div class="panel">
<div class="panel-header">
<h2>任务完成趋势</h2>
</div>
<div class="panel-body">
<div id="trendChart" style="width: 100%; height: 300px;"></div>
</div>
</div>
</div>
<!-- 右侧区域已移除任务完成率分布与项目类型分布图表 -->
<div class="right-section"></div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts';
import { listProject } from '@/api/project/project'
import { listModule } from '@/api/project/module'
import { listUser } from '@/api/system/user'
export default {
name: 'DataAnalysisDashboard',
data() {
return {
// 日期范围
dateRange: [],
// 标签页
activeTab: 'all',
// 成员完成情况表数据name: 成员名, date: 已完成数量, address: 未完成数量)
membersData: [],
// 项目状态表数据name: 项目名, date: 逾期量, status: 'completed'|'pending', projectId, ownerNick, createTime, statusText
projectsData: [],
// 模块与项目原始数据(可选持有)
_allModules: [],
_allProjects: [],
// 统计数据
totalMembers: 0,
totalProjects: 0,
overdueProjects: 0,
completionRate: 0,
searchMember: '',
searchProject: ''
};
},
computed: {
filteredMembersData() {
const keyword = this.searchMember.toLowerCase();
return this.membersData.filter(m => m.name.toLowerCase().includes(keyword));
},
filteredProjectsData() {
const keyword = this.searchProject.toLowerCase();
return this.projectsData.filter(p => p.name.toLowerCase().includes(keyword));
}
},
mounted() {
// 初始化图表
// this.initTrafficChart();
this.initTrendChart();
// 加载真实数据
this.loadDashboardData();
// 监听窗口大小变化
window.addEventListener('resize', this.handleResize);
},
beforeDestroy() {
// 清理监听器
window.removeEventListener('resize', this.handleResize);
},
methods: {
// 兼容后端日期字符串YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss
parseDate(val) {
if (!val) return null;
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
if (typeof val === 'string') {
const s = val.replace(' ', 'T');
const d = new Date(s);
if (!isNaN(d.getTime())) return d;
const d2 = new Date(val.split(' ')[0]);
return isNaN(d2.getTime()) ? null : d2;
}
const d = new Date(val);
return isNaN(d.getTime()) ? null : d;
},
toYMD(d) {
if (!d) return '';
const y = d.getFullYear();
const m = `${d.getMonth() + 1}`.padStart(2, '0');
const day = `${d.getDate()}`.padStart(2, '0');
return `${y}-${m}-${day}`;
},
mapProjectStatus(code) {
// 后端 project.status: 0未完成 1已完成 2隐藏见表结构注释
if (code === '1' || code === 1) return '已完成';
if (code === '2' || code === 2) return '隐藏';
return '未完成';
},
async loadDashboardData() {
try {
const [projRes, modRes] = await Promise.all([
listProject({ pageNum: 1, pageSize: 9999 }),
listModule({ pageNum: 1, pageSize: 9999 })
]);
const projects = (projRes && projRes.rows) ? projRes.rows : [];
const modules = (modRes && modRes.rows) ? modRes.rows : [];
this._allProjects = projects;
this._allModules = modules;
// 统计总数
this.totalProjects = projects.length;
// 读取用户映射userId -> 昵称/账号
let userMap = {};
let users = [];
try {
const usersRes = await listUser({ pageNum: 1, pageSize: 9999 });
users = (usersRes && usersRes.rows) ? usersRes.rows : [];
userMap = users.reduce((acc, u) => {
acc[String(u.userId)] = u.nickName || u.userName || String(u.userId);
return acc;
}, {});
} catch (ignore) {
userMap = {};
}
// 成员聚合(按 assignee=用户ID 字符串)
const memberMap = {};
modules.forEach(m => {
const assigneeId = m.assignee || '未指派';
const displayName = assigneeId === '未指派' ? '未指派' : (userMap[assigneeId] || assigneeId);
if (!memberMap[assigneeId]) memberMap[assigneeId] = { name: displayName, assigneeId, date: 0, address: 0 };
if (m.status === '2') memberMap[assigneeId].date += 1; else memberMap[assigneeId].address += 1;
});
this.membersData = Object.values(memberMap);
this.totalMembers = users.length + (users.some(u => u.userName === 'admin') ? 0 : 1);
// 项目聚合
const now = new Date();
const modulesByProject = modules.reduce((acc, m) => {
const pid = m.projectId;
if (!acc[pid]) acc[pid] = [];
acc[pid].push(m);
return acc;
}, {});
// 项目截止日期映射 + 负责人昵称映射
const projectDeadlineMap = {};
const ownerNickMap = {};
projects.forEach(p => {
projectDeadlineMap[p.projectId] = this.parseDate(p.deadline);
if (p.ownerId != null) ownerNickMap[p.ownerId] = userMap[String(p.ownerId)] || p.ownerId;
});
const projectsData = projects.map(p => {
const list = modulesByProject[p.projectId] || [];
const total = list.length;
const completed = list.filter(x => x.status === '2').length;
const pending = total - completed;
const deadline = projectDeadlineMap[p.projectId];
const overdue = deadline && now > deadline ? list.filter(x => x.status !== '2').length : 0;
return {
name: p.projectName,
date: overdue,
status: total > 0 && completed === total ? 'completed' : 'pending',
statusText: this.mapProjectStatus(p.status),
projectId: p.projectId,
ownerNick: ownerNickMap[p.ownerId] || (p.ownerId || ''),
createTime: p.createTime
};
});
this.projectsData = projectsData;
// 逾期模块数(而非逾期项目数)
const overdueModulesCount = modules.filter(m => {
const ddl = projectDeadlineMap[m.projectId];
return ddl && now > ddl && m.status !== '2';
}).length;
this.overdueProjects = overdueModulesCount;
// 完成率(按模块)
const totalModules = modules.length;
const completedModules = modules.filter(m => m.status === '2').length;
this.completionRate = totalModules > 0 ? Math.round((completedModules / totalModules) * 100) : 0;
// 更新饼图
this.refreshCompletionPieChart( this.completionRate );
// 用真实数据更新趋势图
this.updateTrendChart(modules);
} catch (e) {
this.$message.error('仪表盘数据加载失败');
}
},
// 计算完成率
calculateRate(completed, pending) {
const completedNum = parseInt(completed) || 0;
const pendingNum = parseInt(pending) || 0;
const total = completedNum + pendingNum;
return total > 0 ? Math.round((completedNum / total) * 100) : 0;
},
// 根据完成率获取颜色
getRateColor(rate) {
if (rate >= 80) return '#67c23a';
if (rate >= 50) return '#e6a23c';
return '#f56c6c';
},
// 根据标签页获取项目数据
filterByTab(data) {
if (this.activeTab === 'all') return data;
if (this.activeTab === 'completed') return data.filter(item => item.status === 'completed');
if (this.activeTab === 'pending') return data.filter(item => item.status === 'pending');
return data;
},
getProjectData() {
return this.filterByTab(this.filteredProjectsData);
},
// 刷新数据
async refreshData() {
await this.loadDashboardData();
this.$message.success('数据已刷新');
},
// 查看所有成员
viewAllMembers() {
this.$router.push({ path: '/unView/userview' });
},
// 用户详情页面跳转方法
userClick(row) {
this.$router.push({ path: '/UnView/UserProject', query: { assignee: row.assigneeId, name: row.name } });
},
// 项目详情页面跳转方法
projectClick(row) {
this.$router.push({ path: '/UnView/Project', query: { table: row.name, projectId: row.projectId } });
},
// 过滤成员数据
filterMembers() {
// 当搜索框有值时,使用 filteredMembersData否则使用原始 membersData
this.$nextTick(() => {
if (this.searchMember) {
this.$emit('update:membersData', this.filteredMembersData);
} else {
this.$emit('update:membersData', this.membersData);
}
});
},
// 移除 filterProjects 方法,因为 computed 会自动响应 v-model 变化
// 如果需要即时过滤,可以保留一个空方法或移除 @input
// filterProjects() {
// // 当搜索框有值时,使用 filteredProjectsData否则使用原始 projectsData
// this.$nextTick(() => {
// if (this.searchProject) {
// this.$emit('update:projectsData', this.filteredProjectsData);
// } else {
// this.$emit('update:projectsData', this.projectsData);
// }
// });
// },
// 初始化流量来源柱状图
// initTrafficChart() {
// const chart = echarts.init(document.getElementById('trafficChart'));
// // 准备图表数据
// const rawData = [
// [100, 302, 301, 334, 390, 330, 320],
// [320, 132, 101, 134, 90, 230, 210],
// [220, 182, 191, 234, 290, 330, 310],
// [150, 212, 201, 154, 190, 330, 410],
// [820, 832, 901, 934, 1290, 1330, 1320]
// ];
// // 计算总数据
// const totalData = [];
// for (let i = 0; i < rawData[0].length; ++i) {
// let sum = 0;
// for (let j = 0; j < rawData.length; ++j) {
// sum += rawData[j][i];
// }
// totalData.push(sum);
// }
// // 配置系列数据
// const series = [
// 'Direct',
// 'Mail Ad',
// 'Affiliate Ad',
// 'Video Ad',
// 'Search Engine'
// ].map((name, sid) => {
// return {
// name,
// type: 'bar',
// stack: 'total',
// barWidth: '60%',
// label: {
// show: true,
// formatter: (params) => Math.round(params.value * 1000) / 10 + '%'
// },
// data: rawData[sid].map((d, did) =>
// totalData[did] <= 0 ? 0 : d / totalData[did]
// )
// };
// });
// const option = {
// tooltip: {
// trigger: 'axis',
// axisPointer: { type: 'shadow' }
// },
// legend: {
// data: ['Direct', 'Mail Ad', 'Affiliate Ad', 'Video Ad', 'Search Engine'],
// bottom: 10
// },
// grid: {
// left: '3%',
// right: '4%',
// bottom: '15%',
// top: '3%',
// containLabel: true
// },
// yAxis: {
// type: 'value',
// axisLabel: { formatter: '{value} %' }
// },
// xAxis: {
// type: 'category',
// data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
// },
// series
// };
// chart.setOption(option);
// // 保存图表实例以便调整大小
// this.trafficChart = chart;
// },
// 初始化任务完成趋势图
initTrendChart() {
const chart = echarts.init(document.getElementById('trendChart'));
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['已完成', '未完成'],
bottom: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: []
},
yAxis: {
type: 'value'
},
series: [
{
name: '已完成',
type: 'line',
stack: 'Total',
data: [],
smooth: true,
lineStyle: { color: '#67c23a' }
},
{
name: '未完成',
type: 'line',
stack: 'Total',
data: [],
smooth: true,
lineStyle: { color: '#f56c6c' }
}
]
};
chart.setOption(option);
this.trendChart = chart;
},
updateTrendChart(modules) {
if (!this.trendChart) return;
// 最近7天
const days = [];
const labels = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - i);
days.push(new Date(d));
labels.push(`${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')}`);
}
const endOf = (d) => {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
};
const completedDaily = days.map(d => 0);
const totalCumu = days.map(() => 0);
const completedCumu = days.map(() => 0);
modules.forEach(m => {
const ct = this.parseDate(m.createTime);
const ft = this.parseDate(m.finishTime);
days.forEach((d, idx) => {
if (ct && ct <= endOf(d)) totalCumu[idx] += 1;
if (m.status === '2' && ft && ft <= endOf(d)) completedCumu[idx] += 1;
});
// 当日完成
if (m.status === '2' && ft) {
const ymd = this.toYMD(ft);
days.forEach((d, idx) => {
if (this.toYMD(d) === ymd) completedDaily[idx] += 1;
});
}
});
const unfinished = totalCumu.map((t, i) => Math.max(0, t - completedCumu[i]));
const option = this.trendChart.getOption();
option.xAxis[0].data = labels;
option.series[0].data = completedDaily;
option.series[1].data = unfinished;
this.trendChart.setOption(option, true);
},
// 初始化任务完成率饼图
initCompletionPieChart() {
const chart = echarts.init(document.getElementById('completionPieChart'));
// 定义颜色方案
const colors = [
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#67c23a' },
{ offset: 1, color: '#85ce61' }
]),
new echarts.graphic.LinearGradient(0, 0, 1, 1, [
{ offset: 0, color: '#f56c6c' },
{ offset: 1, color: '#f78989' }
])
];
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [
{
name: '任务状态',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
data: [
{ value: this.completionRate, name: '已完成' },
{ value: 100 - this.completionRate, name: '未完成' },
],
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '20',
fontWeight: 'bold'
}
},
labelLine: { show: false },
itemStyle: {
color: (params) => colors[params.dataIndex],
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
}
}
]
};
chart.setOption(option);
this.completionPieChart = chart;
},
refreshCompletionPieChart(rate) {
if (!this.completionPieChart) return;
const option = this.completionPieChart.getOption();
if (option && option.series && option.series[0]) {
option.series[0].data = [
{ value: rate, name: '已完成' },
{ value: 100 - rate, name: '未完成' },
];
this.completionPieChart.setOption(option, true);
}
},
// 初始化项目类型分布图表
initProjectTypeChart() {
const chart = echarts.init(document.getElementById('projectTypeChart'));
const option = {
tooltip: {
trigger: 'item'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
roseType: 'area',
data: [
{ value: 10, name: '开发' },
{ value: 5, name: '测试' },
{ value: 3, name: '设计' },
{ value: 7, name: '文档' },
{ value: 2, name: '其他' }
],
itemStyle: {
borderRadius: 5,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}\n{d}%'
}
}
]
};
chart.setOption(option);
this.projectTypeChart = chart;
},
// 处理窗口大小变化
handleResize() {
// if (this.trafficChart) this.trafficChart.resize();
if (this.trendChart) this.trendChart.resize();
}
}
};
</script>
<style scoped lang="scss">
.dashboard-container {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
}
/* 页面标题 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-header h1 {
font-size: 24px;
color: #303133;
margin: 0;
}
.header-actions {
display: flex;
gap: 15px;
align-items: center;
}
/* 数据概览卡片 */
.overview-cards {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.card-item {
flex: 1;
background: #fff;
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.card-item:hover {
transform: translateY(-5px);
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.15);
}
.card-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.card-icon-blue { background-color: #409eff; }
.card-icon-green { background-color: #67c23a; }
.card-icon-orange { background-color: #e6a23c; }
.card-icon-purple { background-color: #909399; }
.card-content {
flex: 1;
}
.card-title {
font-size: 14px;
color: #606266;
margin-bottom: 5px;
}
.card-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
/* 主要内容区域 */
.main-content {
display: grid;
grid-template-columns: 1fr 1.5fr 1fr;
gap: 20px;
}
/* 面板样式 */
.panel {
background: #fff;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.panel-header {
padding: 15px 20px;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h2 {
font-size: 16px;
color: #303133;
margin: 0;
font-weight: 500;
}
.panel-body {
padding: 20px;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.main-content {
grid-template-columns: 1fr 1fr;
}
.center-section {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.overview-cards {
flex-direction: column;
}
.main-content {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
}
</style>