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