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
352 lines
10 KiB
Vue
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>
|