Files
ctms/frontend/src/views/ia/project-overview/EnrollmentBarChart.vue
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

352 lines
10 KiB
Vue

<template>
<div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body">
<div class="chart-empty-title">暂无入组进度数据</div>
<div class="chart-empty-desc">{{ emptyText }}</div>
</div>
</div>
<div v-else class="chart-body">
<div class="chart-scroll">
<div class="chart-plot" :style="chartPlotStyle">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#e8edf3" />
<stop offset="100%" stop-color="#f1f5f9" />
</linearGradient>
<linearGradient :id="gradientActualId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4a7c9b" />
<stop offset="100%" stop-color="#3f5d75" />
</linearGradient>
</defs>
<!-- Horizontal grid lines -->
<g class="chart-grid">
<line
v-for="tick in yTicks"
:key="`grid-${tick.value}`"
class="grid-line"
:x1="axisLeft"
:y1="tickY(tick.value)"
:x2="axisRight"
:y2="tickY(tick.value)"
/>
</g>
<!-- Y axis labels -->
<g class="chart-axis">
<g v-for="tick in yTicks" :key="`tick-${tick.value}`" class="axis-tick">
<text :x="axisLeft - 14" :y="tickY(tick.value)" class="tick-label">
{{ formatNumber(tick.value) }}
</text>
</g>
</g>
<!-- Bars -->
<g class="chart-bars">
<g v-for="(item, index) in items" :key="item.key" class="bar-group">
<title>{{ item.label }}: {{ item.actual }}{{ showTarget ? ` / ${item.target}` : '' }}</title>
<!-- Target bar (background) -->
<rect
v-if="showTarget && (item.target || 0) > 0"
class="bar-target"
:x="barLeft(index)"
:y="barTop(item.target || 0)"
:width="barWidth"
:height="barHeight(item.target || 0)"
:rx="barRadius"
:fill="`url(#${gradientTargetId})`"
/>
<!-- Actual bar -->
<rect
v-if="item.actual > 0"
class="bar-actual"
:x="barLeft(index)"
:y="barTop(item.actual)"
:width="barWidth"
:height="barHeight(item.actual)"
:rx="barRadius"
:fill="`url(#${gradientActualId})`"
/>
<!-- Value label -->
<text :x="barCenter(index)" :y="valueY(item)" class="bar-value">
<tspan class="bar-value-actual">{{ formatNumber(item.actual) }}</tspan>
<tspan v-if="showTarget" class="bar-value-divider" dx="3">/</tspan>
<tspan v-if="showTarget" class="bar-value-target" dx="3">{{ formatNumber(item.target || 0) }}</tspan>
</text>
<!-- X axis label -->
<text :x="barCenter(index)" :y="labelY" class="bar-label">
{{ truncateLabel(item.label) }}
</text>
</g>
</g>
</svg>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import StateLoading from "../../../components/StateLoading.vue";
export type ChartMode = "center" | "month";
export interface EnrollmentBarItem {
key: string;
label: string;
actual: number;
target?: number;
}
const props = withDefaults(
defineProps<{
mode: ChartMode;
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
compact?: boolean;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
compact: false,
}
);
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center");
const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = computed(() => ({
top: compact.value ? 24 : 28,
right: compact.value ? 24 : 32,
bottom: compact.value ? 42 : 48,
left: compact.value ? 46 : 56,
}));
const chartWidth = computed(() => {
if (!compact.value) return baseChartWidth.value;
const itemWidth = showTarget.value ? 72 : 64;
return Math.max(
baseChartWidth.value,
props.items.length * itemWidth + axisPadding.value.left + axisPadding.value.right,
);
});
const chartPlotStyle = computed(() => ({
"--chart-min-width": `${chartWidth.value}px`,
"--chart-aspect": `${chartWidth.value} / ${chartHeight.value}`,
}));
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
const maxValue = computed(() => {
if (!props.items.length) return 1;
return props.items.reduce((max, item) => {
const candidate = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return Math.max(max, candidate);
}, 1);
});
const yTicks = computed(() => {
const max = maxValue.value;
const step = max <= 5 ? 1 : max <= 12 ? 2 : Math.max(1, Math.ceil(max / 5));
const top = Math.max(1, Math.ceil(max / step) * step);
const ticks: Array<{ value: number; minor: boolean }> = [];
for (let value = top; value >= 0; value -= step) {
ticks.push({ value, minor: false });
}
return ticks;
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = computed(() => axisPadding.value.left);
const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = computed(() => axisPadding.value.top);
const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = computed(() => (compact.value ? 5 : 6));
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(compact.value ? 44 : 52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft.value + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap.value;
};
const truncateLabel = (label: string) => {
const limit = compact.value ? 7 : 8;
if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
</script>
<style scoped>
.enrollment-chart {
width: 100%;
}
.chart-empty-shell {
min-height: 160px;
border-radius: 12px;
padding: 22px;
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
}
.chart-empty-body {
min-height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.chart-empty-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-regular);
}
.chart-empty-desc {
margin-top: 8px;
font-size: 13px;
line-height: 1.7;
color: var(--ctms-text-secondary);
}
.chart-body {
display: flex;
flex-direction: column;
}
.chart-scroll {
overflow: hidden;
}
.chart-plot {
border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative;
width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px;
}
.chart-svg {
width: 100%;
height: 100%;
display: block;
overflow: visible;
}
.grid-line {
stroke: #e8ecf1;
stroke-width: 1px;
stroke-dasharray: 4 3;
}
.tick-label {
font-size: 11px;
fill: var(--ctms-text-disabled);
dominant-baseline: middle;
text-anchor: end;
font-weight: 500;
}
.bar-target {
stroke: #dce3ec;
stroke-width: 1px;
}
.bar-actual {
transition: opacity 0.2s ease;
}
.bar-group:hover .bar-actual {
opacity: 0.85;
}
.bar-value {
font-size: 12px;
fill: var(--ctms-text-main);
font-weight: 600;
text-anchor: middle;
}
.bar-value-actual {
fill: var(--ctms-primary);
font-weight: 700;
font-size: 13px;
}
.bar-value-divider {
fill: var(--ctms-text-disabled);
font-weight: 400;
}
.bar-value-target {
fill: var(--ctms-text-secondary);
font-weight: 500;
}
.bar-label {
font-size: 12px;
fill: var(--ctms-text-regular);
text-anchor: middle;
dominant-baseline: hanging;
font-weight: 500;
}
.enrollment-chart--compact .chart-plot {
width: max(100%, var(--chart-min-width));
min-height: 0;
border-radius: 6px;
}
.enrollment-chart--compact .chart-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.enrollment-chart--compact .tick-label {
font-size: 10px;
}
.enrollment-chart--compact .bar-value {
font-size: 11px;
}
.enrollment-chart--compact .bar-value-actual {
font-size: 12px;
}
.enrollment-chart--compact .bar-label {
font-size: 10px;
}
@media (max-width: 768px) {
.bar-label {
font-size: 11px;
}
}
</style>