项目概览、参与者管理初步优化(未接入真实数据)
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="enrollment-chart">
|
||||
<StateLoading v-if="loading" :rows="5" />
|
||||
<StateEmpty v-else-if="items.length === 0" :description="emptyText" />
|
||||
<div v-else class="chart-body">
|
||||
<div class="chart-scroll">
|
||||
<div class="chart-plot">
|
||||
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f1f5f9" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</linearGradient>
|
||||
<filter :id="shadowId" x="-20%" y="-20%" width="140%" height="160%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#3f5d75" flood-opacity="0.18" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
class="chart-frame"
|
||||
x="0.5"
|
||||
:y="frameTop"
|
||||
:width="chartWidth - 1"
|
||||
:height="frameHeight"
|
||||
rx="12"
|
||||
/>
|
||||
<g class="chart-axis">
|
||||
<line
|
||||
class="axis-line"
|
||||
:x1="axisLeft"
|
||||
:y1="axisTop"
|
||||
:x2="axisLeft"
|
||||
:y2="axisBottom"
|
||||
/>
|
||||
<line
|
||||
class="axis-line"
|
||||
:x1="axisLeft"
|
||||
:y1="axisBottom"
|
||||
:x2="axisRight"
|
||||
:y2="axisBottom"
|
||||
/>
|
||||
<g v-for="tick in yTicks" :key="tick.value" class="axis-tick">
|
||||
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label">
|
||||
{{ formatNumber(tick.value) }}
|
||||
</text>
|
||||
<line
|
||||
class="tick-line"
|
||||
:class="{ minor: tick.minor }"
|
||||
:x1="axisLeft - 8"
|
||||
:y1="tickY(tick.value)"
|
||||
:x2="axisLeft"
|
||||
:y2="tickY(tick.value)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g class="chart-bars">
|
||||
<g v-for="(item, index) in items" :key="item.key" class="bar-group">
|
||||
<title>{{ item.label }}</title>
|
||||
<rect
|
||||
v-if="showTarget"
|
||||
class="bar-target"
|
||||
:x="barLeft(index)"
|
||||
:y="barTop(item.target || 0)"
|
||||
:width="barWidth"
|
||||
:height="barHeight(item.target || 0)"
|
||||
rx="8"
|
||||
:fill="`url(#${gradientId})`"
|
||||
/>
|
||||
<rect
|
||||
class="bar-actual"
|
||||
:x="barLeft(index)"
|
||||
:y="barTop(item.actual)"
|
||||
:width="barWidth"
|
||||
:height="barHeight(item.actual)"
|
||||
rx="8"
|
||||
:filter="`url(#${shadowId})`"
|
||||
/>
|
||||
<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="4">/</tspan>
|
||||
<tspan v-if="showTarget" class="bar-value-target" dx="4">{{ formatNumber(item.target || 0) }}</tspan>
|
||||
</text>
|
||||
<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";
|
||||
import StateEmpty from "../../../components/StateEmpty.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;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
emptyText: "暂无入组数据",
|
||||
}
|
||||
);
|
||||
|
||||
const showTarget = computed(() => props.mode === "center");
|
||||
|
||||
const chartWidth = 960;
|
||||
const chartHeight = 300;
|
||||
const axisPadding = {
|
||||
top: 24,
|
||||
right: 24,
|
||||
bottom: 52,
|
||||
left: 72,
|
||||
};
|
||||
|
||||
const gradientId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const shadowId = `enroll-shadow-${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 majorStep = Math.max(1, Math.ceil(max / 4));
|
||||
const top = majorStep * 4;
|
||||
const ticks: Array<{ value: number; minor: boolean }> = [];
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const value = top - i * majorStep;
|
||||
ticks.push({ value, minor: false });
|
||||
if (i < 4) {
|
||||
ticks.push({ value: value - Math.round(majorStep / 2), minor: true });
|
||||
}
|
||||
}
|
||||
return ticks.filter((tick) => tick.value >= 0).sort((a, b) => b.value - a.value);
|
||||
});
|
||||
|
||||
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
|
||||
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
|
||||
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
|
||||
const axisLeft = axisPadding.left;
|
||||
const axisRight = chartWidth - axisPadding.right;
|
||||
const axisTop = axisPadding.top;
|
||||
const axisBottom = chartHeight - axisPadding.bottom;
|
||||
const labelY = axisBottom + 18;
|
||||
const valueGap = 8;
|
||||
const valuePadding = 8;
|
||||
|
||||
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
|
||||
const barWidth = computed(() => Math.min(48, bandWidth.value * 0.6));
|
||||
|
||||
const barCenter = (index: number) => axisLeft + 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 + plotHeight.value - barHeight(value);
|
||||
const tickY = (value: number) => axisTop + ((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;
|
||||
};
|
||||
|
||||
const minValueY = computed(() => {
|
||||
if (!props.items.length) return axisTop;
|
||||
return Math.min(
|
||||
...props.items.map((item) => {
|
||||
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
|
||||
return barTop(anchor) - valueGap;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const extraTop = computed(() => Math.max(0, valuePadding - minValueY.value));
|
||||
const frameTop = computed(() => 0.5 - extraTop.value);
|
||||
const frameHeight = computed(() => chartHeight - 1 + extraTop.value);
|
||||
|
||||
const truncateLabel = (label: string) => {
|
||||
if (label.length <= 8) return label;
|
||||
return `${label.slice(0, 7)}...`;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.enrollment-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-scroll {
|
||||
overflow: hidden;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.chart-plot {
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 5;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.chart-frame {
|
||||
fill: #ffffff;
|
||||
stroke: #000000;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.axis-line,
|
||||
.tick-line {
|
||||
stroke: #000000;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
font-size: 11px;
|
||||
fill: #000000;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: end;
|
||||
}
|
||||
|
||||
.bar-target {
|
||||
stroke: #e2e8f0;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.bar-actual {
|
||||
fill: var(--ctms-primary);
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: 12px;
|
||||
fill: var(--ctms-text-main);
|
||||
font-weight: 600;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.bar-value-actual {
|
||||
fill: var(--ctms-text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-value-divider,
|
||||
.bar-value-target {
|
||||
fill: var(--ctms-text-disabled);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 12px;
|
||||
fill: var(--ctms-text-regular);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: hanging;
|
||||
}
|
||||
|
||||
.chart-footnote {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bar-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user