|
|
@@ -2,27 +2,23 @@
|
|
|
<div class="growth-stage-timeline">
|
|
|
<div class="growth-stage-timeline__scroll" ref="scrollRef">
|
|
|
<div class="growth-stage-timeline__inner" :style="innerStyle">
|
|
|
- <!-- 生育期背景 -->
|
|
|
+ <!-- 生育期背景:同一 period 连续多列合并为一格,标题与描述跨列居中 -->
|
|
|
<div
|
|
|
class="growth-stage-timeline__bg"
|
|
|
:style="{ gridTemplateColumns: gridCols }"
|
|
|
>
|
|
|
<div
|
|
|
- v-for="(stage, i) in normalizedStages"
|
|
|
- :key="'bg-' + i"
|
|
|
- class="growth-stage-timeline__bg-cell"
|
|
|
- :class="{
|
|
|
- 'growth-stage-timeline__bg-cell--start': isPeriodStart(i),
|
|
|
- }"
|
|
|
+ v-for="(run, ri) in periodRuns"
|
|
|
+ :key="'bg-run-' + ri"
|
|
|
+ class="growth-stage-timeline__bg-cell growth-stage-timeline__bg-cell--period"
|
|
|
+ :style="{ gridColumn: `span ${run.span}` }"
|
|
|
>
|
|
|
- <template v-if="isPeriodStart(i)">
|
|
|
- <div class="growth-stage-timeline__period-title">
|
|
|
- {{ stage.periodTitle }}
|
|
|
- </div>
|
|
|
- <div class="growth-stage-timeline__period-sub">
|
|
|
- {{ stage.periodSubtitle }}
|
|
|
- </div>
|
|
|
- </template>
|
|
|
+ <div class="growth-stage-timeline__period-title">
|
|
|
+ {{ run.periodTitle }}
|
|
|
+ </div>
|
|
|
+ <div class="growth-stage-timeline__period-sub">
|
|
|
+ {{ run.periodSubtitle }}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -150,7 +146,7 @@ const props = defineProps({
|
|
|
},
|
|
|
});
|
|
|
|
|
|
-const emit = defineEmits(["update:modelValue", "change"]);
|
|
|
+const emit = defineEmits(["update:modelValue", "change", "scrollSettled"]);
|
|
|
|
|
|
const scrollRef = ref(null);
|
|
|
const trackRef = ref(null);
|
|
|
@@ -192,11 +188,37 @@ function periodKey(stage) {
|
|
|
return `${s.periodTitle || ""}\0${s.periodSubtitle || ""}`;
|
|
|
}
|
|
|
|
|
|
-function isPeriodStart(i) {
|
|
|
- if (i === 0) return true;
|
|
|
+/** 连续相同生育期合并为一段,用于背景区 grid-column span */
|
|
|
+const periodRuns = computed(() => {
|
|
|
const list = normalizedStages.value;
|
|
|
- return periodKey(list[i]) !== periodKey(list[i - 1]);
|
|
|
-}
|
|
|
+ if (!list.length) {
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ span: 1,
|
|
|
+ periodTitle: "",
|
|
|
+ periodSubtitle: "",
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ const runs = [];
|
|
|
+ let i = 0;
|
|
|
+ while (i < list.length) {
|
|
|
+ const key = periodKey(list[i]);
|
|
|
+ let span = 1;
|
|
|
+ let j = i + 1;
|
|
|
+ while (j < list.length && periodKey(list[j]) === key) {
|
|
|
+ span++;
|
|
|
+ j++;
|
|
|
+ }
|
|
|
+ runs.push({
|
|
|
+ span,
|
|
|
+ periodTitle: list[i].periodTitle ?? "",
|
|
|
+ periodSubtitle: list[i].periodSubtitle ?? "",
|
|
|
+ });
|
|
|
+ i = j;
|
|
|
+ }
|
|
|
+ return runs;
|
|
|
+});
|
|
|
|
|
|
function clampStageIndex(v) {
|
|
|
const last = Math.max(0, colCount.value - 1);
|
|
|
@@ -427,20 +449,72 @@ function onPointerUp(e) {
|
|
|
}
|
|
|
movedDuringHandleDrag = false;
|
|
|
emit("update:modelValue", nextIdx);
|
|
|
- emit("change", nextIdx, normalizedStages.value[nextIdx]);
|
|
|
+ armScrollSettledAfterHandle();
|
|
|
+}
|
|
|
+
|
|
|
+/** 手柄松开后待「横向滚动结束」再向父组件派发 change / scrollSettled */
|
|
|
+let pendingScrollSettled = false;
|
|
|
+let scrollSettledFallbackTimer = null;
|
|
|
+let scrollSettledDebounceTimer = null;
|
|
|
+
|
|
|
+function clearScrollSettledTimers() {
|
|
|
+ if (scrollSettledFallbackTimer != null) {
|
|
|
+ clearTimeout(scrollSettledFallbackTimer);
|
|
|
+ scrollSettledFallbackTimer = null;
|
|
|
+ }
|
|
|
+ if (scrollSettledDebounceTimer != null) {
|
|
|
+ clearTimeout(scrollSettledDebounceTimer);
|
|
|
+ scrollSettledDebounceTimer = null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function emitScrollSettledIfPending() {
|
|
|
+ if (!pendingScrollSettled) return;
|
|
|
+ pendingScrollSettled = false;
|
|
|
+ clearScrollSettledTimers();
|
|
|
+ const idx = activeIndex.value;
|
|
|
+ const stage = normalizedStages.value[idx];
|
|
|
+ emit("change", idx, stage);
|
|
|
+ emit("scrollSettled", idx, stage);
|
|
|
+}
|
|
|
+
|
|
|
+function armScrollSettledAfterHandle() {
|
|
|
+ pendingScrollSettled = true;
|
|
|
+ clearScrollSettledTimers();
|
|
|
+ scrollSettledFallbackTimer = setTimeout(() => {
|
|
|
+ scrollSettledFallbackTimer = null;
|
|
|
+ emitScrollSettledIfPending();
|
|
|
+ }, 200);
|
|
|
}
|
|
|
|
|
|
function onScrollAreaScroll() {
|
|
|
if (showHandleTooltip.value) clampTooltipToScrollArea();
|
|
|
+ if (!pendingScrollSettled) return;
|
|
|
+ if (scrollSettledDebounceTimer != null) {
|
|
|
+ clearTimeout(scrollSettledDebounceTimer);
|
|
|
+ }
|
|
|
+ scrollSettledDebounceTimer = setTimeout(() => {
|
|
|
+ scrollSettledDebounceTimer = null;
|
|
|
+ emitScrollSettledIfPending();
|
|
|
+ }, 80);
|
|
|
+}
|
|
|
+
|
|
|
+function onScrollAreaScrollEnd() {
|
|
|
+ emitScrollSettledIfPending();
|
|
|
+}
|
|
|
+
|
|
|
+function onWindowResize() {
|
|
|
+ if (showHandleTooltip.value) clampTooltipToScrollArea();
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
window.addEventListener("pointermove", onPointerMove);
|
|
|
window.addEventListener("pointerup", onPointerUp);
|
|
|
window.addEventListener("pointercancel", onPointerUp);
|
|
|
- window.addEventListener("resize", onScrollAreaScroll);
|
|
|
+ window.addEventListener("resize", onWindowResize);
|
|
|
const el = scrollRef.value;
|
|
|
el?.addEventListener("scroll", onScrollAreaScroll, { passive: true });
|
|
|
+ el?.addEventListener("scrollend", onScrollAreaScrollEnd, { passive: true });
|
|
|
el?.addEventListener("wheel", onTimelineWheel, { passive: false });
|
|
|
nextTick(() => clampTooltipToScrollArea());
|
|
|
el?.addEventListener("touchstart", onScrollAreaTouchStart, {
|
|
|
@@ -470,14 +544,17 @@ onBeforeUnmount(() => {
|
|
|
window.removeEventListener("pointermove", onPointerMove);
|
|
|
window.removeEventListener("pointerup", onPointerUp);
|
|
|
window.removeEventListener("pointercancel", onPointerUp);
|
|
|
- window.removeEventListener("resize", onScrollAreaScroll);
|
|
|
+ window.removeEventListener("resize", onWindowResize);
|
|
|
const el = scrollRef.value;
|
|
|
el?.removeEventListener("scroll", onScrollAreaScroll);
|
|
|
+ el?.removeEventListener("scrollend", onScrollAreaScrollEnd);
|
|
|
el?.removeEventListener("wheel", onTimelineWheel);
|
|
|
el?.removeEventListener("touchstart", onScrollAreaTouchStart);
|
|
|
el?.removeEventListener("touchmove", onScrollAreaTouchMove);
|
|
|
resizeObserver?.disconnect();
|
|
|
resizeObserver = null;
|
|
|
+ clearScrollSettledTimers();
|
|
|
+ pendingScrollSettled = false;
|
|
|
});
|
|
|
</script>
|
|
|
|
|
|
@@ -504,42 +581,48 @@ onBeforeUnmount(() => {
|
|
|
|
|
|
.growth-stage-timeline__bg {
|
|
|
display: grid;
|
|
|
- min-height: 56px;
|
|
|
+ align-items: stretch;
|
|
|
border-radius: 6px 6px 0 0;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
.growth-stage-timeline__bg-cell {
|
|
|
background: #f0f2f5;
|
|
|
- padding: 8px 6px;
|
|
|
+ padding: 8px 10px;
|
|
|
text-align: center;
|
|
|
box-sizing: border-box;
|
|
|
border-right: 1px solid rgba(0, 0, 0, 0.04);
|
|
|
+ min-width: 0;
|
|
|
|
|
|
&:last-child {
|
|
|
border-right: none;
|
|
|
}
|
|
|
|
|
|
- &--start {
|
|
|
+ &--period {
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
- gap: 2px;
|
|
|
+ gap: 4px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.growth-stage-timeline__period-title {
|
|
|
- font-size: 15px;
|
|
|
+ font-size: 14px;
|
|
|
font-weight: 600;
|
|
|
color: #1d2129;
|
|
|
line-height: 1.2;
|
|
|
+ text-align: center;
|
|
|
+ max-width: 100%;
|
|
|
}
|
|
|
|
|
|
.growth-stage-timeline__period-sub {
|
|
|
font-size: 11px;
|
|
|
color: rgba(60, 60, 60, 0.45);
|
|
|
line-height: 1.3;
|
|
|
+ text-align: center;
|
|
|
+ max-width: 100%;
|
|
|
+ word-break: break-word;
|
|
|
}
|
|
|
|
|
|
.growth-stage-timeline__track {
|