提交 bb76d85f authored 作者: coderBryanFu's avatar coderBryanFu

feat:新增概览页时间线日历热力图功能

上级 bb99a95a
<script setup lang="ts">
import "@/styles/container.scss"
import TextStyle from './textStyle.vue';
import ConstStyle from './constStyle.vue';
import { ElScrollbar, ElSpace } from "element-plus";
</script>
<template>
<el-scrollbar>
<div class="common-page">
<el-space direction="vertical" alignment="flex-start">
<div class="text-title-0-show">开发样式</div>
<div class="text-title-1-show">样式变量</div>
<const-style></const-style>
<ConstStyle />
<div class="text-title-1-show">文字样式</div>
<text-style></text-style>
<TextStyle />
</el-space>
</div>
</el-scrollbar>
</template>
\ No newline at end of file
</template>
<script setup>
import "@/styles/container.scss"
import TextStyle from './textStyle.vue';
import ConstStyle from './constStyle.vue';
</script>
\ No newline at end of file
<template>
<table style="width: 100%; border-collapse: collapse; border: 1px solid #ebeef5;">
<table style="width: 1600px; border-collapse: collapse; border: 1px solid #ebeef5;">
<!-- 表头 -->
<thead>
<tr class="text-title-2">
......
......@@ -8,6 +8,7 @@ import { withFallbackImage } from "./utils";
import "./styles/scrollbar.css";
import "./styles/elui.css";
import "./styles/main.css";
import "./styles/common.scss"
import '@/assets/fonts/font.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
......
<template>
<div style="position: relative;">
<div ref="chartRef" style="height: 620px; width: 1640px" @click="handleClick"></div>
<div v-show="tooltipVisible" class="custom-tooltip background-as-card"
:style="{ left: mouseX + 'px', top: mouseY + 'px' }">
<div class="tooltip-header flex-display">
<div class="tooltip-header-left text-title-3-bold">{{ currentDate }}</div>
<div class="tooltip-header-right text-title-3-show">{{ `${'3个部门'}/${'3项举措'}` }}</div>
</div>
<div class="tooltip-main">
<div class="tooltip-main-item" v-for="item, index in currentDetailList" :key="index">
<div class="item-header flex-display">
<div class="item-header-left flex-display">
<div class="logo">
<img style="width:100%; height: 100%" :src="item.orgLogoUrl" alt="logo">
</div>
<div class="name text-bold">{{ item.orgName }}</div>
<div class="status">
<div class="status-on text-tip-2" v-if="item.stauts === 2">{{ '已落实' }}</div>
<div class="status-off text-tip-2" v-else>{{ '未落实' }}</div>
</div>
</div>
<div class="item-header-right flex-display">
<AreaTag v-for="tag, idx in item.techDomainList.slice(0, 3)" :key="idx" :tagName="tag"></AreaTag>
</div>
</div>
<div class="item-content text-compact">{{ item.name }}</div>
<div class="item-footer"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
import * as echarts from 'echarts/core';
import { CustomChart } from 'echarts/charts';
import { TitleComponent, TooltipComponent, CalendarComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const props = defineProps(
{
timelineData: {
type: Array,
default: []
}
}
);
echarts.use([
CustomChart,
TitleComponent,
TooltipComponent,
CalendarComponent,
CanvasRenderer,
]);
const chartRef = ref(null);
let chartInstance = null;
// 弹窗控制
const tooltipVisible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
const currentDate = ref('')
const currentDetailList = ref([])
// // 鼠标移动,记录位置
const handleMouseMove = (e) => {
mouseX.value = e.clientX + 20
mouseY.value = e.clientY + 20
}
const handleClick = (e) => {
mouseX.value = e.clientX - 160
mouseY.value = e.clientY + 50
}
// 计算展示的月份 获取今天在内的之后的三个月的月份
const startDate = dayjs();
console.log('startDate', startDate);
const months = [0, 1, 2].map(offset => startDate.add(offset, 'month'));
// 生成日历数据
function buildDataMap(arr) {
const map = new Map();
const stats = months.map(() => ({ total: 0, resolved: 0 }));
// 按日期聚合
arr.forEach(item => {
const date = item.endDate;
if (!map.has(date)) {
map.set(date, {
date,
count: 0,
resolved: 0,
logos: [],
status: 'empty',
raw: [],
});
}
const d = map.get(date);
d.count += 1;
if (item.status === 2) d.resolved += 1;
if (d.logos.length < 3 && item.orgLogoUrl) d.logos.push(item.orgLogoUrl);
d.status = 'has_events';
d.raw.push(item);
});
// 统计每月
months.forEach((month, idx) => {
const daysInMonth = month.daysInMonth();
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = month.date(i).format('YYYY-MM-DD');
const d = map.get(dateStr);
if (d) {
stats[idx].total += d.count;
stats[idx].resolved += d.resolved;
} else {
map.set(dateStr, {
date: dateStr,
count: 0,
resolved: 0,
logos: [],
status: 'empty',
raw: [],
});
}
}
});
return { dataMap: map, monthStats: stats };
}
console.log('timelineData', props.timelineData);
const { dataMap, monthStats } = buildDataMap(props.timelineData);
function getOption() {
const calendarWidthPercent = 30;
const gapPercent = (95 - (calendarWidthPercent * 3)) / 4;
const calendars = months.map((month, index) => ({
top: 40,
left: `${gapPercent + index * (calendarWidthPercent + gapPercent)}%`,
width: `${calendarWidthPercent}%`,
range: month.format('YYYY-MM'),
cellSize: ['auto', 82],
splitLine: { show: true, lineStyle: { color: '#fff', width: 4 } },
itemStyle: { color: '#f0f9ff', borderColor: '#fff', borderWidth: 4 },
yearLabel: { show: false },
monthLabel: { show: false },
dayLabel: { show: false },
}));
const titles = months.map((month, index) => ({
text: `{month|${month.format('M月')}} {stats|共 ${monthStats[index].total} 项, 已落实 ${monthStats[index].resolved}}`,
left: `${gapPercent + index * (calendarWidthPercent + gapPercent)}%`,
top: 10,
textStyle: {
rich: {
month: {
fontSize: 18,
fontWeight: 'bold',
color: 'rgb(5, 95, 194)',
fontFamily: 'Source Han Sans CN'
},
stats: {
fontSize: 18,
color: 'rgb(5, 95, 194)',
fontWeight: 'bold',
fontFamily: 'Source Han Sans CN'
}
}
}
}));
const series = months.map((month, index) => {
const monthData = [];
const daysInMonth = month.daysInMonth();
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = month.date(i).format('YYYY-MM-DD');
const item = dataMap.get(dateStr);
monthData.push([dateStr, item]);
}
// console.log('monthData', monthData);
return {
type: 'custom',
coordinateSystem: 'calendar',
calendarIndex: index,
data: monthData,
renderItem: (params, api) => {
const cellPoint = api.coord([api.value(0), 0]);
const cellWidth = params.coordSys.cellWidth;
const cellHeight = params.coordSys.cellHeight;
const dataIndex = params.dataIndex;
const rawDataItem = monthData[dataIndex];
const item = rawDataItem[1];
if (!item) return;
const x = cellPoint[0] - cellWidth / 2;
const y = cellPoint[1] - cellHeight / 2;
const groupChildren = [];
const bgColor = item.status === 'has_events' ? '#bfdbfe' : '#f0f9ff';
groupChildren.push({
type: 'rect',
shape: { x, y, width: cellWidth, height: cellHeight },
style: {
fill: bgColor,
stroke: '#fff',
lineWidth: 1
},
});
if (item.status === 'has_events') {
const iconSize = 20;
const iconGap = -5;
const maxIcons = 3;
const displayLogos = item.logos.slice(0, maxIcons);
const iconsY = y + 8;
const iconsX = x + 8;
displayLogos.forEach((logo, idx) => {
groupChildren.push({
type: 'image',
style: {
image: logo,
x: iconsX + idx * (iconSize + iconGap),
y: iconsY,
width: iconSize,
height: iconSize,
},
});
});
groupChildren.push({
type: 'text',
style: {
text: `${item.count}项`,
x: x + 8,
y: y + cellHeight - 12,
textAlign: 'left',
textVerticalAlign: 'bottom',
fontSize: 18,
fontFamily: 'Source Han Sans CN'
// fontWeight: 'bold',
},
});
} else {
const d = dayjs(item.date);
groupChildren.push({
type: 'text',
style: {
text: d.format('YYYY年'),
x: x + cellWidth / 2,
y: y + cellHeight / 2 - 8,
textAlign: 'center',
textVerticalAlign: 'bottom',
fontSize: 10,
// fill: '#93c5fd',
fill:'rgb(185, 220, 255)',
fontFamily: 'Source Han Sans CN'
},
});
groupChildren.push({
type: 'text',
style: {
text: d.format('MM月DD日'),
x: x + cellWidth / 2,
y: y + cellHeight / 2 + 4,
textAlign: 'center',
textVerticalAlign: 'top',
fontSize: 12,
// fontWeight: 'bold',
fill: '#rgb(185, 220, 255)',
fontFamily: 'Source Han Sans CN'
},
});
}
return {
type: 'group',
children: groupChildren,
};
},
};
});
// const visualMap = {
// min: 0, // 数据最小值
// max: 10, // 数据最大值,根据你的实际数据范围调整
// calculable: true, // 是否显示拖拽手柄
// orient: 'horizontal', // 图例方向
// left: 'center', // 图例位置
// inRange: {
// // 颜色数组:从左(浅)到右(深)代表数值从小到大
// color: ['rgb(231, 243, 255)', 'rgb(137, 193, 255)'] // 示例:从白色到深蓝色
// }
// }
return {
tooltip: { show: false },
title: titles,
calendar: calendars,
// visualMap: visualMap,
series: series,
};
}
onMounted(() => {
if (chartRef.value) {
chartInstance = echarts.init(chartRef.value, undefined, { renderer: 'canvas' });
chartInstance.setOption(getOption());
window.addEventListener('resize', resizeChart);
chartInstance.on('click', (params) => {
console.log('params', params);
if (Array.isArray(params.data) && params.data.length >= 2) {
const date = params.data[0]
// const list = dataMap.get(date)?.raw || []
const list = params.data[1].raw
if (list.length > 0) {
currentDate.value = date
currentDetailList.value = list
tooltipVisible.value = true
} else {
tooltipVisible.value = false
}
}
});
// chartInstance.on('mouseout', () => {
// tooltipVisible.value = false
// });
}
});
function resizeChart() {
if (chartInstance) {
chartInstance.resize();
}
}
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeChart);
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style scoped>
.custom-tooltip {
position: absolute;
/* 使用 fixed 相对于视口定位 */
/* pointer-events: none; */
/* 让鼠标事件穿透到图表,避免闪烁 */
z-index: 1000;
width: 670px;
background: rgb(255, 255, 255);
}
.tooltip-header {
height: 50px;
box-sizing: border-box;
padding: 13px 24px;
justify-content: space-between;
border-bottom: 1px solid rgb(234, 236, 238);
}
.tooltip-main {
padding: 0 24px 10px;
}
.tooltip-main-item {
width: 622px;
height: 144px;
border-bottom: 1px solid rgb(234, 236, 238);
}
.item-header {
height: 46px;
justify-content: space-between;
align-items: center;
}
.item-header-left {
gap: 8px;
align-items: center;
}
.logo {
width: 24px;
height: 24px;
}
.status-on {
height: 23px;
padding: 0 8px;
color: rgb(33, 129, 57);
background: rgba(33, 129, 57, 0.1);
}
.status-off {
height: 23px;
padding: 0 8px;
color: rgb(206, 79, 81);
background: rgba(206, 79, 81, 0.1);
}
.item-content {
height: 48px;
display: -webkit-box;
-webkit-line-clamp: 2;
/* 限制显示2行 */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
/* 可选:防止单词被截断 */
}
.item-header-right {
gap: 8px;
}
</style>
<template>
<div style="position: relative;">
<div ref="chartRef" style="height: 620px; width: 1640px" @mousemove="handleMouseMove"></div>
<div v-show="tooltipVisible" class="custom-tooltip background-as-card"
:style="{ left: mouseX + 'px', top: mouseY + 'px' }">
<div class="tooltip-header flex-display">
<div class="tooltip-header-left text-title-3-bold">{{ currentDate }}</div>
<div class="tooltip-header-right text-title-3-show">{{ `${'3个部门'}/${'3项举措'}` }}</div>
<div ref="chartRef" style="height: 620px; width: 1640px" @click="handleClick"></div>
<div
v-show="tooltipVisible"
class="custom-tooltip background-as-card"
:style="{ left: mouseX + 'px', top: mouseY + 'px', cursor: dragging ? 'move' : 'default' }"
@mousedown="startDrag"
>
<div
class="tooltip-header flex-display"
@mousedown.stop="startDrag"
style="cursor: move;"
>
<div class="tooltip-header-left text-title-3-bold">{{ currentDate }}</div>
<div class="tooltip-header-right text-title-3-show">{{ `${'3个部门'}/${'3项举措'}` }}</div>
</div>
<div class="tooltip-main">
<div class="tooltip-main-item" v-for="item, index in currentDetailList" :key="index">
<div class="item-header flex-display">
<div class="item-header-left flex-display">
<div class="logo">
<img style="width:100%; height: 100%" :src="item.orgLogoUrl" alt="logo">
</div>
<div class="name text-bold">{{ item.orgName }}</div>
<div class="status">
<div class="status-on text-tip-2" v-if="item.stauts === 2">{{ '已落实' }}</div>
<div class="status-off text-tip-2" v-else>{{ '未落实' }}</div>
</div>
</div>
<div class="item-header-right flex-display">
<AreaTag v-for="tag, idx in item.techDomainList.slice(0,3)" :key="idx" :tagName="tag"></AreaTag>
</div>
<div class="tooltip-main-item" v-for="item, index in currentDetailList" :key="index">
<div class="item-header flex-display">
<div class="item-header-left flex-display">
<div class="logo">
<img style="width:100%; height: 100%" :src="item.orgLogoUrl" alt="logo">
</div>
<div class="item-content text-compact">{{ item.name }}</div>
<div class="item-footer"></div>
<div class="name text-bold">{{ item.orgName }}</div>
<div class="status">
<div class="status-on text-tip-2" v-if="item.stauts === 2">{{ '已落实' }}</div>
<div class="status-off text-tip-2" v-else>{{ '未落实' }}</div>
</div>
</div>
<div class="item-header-right flex-display">
<AreaTag v-for="tag, idx in item.techDomainList.slice(0, 3)" :key="idx" :tagName="tag"></AreaTag>
</div>
</div>
<div class="item-content text-compact">{{ item.name }}</div>
<div class="item-footer"></div>
</div>
</div>
</div>
</div>
</template>
......@@ -53,6 +60,32 @@ const props = defineProps(
}
);
const dragging = ref(false);
let dragOffsetX = 0;
let dragOffsetY = 0;
const startDrag = (e) => {
dragging.value = true;
dragOffsetX = e.clientX - mouseX.value;
dragOffsetY = e.clientY - mouseY.value;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
}
const onDrag = (e) => {
if (dragging.value) {
mouseX.value = e.clientX - dragOffsetX;
mouseY.value = e.clientY - dragOffsetY;
}
}
const stopDrag = () => {
dragging.value = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
echarts.use([
CustomChart,
......@@ -72,10 +105,18 @@ const mouseY = ref(0)
const currentDate = ref('')
const currentDetailList = ref([])
// 鼠标移动,记录位置
// // 鼠标移动,记录位置
const handleMouseMove = (e) => {
mouseX.value = e.clientX + 20
mouseY.value = e.clientY + 20
mouseY.value = e.clientY - 20
}
const handleClick = (e) => {
// mouseX.value = e.clientX - 160
// mouseY.value = e.clientY - 120
mouseX.value = e.clientX + 10
mouseY.value = e.clientY - 100
}
// 计算展示的月份 获取今天在内的之后的三个月的月份
......@@ -141,12 +182,27 @@ console.log('timelineData', props.timelineData);
const { dataMap, monthStats } = buildDataMap(props.timelineData);
function getOption() {
const calendarWidthPercent = 30;
const gapPercent = (95 - (calendarWidthPercent * 3)) / 4;
// 计算所有天的最大 count
let maxCount = 0;
dataMap.forEach(item => {
if (item.count > maxCount) maxCount = item.count;
});
// 颜色插值函数
function getColorByCount(count) {
// rgb(231,243,255) -> rgb(137,193,255)
const start = [231, 243, 255];
const end = [137, 193, 255];
if (maxCount === 0) return `rgb(${start.join(',')})`;
const ratio = Math.min(count / maxCount, 1);
const rgb = start.map((s, i) => Math.round(s + (end[i] - s) * ratio));
return `rgb(${rgb.join(',')})`;
}
const calendars = months.map((month, index) => ({
top: 40,
left: `${gapPercent + index * (calendarWidthPercent + gapPercent)}%`,
......@@ -167,14 +223,16 @@ function getOption() {
textStyle: {
rich: {
month: {
fontSize: 24,
fontSize: 18,
fontWeight: 'bold',
color: '#1e3a8a',
color: 'rgb(5, 95, 194)',
fontFamily: 'Source Han Sans CN'
},
stats: {
fontSize: 14,
color: '#1e40af',
fontSize: 18,
color: 'rgb(5, 95, 194)',
fontWeight: 'bold',
fontFamily: 'Source Han Sans CN'
}
}
}
......@@ -189,9 +247,6 @@ function getOption() {
monthData.push([dateStr, item]);
}
// console.log('monthData', monthData);
return {
type: 'custom',
coordinateSystem: 'calendar',
......@@ -210,14 +265,17 @@ function getOption() {
const y = cellPoint[1] - cellHeight / 2;
const groupChildren = [];
const bgColor = item.status === 'has_events' ? '#bfdbfe' : '#f0f9ff';
// 动态颜色
const bgColor = item.status === 'has_events'
? getColorByCount(item.count)
: '#f0f9ff';
groupChildren.push({
type: 'rect',
shape: { x, y, width: cellWidth, height: cellHeight },
style: {
fill: bgColor,
stroke: '#efefef',
stroke: '#fff',
lineWidth: 1
},
});
......@@ -252,7 +310,7 @@ function getOption() {
textAlign: 'left',
textVerticalAlign: 'bottom',
fontSize: 18,
fontWeight: 'bold',
fontFamily: 'Source Han Sans CN'
},
});
} else {
......@@ -266,7 +324,8 @@ function getOption() {
textAlign: 'center',
textVerticalAlign: 'bottom',
fontSize: 10,
fill: '#93c5fd',
fill: 'rgb(185, 220, 255)',
fontFamily: 'Source Han Sans CN'
},
});
groupChildren.push({
......@@ -278,8 +337,8 @@ function getOption() {
textAlign: 'center',
textVerticalAlign: 'top',
fontSize: 12,
fontWeight: 'bold',
fill: '#93c5fd',
fill: 'rgb(185, 220, 255)',
fontFamily: 'Source Han Sans CN'
},
});
}
......@@ -292,7 +351,6 @@ function getOption() {
};
});
return {
tooltip: { show: false },
title: titles,
......@@ -307,7 +365,7 @@ onMounted(() => {
chartInstance.setOption(getOption());
window.addEventListener('resize', resizeChart);
chartInstance.on('mouseover', (params) => {
chartInstance.on('click', (params) => {
console.log('params', params);
if (Array.isArray(params.data) && params.data.length >= 2) {
......@@ -318,13 +376,15 @@ onMounted(() => {
currentDate.value = date
currentDetailList.value = list
tooltipVisible.value = true
} else {
tooltipVisible.value = false
}
}
});
chartInstance.on('mouseout', () => {
tooltipVisible.value = false
});
// chartInstance.on('mouseout', () => {
// tooltipVisible.value = false
// });
}
});
......@@ -347,11 +407,12 @@ onBeforeUnmount(() => {
.custom-tooltip {
position: fixed;
/* 使用 fixed 相对于视口定位 */
pointer-events: none;
/* pointer-events: none; */
/* 让鼠标事件穿透到图表,避免闪烁 */
z-index: 1000;
width: 670px;
background: rgb(255, 255, 255);
}
.tooltip-header {
......@@ -404,16 +465,19 @@ onBeforeUnmount(() => {
background: rgba(206, 79, 81, 0.1);
}
.item-content{
.item-content {
height: 48px;
display: -webkit-box;
-webkit-line-clamp: 2; /* 限制显示2行 */
-webkit-line-clamp: 2;
/* 限制显示2行 */
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word; /* 可选:防止单词被截断 */
word-break: break-word;
/* 可选:防止单词被截断 */
}
.item-header-right{
.item-header-right {
gap: 8px;
}
</style>
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论