feat(工作台): 优化工作台所有柱状图

This commit is contained in:
xinxin.wu 2024-12-06 19:27:25 +08:00 committed by Craftsman
parent c71fd05a7c
commit a51ea8702f
11 changed files with 236 additions and 254 deletions

View File

@ -1,9 +1,11 @@
<template>
<VCharts v-if="renderChart" ref="chartRef" :option="options" :autoresize="autoResize" :style="{ width, height }" />
<VCharts v-if="chartId" ref="chartRef" :option="options" :autoresize="autoResize" :style="{ width, height }" />
</template>
<script lang="ts" setup>
import { nextTick, ref } from 'vue';
import { ref } from 'vue';
import { getGenerateId } from '@/utils';
import { BarChart, CustomChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
@ -54,11 +56,15 @@
},
});
const renderChart = ref(false);
const chartRef = ref<InstanceType<typeof VCharts>>();
nextTick(() => {
renderChart.value = true;
const chartId = ref('');
onMounted(() => {
chartId.value = getGenerateId();
});
onUnmounted(() => {
chartId.value = '';
});
defineExpose({

View File

@ -0,0 +1,71 @@
export default function bindDataZoomEvent(
chartRef: any,
options: Record<string, any>,
barWidth = 12,
barWidthMargin = 6,
barNumber = 7,
minBarGroupWidth = 24
) {
const chartDom = chartRef.value?.chartRef;
const handleDataZoom = (params: any) => {
const containerWidth = chartDom.getDom().offsetWidth;
// 计算缩放百分比
const percent = (params.end - params.start) / 100;
// 计算单组条形图的宽度(包括间隔和最小宽度)
const singleGroupWidth = barWidth * barNumber + barWidthMargin * (barNumber - 1) + minBarGroupWidth;
// 计算可视区域内的最大条形图组数
const maxVisibleGroups = Math.floor(containerWidth / singleGroupWidth);
// 根据缩放百分比,计算需要显示的分类数量
const val = options.value.xAxis.data.length * percent;
const calcCount = Math.ceil(val);
// 计算每个标签的宽度
const labelWidth = (containerWidth - calcCount * minBarGroupWidth) / calcCount;
// 更新图表的配置项,重新设置数据缩放和 x 轴标签的显示
chartDom.setOption(
{
...options.value,
dataZoom: [
{
...options.value.dataZoom[0],
start: params.start,
end: params.end,
maxValueSpan: maxVisibleGroups,
},
],
xAxis: {
axisLabel: {
width: labelWidth,
overflow: 'truncate',
ellipsis: '...',
interval: 0,
},
...options.value.xAxis,
},
},
{ notMerge: true }
);
};
chartDom.chart.on('dataZoom', handleDataZoom);
const handleResize = () => {
if (chartDom) {
const currentOptions = chartDom.chart.getOption();
if (currentOptions.dataZoom.length) {
handleDataZoom({ start: currentOptions.dataZoom[0].start, end: currentOptions.dataZoom[0].end });
}
}
};
window.addEventListener('resize', handleResize);
return {
clear: () => {
chartDom.chart.off('dataZoom', handleDataZoom);
window.removeEventListener('resize', handleResize);
},
handleDataZoom,
};
}

View File

@ -189,7 +189,7 @@ export const defaultValueMap: Record<string, any> = {
},
complete: {
defaultList: cloneDeep(defaultComplete),
color: ['#00C261', '#3370FF', '#D4D4D8', '#FF9964'],
color: ['#D4D4D8', '#3370FF', '#00C261', '#FF9964'],
defaultName: 'workbench.homePage.completeRate',
},
},

View File

@ -34,7 +34,7 @@
</div>
</div>
<div class="mt-[16px]">
<MsChart height="260px" :options="options" />
<MsChart ref="chartRef" height="260px" :options="options" />
</div>
</div>
</div>
@ -47,17 +47,17 @@
import { ref } from 'vue';
import MsChart from '@/components/pure/chart/index.vue';
import bindDataZoomEvent from '@/components/pure/chart/utils';
import MsSelect from '@/components/business/ms-select';
import CardSkeleton from './cardSkeleton.vue';
import { workBugHandlerDetail, workHandleUserOptions } from '@/api/modules/workbench';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { characterLimit } from '@/utils';
import type { OverViewOfProject, SelectedCardItem, TimeFormParams } from '@/models/workbench/homePage';
import { getColorScheme, getCommonBarOptions, handleNoDataDisplay } from '../utils';
import { createCustomTooltip, getColorScheme, getSeriesData } from '../utils';
const { t } = useI18n();
const appStore = useAppStore();
@ -97,50 +97,21 @@
const hasPermission = ref<boolean>(false);
function handleData(detail: OverViewOfProject) {
options.value = getCommonBarOptions(detail.xaxis.length >= 7, [...defectStatusColor, ...getColorScheme(13)]);
const { invisible, text } = handleNoDataDisplay(detail.xaxis, hasPermission.value);
options.value.graphic.invisible = invisible;
options.value.graphic.style.text = text;
options.value.xAxis.data = detail.xaxis.map((e) => characterLimit(e, 10));
let maxAxis = 5;
options.value.series = detail.projectCountList.map((item) => {
const countData: Record<string, any>[] = item.count.map((e) => {
return {
name: item.name,
value: e,
originValue: e,
};
});
const itemMax = Math.max(...item.count);
maxAxis = Math.max(itemMax, maxAxis);
const data = detail.projectCountList.map((e) => {
return {
name: item.name,
type: 'bar',
stack: 'bugMember',
barWidth: 12,
data: countData,
itemStyle: {
borderRadius: [2, 2, 0, 0],
},
barMinHeight: ((optionData: Record<string, any>[]) => {
optionData.forEach((itemValue: any, index: number) => {
if (itemValue.value === 0) optionData[index].value = null;
});
let hasZero = false;
for (let i = 0; i < optionData.length; i++) {
if (optionData[i].value === 0) {
hasZero = true;
break;
}
}
return hasZero ? 0 : 5;
})(countData),
value: '',
label: e.name,
};
});
options.value.yAxis[0].max = maxAxis <= 100 ? 100 : maxAxis + 50;
options.value = getSeriesData(
data,
detail,
[...defectStatusColor, ...getColorScheme(13)],
false,
true,
props.item.fullScreen
);
}
const showSkeleton = ref(false);
@ -176,6 +147,7 @@
value: e.value,
}));
}
const chartRef = ref<InstanceType<typeof MsChart>>();
async function handleProjectChange(isRefreshKey: boolean = false, setAll = false) {
await nextTick();
@ -190,7 +162,13 @@
}
}
await nextTick();
getDefectMemberDetail();
await getDefectMemberDetail();
const chartDom = chartRef.value?.chartRef;
if (chartDom && chartDom.chart) {
createCustomTooltip(chartDom);
bindDataZoomEvent(chartRef, options);
}
}
async function changeProject() {

View File

@ -0,0 +1,59 @@
<template>
<div v-if="props.contentTabList.length" class="card-list">
<div v-for="ele of contentTabList" :key="ele.icon" class="card-list-item">
<div class="w-full">
<div class="card-title flex items-center gap-[8px]">
<div :class="`card-title-icon bg-[${ele?.color}]`">
<MsIcon :type="ele.icon" class="text-[var(--color-text-fff)]" size="12" />
</div>
<div class="text-[var(--color-text-1)]"> {{ ele.label }}</div>
</div>
<div class="card-number !text-[20px] !font-medium"> {{ addCommasToNumber(ele.count || 0) }} </div>
</div>
</div>
</div>
<NoData v-else :no-permission-text="props.noPermissionText" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
import NoData from '../../components/notData.vue';
import { addCommasToNumber } from '@/utils';
const props = defineProps<{
contentTabList: {
label: string;
value: string | number;
count?: number;
icon?: string;
color?: string;
[key: string]: any;
}[];
noPermissionText?: string;
}>();
</script>
<style scoped lang="less">
.card-list {
gap: 16px;
@apply flex flex-1;
.card-list-item {
padding: 16px;
border: 1px solid var(--color-text-n8);
border-radius: 4px;
@apply flex-1;
.card-title-icon {
width: 20px;
height: 20px;
border-radius: 50%;
@apply flex items-center justify-center;
}
.card-number {
margin-left: 28px;
font-size: 20px;
}
}
}
</style>

View File

@ -30,7 +30,7 @@
</div>
</div>
<div class="my-[16px]">
<TabCard
<HeaderCard
:content-tab-list="cardModuleList"
:no-permission-text="hasPermission ? '' : 'workbench.homePage.notHasResPermission'"
/>
@ -50,9 +50,10 @@
import { ref } from 'vue';
import MsChart from '@/components/pure/chart/index.vue';
import bindDataZoomEvent from '@/components/pure/chart/utils';
import MsSelect from '@/components/business/ms-select';
import CardSkeleton from './cardSkeleton.vue';
import TabCard from './tabCard.vue';
import HeaderCard from './headerCard.vue';
import { workMyCreatedDetail, workProOverviewDetail } from '@/api/modules/workbench';
import { contentTabList } from '@/config/workbench';
@ -191,12 +192,21 @@
onMounted(async () => {
await initOverViewDetail();
setTimeout(() => {
nextTick(() => {
const chartDom = chartRef.value?.chartRef;
if (chartDom && chartDom.chart) {
createCustomTooltip(chartDom);
bindDataZoomEvent(chartRef, options);
}
}, 0);
});
});
onBeforeUnmount(() => {
const unbindDataZoom = bindDataZoomEvent(chartRef, options);
if (unbindDataZoom) {
unbindDataZoom.clear();
}
});
watch(

View File

@ -50,6 +50,7 @@
import { ref } from 'vue';
import MsChart from '@/components/pure/chart/index.vue';
import bindDataZoomEvent from '@/components/pure/chart/utils';
import MsSelect from '@/components/business/ms-select';
import CardSkeleton from './cardSkeleton.vue';
@ -149,12 +150,12 @@
await nextTick();
await initOverViewMemberDetail();
setTimeout(() => {
const chartDom = chartRef.value?.chartRef;
if (chartDom && chartDom.chart) {
createCustomTooltip(chartDom);
}
}, 0);
const chartDom = chartRef.value?.chartRef;
if (chartDom && chartDom.chart) {
createCustomTooltip(chartDom);
bindDataZoomEvent(chartRef, options);
}
}
async function changeProject() {
@ -214,6 +215,13 @@
onMounted(() => {
handleProjectChange(false);
});
onBeforeUnmount(() => {
const unbindDataZoom = bindDataZoomEvent(chartRef, options);
if (unbindDataZoom) {
unbindDataZoom.clear();
}
});
</script>
<style scoped lang="less"></style>

View File

@ -1,150 +0,0 @@
<template>
<div ref="cardWrapperRef">
<a-tabs v-if="props.contentTabList.length" default-active-key="1" class="ms-tab-card">
<a-tab-pane v-for="item of props.contentTabList" :key="item.value" :title="`${item.label}`">
<template #title>
<slot name="item" :item="item">
<div class="w-full">
<div class="card-title flex items-center gap-[8px]">
<div :class="`card-title-icon bg-[${item?.color}]`">
<MsIcon :type="item.icon" class="text-white" size="12" />
</div>
<div class="text-[var(--color-text-1)]"> {{ item.label }}</div>
</div>
<div class="card-number !text-[20px] !font-medium"> {{ addCommasToNumber(item.count || 0) }} </div>
</div>
</slot>
</template>
</a-tab-pane>
</a-tabs>
<NoData v-else :no-permission-text="props.noPermissionText" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { debounce } from 'lodash-es';
import NoData from '../../components/notData.vue';
import { addCommasToNumber } from '@/utils';
const props = defineProps<{
contentTabList: {
label: string;
value: string | number;
count?: number;
icon?: string;
color?: string;
[key: string]: any;
}[];
minWidth?: string;
notHasPadding?: boolean;
hiddenBorder?: boolean;
noPermissionText?: string;
}>();
const width = ref<string | number>();
const cardWrapperRef = ref<HTMLElement | null>(null);
const calculateWidth = debounce(() => {
const wrapperContent = cardWrapperRef.value as HTMLElement;
if (wrapperContent) {
const wrapperTotalWidth = wrapperContent.offsetWidth;
const gap = 16;
const gapWidth = (props.contentTabList.length - 1) * gap; //
const itemWidth = Math.floor((wrapperTotalWidth - gapWidth) / props.contentTabList.length);
width.value = `${itemWidth}px`;
}
}, 50);
let resizeObserver: ResizeObserver;
onMounted(() => {
const wrapperContent = cardWrapperRef.value;
if (wrapperContent) {
resizeObserver = new ResizeObserver(() => {
calculateWidth();
});
resizeObserver.observe(wrapperContent);
}
});
const minwidth = ref();
const padding = ref();
const color = ref();
watch(
[() => props.minWidth, () => props.notHasPadding, () => props.hiddenBorder],
(val) => {
const [newMinWidth, noPadding, isHiddenBorder] = val;
minwidth.value = `${newMinWidth || '136px'}`;
padding.value = `${noPadding ? '0px' : '16px'}`;
color.value = `${isHiddenBorder ? 'transparent' : 'var(--color-text-n8)'}`;
calculateWidth();
},
{
immediate: true,
}
);
watch(
() => props.contentTabList,
(val) => {
if (val.length) {
calculateWidth();
}
}
);
onBeforeUnmount(() => {
window.removeEventListener('resize', calculateWidth);
});
</script>
<style scoped lang="less">
:deep(.ms-tab-card) {
.arco-tabs-nav-tab {
.arco-tabs-nav-ink {
display: none;
}
.arco-tabs-nav-tab-list {
gap: 16px;
@apply flex;
}
.arco-tabs-tab {
margin: 0;
box-sizing: border-box;
padding: v-bind(padding) !important;
width: v-bind(width);
min-width: v-bind(minwidth) !important;
border: 1px solid v-bind(color);
border-radius: 4px;
&.arco-tabs-tab-active {
color: var(--color-text-1);
}
&:hover {
color: var(--color-text-1);
}
}
.arco-tabs-tab-title {
@apply w-full;
.card-number {
margin-left: 28px;
font-size: 20px !important;
}
}
}
.card-title-icon {
width: 20px;
height: 20px;
border-radius: 50%;
@apply flex items-center justify-center;
}
}
:deep(.arco-tabs-nav-button) {
width: 36px;
height: 36px;
border: 1px solid var(--color-text-n8);
border-radius: 2px;
}
</style>

View File

@ -191,16 +191,16 @@
count: completeRate,
},
{
name: t('common.completed'),
count: finished,
name: t('common.notStarted'),
count: prepared,
},
{
name: t('common.inProgress'),
count: running,
},
{
name: t('common.notStarted'),
count: prepared,
name: t('common.completed'),
count: finished,
},
{
name: t('common.archived'),

View File

@ -71,19 +71,7 @@
<MsChart height="76px" width="76px" :options="execOptions" />
</div>
</div>
<div class="card-list">
<div v-for="ele of cardModuleList" :key="ele.icon" class="card-list-item">
<div class="w-full">
<div class="card-title flex items-center gap-[8px]">
<div :class="`card-title-icon bg-[${ele?.color}]`">
<MsIcon :type="ele.icon" class="text-white" size="12" />
</div>
<div class="text-[var(--color-text-1)]"> {{ ele.label }}</div>
</div>
<div class="card-number !text-[20px] !font-medium"> {{ addCommasToNumber(ele.count || 0) }} </div>
</div>
</div>
</div>
<HeaderCard :content-tab-list="cardModuleList" />
</div>
<div>
<MsChart ref="chartRef" height="280px" :options="options" />
@ -100,16 +88,18 @@
import { CascaderOption } from '@arco-design/web-vue';
import MsChart from '@/components/pure/chart/index.vue';
import bindDataZoomEvent from '@/components/pure/chart/utils';
import MsCascader from '@/components/business/ms-cascader/index.vue';
import MsStatusTag from '@/components/business/ms-status-tag/index.vue';
import CardSkeleton from './cardSkeleton.vue';
import HeaderCard from './headerCard.vue';
import ThresholdProgress from './thresholdProgress.vue';
import { getWorkTestPlanListUrl, workTestPlanOverviewDetail } from '@/api/modules/workbench';
import { commonRatePieOptions } from '@/config/workbench';
import { useI18n } from '@/hooks/useI18n';
import useAppStore from '@/store/modules/app';
import { addCommasToNumber, findNodePathByKey, mapTree } from '@/utils';
import { findNodePathByKey, mapTree } from '@/utils';
import type { ModuleCardItem, SelectedCardItem, TimeFormParams } from '@/models/workbench/homePage';
import { TestPlanStatusEnum } from '@/enums/testPlanEnum';
@ -388,12 +378,11 @@
await initOverViewDetail();
setTimeout(() => {
const chartDom = chartRef.value?.chartRef;
if (chartDom && chartDom.chart) {
createCustomTooltip(chartDom);
}
}, 0);
const chartDom = chartRef.value?.chartRef;
if (chartDom && chartDom.chart) {
createCustomTooltip(chartDom);
bindDataZoomEvent(chartRef, options);
}
}
async function handleRefreshKeyChange() {
@ -455,7 +444,7 @@
.threshold-card-item {
margin-right: 16px;
width: 34%;
height: 76px;
height: 78px;
border: 1px solid var(--color-text-n8);
border-radius: 4px;
gap: 12px;

View File

@ -71,7 +71,7 @@ export const colorMapConfig: Record<string, string[]> = {
[WorkCardEnum.CASE_COUNT]: ['#ED0303', '#FFA200', '#3370FF', '#D4D4D8'],
[WorkCardEnum.ASSOCIATE_CASE_COUNT]: ['#00C261', '#3370FF'],
[WorkCardEnum.REVIEW_CASE_COUNT]: ['#D4D4D8', '#3370FF', '#00C261', '#ED0303', '#FFA200'],
[WorkCardEnum.TEST_PLAN_COUNT]: ['#9441B1', '#3370FF', '#00C261', '#D4D4D8'],
[WorkCardEnum.TEST_PLAN_COUNT]: ['#D4D4D8', '#3370FF', '#00C261', '#FF9964'],
[WorkCardEnum.PLAN_LEGACY_BUG]: ['#FFA200', '#3370FF', '#D4D4D8', '#00C261', ...getColorScheme(13)],
[WorkCardEnum.BUG_COUNT]: ['#FFA200', '#3370FF', '#D4D4D8', '#00C261', ...getColorScheme(13)],
[WorkCardEnum.HANDLE_BUG_BY_ME]: ['#FFA200', '#3370FF', '#D4D4D8', '#00C261', ...getColorScheme(13)],
@ -81,7 +81,12 @@ export const colorMapConfig: Record<string, string[]> = {
};
// 柱状图
export function getCommonBarOptions(hasRoom: boolean, color: string[], isTestPlan = false): Record<string, any> {
export function getCommonBarOptions(
hasRoom: boolean,
color: string[],
isTestPlan = false,
fullScreen = true
): Record<string, any> {
return {
tooltip: [
{
@ -158,12 +163,11 @@ export function getCommonBarOptions(hasRoom: boolean, color: string[], isTestPla
axisLabel: {
show: true,
color: '#646466',
width: 120,
width: 100,
overflow: 'truncate',
ellipsis: '...',
showMinLabel: true,
showMaxLabel: true,
// TOTO 等待优化
interval: 0,
},
axisPointer: {
@ -186,7 +190,6 @@ export function getCommonBarOptions(hasRoom: boolean, color: string[], isTestPla
{
type: 'value',
alignTicks: true,
name: t('workbench.homePage.unit'), // 设置单位
position: 'left',
axisLine: {
show: false,
@ -272,12 +275,13 @@ export function getCommonBarOptions(hasRoom: boolean, color: string[], isTestPla
type: 'slider',
height: 24,
bottom: 10,
// TODO 待优化
realtime: true,
minSpan: 1,
maxSpan: 26,
maxValueSpan: fullScreen ? 12 : 6,
startValue: 0,
end: 30,
rangeMode: ['value', 'percent'], // 起点按实际值,终点按百分比动态计算
endValue: fullScreen ? 12 : 6,
rangeMode: ['percent', 'percent'], // 起点按实际值,终点按百分比动态计算
showDataShadow: 'auto',
showDetail: false,
filterMode: 'none',
@ -538,9 +542,9 @@ export const routeNavigationMap: Record<string, any> = {
},
complete: {
status: [
WorkNavValueEnum.TEST_PLAN_COMPLETED, // 测试计划-已完成
WorkNavValueEnum.TEST_PLAN_UNDERWAY, // 测试计划-进行中
WorkNavValueEnum.TEST_PLAN_PREPARED, // 测试计划-未开始
WorkNavValueEnum.TEST_PLAN_UNDERWAY, // 测试计划-进行中
WorkNavValueEnum.TEST_PLAN_COMPLETED, // 测试计划-已完成
WorkNavValueEnum.TEST_PLAN_ARCHIVED, // 测试计划-已归档
],
route: RouteEnum.TEST_PLAN_INDEX,
@ -637,14 +641,16 @@ export function getSeriesData(
contentTabList: ModuleCardItem[],
detail: OverViewOfProject,
colorConfig: string[],
isTestPlan = false
isTestPlan = false,
isStack = false,
fullScreen = true
) {
let options: Record<string, any> = {};
const { projectCountList, xaxis, errorCode } = detail;
const hasPermission = errorCode !== 109001;
options = getCommonBarOptions(xaxis.length >= 7, colorConfig, isTestPlan);
options = getCommonBarOptions(xaxis.length >= 7, colorConfig, isTestPlan, fullScreen);
options.xAxis.data = xaxis;
const { invisible, text } = handleNoDataDisplay(xaxis, hasPermission);
options.graphic.invisible = invisible;
@ -654,7 +660,7 @@ export function getSeriesData(
const seriesData = projectCountList.map((item, sid) => {
const countData: Record<string, any>[] = item.count.map((e) => {
return {
name: t(contentTabList[sid].label),
name: t(contentTabList[sid]?.label ?? ''),
value: e,
originValue: e,
tooltip: {
@ -683,8 +689,8 @@ export function getSeriesData(
maxAxis = Math.max(itemMax, maxAxis);
return {
name: t(contentTabList[sid].label),
const itemSeries: Record<string, any> = {
name: t(contentTabList[sid]?.label ?? ''),
type: 'bar',
barWidth: 12,
legendHoverLink: true,
@ -692,7 +698,6 @@ export function getSeriesData(
itemStyle: {
borderRadius: [2, 2, 0, 0],
},
barCategoryGap: 24,
data: countData,
barMinHeight: ((optionData: Record<string, any>[]) => {
optionData.forEach((itemValue: any, index: number) => {
@ -708,6 +713,12 @@ export function getSeriesData(
return hasZero ? 0 : 5;
})(countData),
};
if (isStack) {
itemSeries.stack = 'stack';
}
return itemSeries;
});
// 动态步长调整函数
@ -752,7 +763,7 @@ export function createCustomTooltip(chartDom: InstanceType<typeof VCharts>) {
customTooltip.textContent = `${params.value}`;
customTooltip.style.display = 'block';
customTooltip.style.left = `${clientX - 20}px`;
customTooltip.style.left = `${clientX}px`;
customTooltip.style.top = `${clientY + 10}px`;
}
});