From a2d9fcb259621829bf4ec9680b5dbeac95ab5805 Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期四, 15 十月 2026 16:19:54 +0800
Subject: [PATCH] fix(crm): 销售目标达成分析默认查当前季度
---
src/views/bi/dashboard/modules/RealtimeTable.vue | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 164 insertions(+), 0 deletions(-)
diff --git a/src/views/bi/dashboard/modules/RealtimeTable.vue b/src/views/bi/dashboard/modules/RealtimeTable.vue
new file mode 100644
index 0000000..ec9131d
--- /dev/null
+++ b/src/views/bi/dashboard/modules/RealtimeTable.vue
@@ -0,0 +1,164 @@
+<script setup lang="ts">
+import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue';
+
+const props = withDefaults(defineProps<{
+ rows: Record<string, unknown>[];
+ formatCell?: (row: Record<string, unknown>, column: string) => unknown;
+ maxHeight?: number;
+ speed?: number;
+}>(), { maxHeight: 260, speed: 24 });
+
+const columns = computed(() => Object.keys(props.rows[0] ?? {}));
+const viewport = ref<HTMLElement>();
+const track = ref<HTMLElement>();
+const original = ref<HTMLElement>();
+const scrolling = ref(false);
+const hovered = ref(false);
+const focused = ref(false);
+const reducedMotion = ref(false);
+let animation: Animation | undefined;
+let observer: ResizeObserver | undefined;
+let motionQuery: MediaQueryList | undefined;
+let frame = 0;
+let distance = 0;
+let duration = 0;
+let active = true;
+let disposed = false;
+let measuring = false;
+
+function syncPlayback() {
+ if (!animation) return;
+ if (hovered.value || focused.value || document.hidden || !active) animation.pause();
+ else animation.play();
+}
+
+async function measure() {
+ if (disposed || measuring || !viewport.value || !original.value || !track.value) return;
+ measuring = true;
+ try {
+ const height = original.value.offsetHeight;
+ const shouldScroll = !reducedMotion.value && height > viewport.value.clientHeight + 1;
+ const nextDuration = height / Math.max(1, props.speed) * 1000;
+ if (shouldScroll && animation && height === distance && duration === nextDuration) return;
+ const offset = distance && duration
+ ? (Number(animation?.currentTime ?? 0) % duration) / duration * distance : 0;
+ animation?.cancel();
+ animation = undefined;
+ scrolling.value = shouldScroll;
+ distance = height;
+ duration = nextDuration;
+ await nextTick();
+ if (disposed || !shouldScroll || !track.value) return;
+ // Animate a normal block, not tbody. A second identical table fills the loop seam.
+ animation = track.value.animate([
+ { transform: 'translateY(0)' },
+ { transform: `translateY(-${height}px)` },
+ ], { duration, iterations: Infinity, easing: 'linear' });
+ animation.currentTime = (offset % height) / height * duration;
+ syncPlayback();
+ } finally {
+ measuring = false;
+ }
+}
+
+function scheduleMeasure() {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => { void measure(); });
+}
+
+function onMotionChange() {
+ reducedMotion.value = motionQuery?.matches ?? false;
+ scheduleMeasure();
+}
+
+function onKeydown(event: KeyboardEvent) {
+ if (!animation || !scrolling.value) return;
+ const step = event.key === 'ArrowDown' ? 40 : event.key === 'ArrowUp' ? -40 : 0;
+ if (!step) return;
+ event.preventDefault();
+ const nextTime = Number(animation.currentTime ?? 0) + step / Math.max(1, props.speed) * 1000;
+ animation.currentTime = ((nextTime % duration) + duration) % duration;
+}
+
+watch(() => [props.rows, props.maxHeight, props.speed], scheduleMeasure, { flush: 'post' });
+watch([hovered, focused], syncPlayback);
+onMounted(() => {
+ motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+ motionQuery.addEventListener('change', onMotionChange);
+ onMotionChange();
+ observer = new ResizeObserver(scheduleMeasure);
+ if (viewport.value) observer.observe(viewport.value);
+ if (original.value) observer.observe(original.value);
+ document.addEventListener('visibilitychange', syncPlayback);
+});
+onActivated(() => { active = true; scheduleMeasure(); syncPlayback(); });
+onDeactivated(() => { active = false; syncPlayback(); });
+onBeforeUnmount(() => {
+ disposed = true;
+ cancelAnimationFrame(frame);
+ animation?.cancel();
+ observer?.disconnect();
+ motionQuery?.removeEventListener('change', onMotionChange);
+ document.removeEventListener('visibilitychange', syncPlayback);
+});
+</script>
+
+<template>
+ <div
+ class="realtime-table"
+ role="region"
+ aria-label="瀹炴椂鏁版嵁琛紝鑱氱劍鎴栨偓鍋滄殏鍋滄粴鍔紝鏂瑰悜閿�愯鏌ョ湅"
+ tabindex="0"
+ :style="{ '--table-height': `${maxHeight}px`, '--table-width': `${columns.length * 120}px` }"
+ @mouseenter="hovered = true"
+ @mouseleave="hovered = false"
+ @focusin="focused = true"
+ @focusout="focused = false"
+ @keydown="onKeydown"
+ >
+ <div class="table-content">
+ <table class="header-table">
+ <thead><tr><th v-for="column in columns" :key="column" scope="col">{{ column }}</th></tr></thead>
+ </table>
+ <div ref="viewport" class="table-viewport" :class="{ 'is-scrolling': scrolling }">
+ <div ref="track" class="table-track" :class="{ 'is-scrolling': scrolling }">
+ <div ref="original">
+ <table>
+ <thead class="sr-only"><tr><th v-for="column in columns" :key="column" scope="col">{{ column }}</th></tr></thead>
+ <tbody>
+ <tr v-for="(row, index) in rows" :key="index">
+ <td v-for="column in columns" :key="column" :title="String(formatCell ? formatCell(row, column) ?? '' : row[column] ?? '')">
+ {{ formatCell ? formatCell(row, column) : row[column] }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <table v-if="scrolling" aria-hidden="true">
+ <tbody>
+ <tr v-for="(row, index) in rows" :key="index">
+ <td v-for="column in columns" :key="column">
+ {{ formatCell ? formatCell(row, column) : row[column] }}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.realtime-table { overflow-x: auto; border: 1px solid rgba(0, 229, 255, 0.08); border-radius: 8px; background: rgba(4, 14, 36, 0.55); }
+.realtime-table:focus-visible { outline: 2px solid var(--cyan, #00e5ff); outline-offset: 2px; }
+.table-content { min-width: var(--table-width); }
+table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 12px; }
+th { padding: 10px 14px; background: rgb(6, 18, 42); color: var(--text-secondary, #b9c4dc); font-size: 10px; font-weight: 600; text-align: left; border-bottom: 1px solid rgba(0, 229, 255, 0.12); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+td { padding: 9px 14px; color: var(--text-primary, #ebf0fc); border-bottom: 1px solid rgba(0, 229, 255, 0.04); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+tbody tr:hover { background: rgba(0, 229, 255, 0.06); }
+.table-viewport { max-height: var(--table-height); overflow-y: auto; scrollbar-width: thin; }
+.table-viewport.is-scrolling { overflow-y: hidden; }
+.table-track.is-scrolling { will-change: transform; }
+.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); }
+</style>
--
Gitblit v1.9.3