<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>
|