diff --git a/frontend/src/components/TableControls.test.js b/frontend/src/components/TableControls.test.js
new file mode 100644
index 0000000..8c95567
--- /dev/null
+++ b/frontend/src/components/TableControls.test.js
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const source = readFileSync(join(__dirname, "TableControls.vue"), "utf8");
+const styles = readFileSync(join(__dirname, "../styles/main.css"), "utf8");
+
+describe("TableControls wide-table scroll dock", () => {
+ it("creates an app-level horizontal scroll dock for wide mode instead of relying on native scrollbars", () => {
+ assert.match(source, /function ensureWideTableScrollDock\(/);
+ assert.match(source, /className = "table-scroll-dock"/);
+ assert.match(source, /aria-label", "宽表横向滚动"/);
+ assert.match(source, /table-scroll-dock-track/);
+ assert.match(source, /table-scroll-dock-thumb/);
+ assert.match(source, /table-scroll-dock-arrow/);
+ });
+
+ it("keeps the scroll dock synchronized with the table wrapper scroll position", () => {
+ assert.match(source, /function syncWideTableScrollDock\(/);
+ assert.match(source, /wideScrollWrap\.scrollLeft/);
+ assert.match(source, /wideScrollWrap\.scrollWidth - wideScrollWrap\.clientWidth/);
+ assert.match(source, /wideScrollThumb\.style\.transform/);
+ assert.match(source, /wideScrollThumb\.style\.width/);
+ });
+
+ it("supports dragging the custom thumb and using left-right step arrows", () => {
+ assert.match(source, /pointerdown/);
+ assert.match(source, /setPointerCapture/);
+ assert.match(source, /function nudgeWideTableScrollDock\(/);
+ assert.match(source, /wideScrollWrap\.scrollBy/);
+ });
+
+ it("styles the scroll dock as the accepted visible low-friction control", () => {
+ assert.match(styles, /body \.table-scroll-dock/);
+ assert.match(styles, /body \.table-scroll-dock-track/);
+ assert.match(styles, /body \.table-scroll-dock-thumb/);
+ assert.match(styles, /body \.table-scroll-dock-arrow/);
+ assert.match(styles, /body \.table-view-wide \+ \.table-scroll-dock/);
+ });
+});
diff --git a/frontend/src/components/TableControls.vue b/frontend/src/components/TableControls.vue
index 426cd3c..bbb1508 100644
--- a/frontend/src/components/TableControls.vue
+++ b/frontend/src/components/TableControls.vue
@@ -78,6 +78,13 @@ let headerObserver = null;
let enhanceFrame = 0;
let modeFrame = 0;
let isEnhancing = false;
+let wideScrollDock = null;
+let wideScrollWrap = null;
+let wideScrollTrack = null;
+let wideScrollThumb = null;
+let wideScrollResizeObserver = null;
+let wideScrollCleanupHandlers = [];
+let wideScrollFrame = 0;
function normalizeLabel(value) {
return String(value || "")
@@ -131,6 +138,212 @@ function clearTableViewClasses(table) {
delete table.dataset.tableViewMode;
}
+function removeWideTableScrollDock() {
+ wideScrollCleanupHandlers.forEach((cleanup) => cleanup());
+ wideScrollCleanupHandlers = [];
+ if (wideScrollResizeObserver) {
+ wideScrollResizeObserver.disconnect();
+ wideScrollResizeObserver = null;
+ }
+ if (wideScrollFrame) {
+ window.cancelAnimationFrame(wideScrollFrame);
+ wideScrollFrame = 0;
+ }
+ wideScrollDock?.remove();
+ wideScrollDock = null;
+ wideScrollWrap = null;
+ wideScrollTrack = null;
+ wideScrollThumb = null;
+}
+
+function getWideScrollMetrics() {
+ if (!wideScrollWrap || !wideScrollTrack) {
+ return null;
+ }
+
+ const maxScroll = Math.max(0, wideScrollWrap.scrollWidth - wideScrollWrap.clientWidth);
+ const trackWidth = wideScrollTrack.clientWidth || 0;
+ const thumbWidth = maxScroll > 0
+ ? Math.max(72, Math.min(trackWidth, (wideScrollWrap.clientWidth / wideScrollWrap.scrollWidth) * trackWidth))
+ : trackWidth;
+ const thumbTravel = Math.max(0, trackWidth - thumbWidth);
+ const progress = maxScroll > 0 ? wideScrollWrap.scrollLeft / maxScroll : 0;
+
+ return { maxScroll, trackWidth, thumbWidth, thumbTravel, progress };
+}
+
+function syncWideTableScrollDock() {
+ const metrics = getWideScrollMetrics();
+ if (!metrics || !wideScrollDock || !wideScrollThumb) {
+ return;
+ }
+
+ wideScrollDock.classList.toggle("table-scroll-dock-disabled", metrics.maxScroll <= 1);
+ wideScrollThumb.style.width = `${metrics.thumbWidth}px`;
+ wideScrollThumb.style.transform = `translateX(${metrics.thumbTravel * metrics.progress}px)`;
+}
+
+function scheduleWideTableScrollDockSync() {
+ if (wideScrollFrame) {
+ return;
+ }
+
+ wideScrollFrame = window.requestAnimationFrame(() => {
+ wideScrollFrame = 0;
+ syncWideTableScrollDock();
+ });
+}
+
+function nudgeWideTableScrollDock(direction) {
+ if (!wideScrollWrap) {
+ return;
+ }
+
+ wideScrollWrap.scrollBy({
+ left: direction * Math.max(180, wideScrollWrap.clientWidth * 0.42),
+ behavior: "smooth"
+ });
+}
+
+function createWideTableScrollDock() {
+ const dock = document.createElement("div");
+ dock.className = "table-scroll-dock";
+ dock.setAttribute("aria-label", "宽表横向滚动");
+
+ const leftArrow = document.createElement("button");
+ leftArrow.type = "button";
+ leftArrow.className = "table-scroll-dock-arrow table-scroll-dock-arrow-left";
+ leftArrow.setAttribute("aria-label", "向左滚动宽表");
+ leftArrow.innerHTML = '‹';
+
+ const track = document.createElement("div");
+ track.className = "table-scroll-dock-track";
+
+ const thumb = document.createElement("button");
+ thumb.type = "button";
+ thumb.className = "table-scroll-dock-thumb";
+ thumb.setAttribute("aria-label", "拖动查看宽表隐藏字段");
+ thumb.innerHTML = '';
+
+ const rightArrow = document.createElement("button");
+ rightArrow.type = "button";
+ rightArrow.className = "table-scroll-dock-arrow table-scroll-dock-arrow-right";
+ rightArrow.setAttribute("aria-label", "向右滚动宽表");
+ rightArrow.innerHTML = '›';
+
+ track.appendChild(thumb);
+ dock.append(leftArrow, track, rightArrow);
+
+ wideScrollTrack = track;
+ wideScrollThumb = thumb;
+
+ const handleTrackPointerDown = (event) => {
+ if (!wideScrollWrap || event.target === thumb) {
+ return;
+ }
+
+ const metrics = getWideScrollMetrics();
+ if (!metrics?.maxScroll || !metrics.thumbTravel) {
+ return;
+ }
+
+ const rect = track.getBoundingClientRect();
+ const targetProgress = Math.min(1, Math.max(0, (event.clientX - rect.left - metrics.thumbWidth / 2) / metrics.thumbTravel));
+ wideScrollWrap.scrollLeft = targetProgress * metrics.maxScroll;
+ syncWideTableScrollDock();
+ };
+
+ const handleThumbPointerDown = (event) => {
+ const metrics = getWideScrollMetrics();
+ if (!wideScrollWrap || !metrics?.maxScroll || !metrics.thumbTravel) {
+ return;
+ }
+
+ event.preventDefault();
+ thumb.setPointerCapture?.(event.pointerId);
+ dock.classList.add("table-scroll-dock-dragging");
+
+ const startX = event.clientX;
+ const startScrollLeft = wideScrollWrap.scrollLeft;
+ const handlePointerMove = (moveEvent) => {
+ const delta = moveEvent.clientX - startX;
+ wideScrollWrap.scrollLeft = startScrollLeft + (delta / metrics.thumbTravel) * metrics.maxScroll;
+ syncWideTableScrollDock();
+ };
+ const handlePointerUp = () => {
+ dock.classList.remove("table-scroll-dock-dragging");
+ thumb.removeEventListener("pointermove", handlePointerMove);
+ thumb.removeEventListener("pointerup", handlePointerUp);
+ thumb.removeEventListener("pointercancel", handlePointerUp);
+ };
+
+ thumb.addEventListener("pointermove", handlePointerMove);
+ thumb.addEventListener("pointerup", handlePointerUp);
+ thumb.addEventListener("pointercancel", handlePointerUp);
+ };
+
+ const handleKeyDown = (event) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ nudgeWideTableScrollDock(-1);
+ }
+ if (event.key === "ArrowRight") {
+ event.preventDefault();
+ nudgeWideTableScrollDock(1);
+ }
+ };
+ const handleLeftClick = () => nudgeWideTableScrollDock(-1);
+ const handleRightClick = () => nudgeWideTableScrollDock(1);
+
+ leftArrow.addEventListener("click", handleLeftClick);
+ rightArrow.addEventListener("click", handleRightClick);
+ track.addEventListener("pointerdown", handleTrackPointerDown);
+ thumb.addEventListener("pointerdown", handleThumbPointerDown);
+ thumb.addEventListener("keydown", handleKeyDown);
+ wideScrollCleanupHandlers.push(() => leftArrow.removeEventListener("click", handleLeftClick));
+ wideScrollCleanupHandlers.push(() => rightArrow.removeEventListener("click", handleRightClick));
+ wideScrollCleanupHandlers.push(() => track.removeEventListener("pointerdown", handleTrackPointerDown));
+ wideScrollCleanupHandlers.push(() => thumb.removeEventListener("pointerdown", handleThumbPointerDown));
+ wideScrollCleanupHandlers.push(() => thumb.removeEventListener("keydown", handleKeyDown));
+
+ return dock;
+}
+
+function ensureWideTableScrollDock(wrap) {
+ if (!wrap || tableMode.value !== "wide") {
+ removeWideTableScrollDock();
+ return;
+ }
+
+ if (wideScrollWrap !== wrap) {
+ removeWideTableScrollDock();
+ }
+
+ wideScrollWrap = wrap;
+ if (!wideScrollDock) {
+ wideScrollDock = createWideTableScrollDock();
+ wrap.insertAdjacentElement("afterend", wideScrollDock);
+
+ const handleWrapScroll = () => scheduleWideTableScrollDockSync();
+ wideScrollWrap.addEventListener("scroll", handleWrapScroll, { passive: true });
+ window.addEventListener("resize", scheduleWideTableScrollDockSync);
+ if (typeof ResizeObserver !== "undefined") {
+ wideScrollResizeObserver = new ResizeObserver(scheduleWideTableScrollDockSync);
+ wideScrollResizeObserver.observe(wideScrollWrap);
+ const table = wideScrollWrap.querySelector("table");
+ if (table) {
+ wideScrollResizeObserver.observe(table);
+ }
+ }
+ wideScrollCleanupHandlers.push(() => wideScrollWrap?.removeEventListener("scroll", handleWrapScroll));
+ wideScrollCleanupHandlers.push(() => window.removeEventListener("resize", scheduleWideTableScrollDockSync));
+ } else if (wideScrollDock.previousElementSibling !== wrap) {
+ wrap.insertAdjacentElement("afterend", wideScrollDock);
+ }
+
+ scheduleWideTableScrollDockSync();
+}
+
function disconnectModeObserver() {
if (modeTableObserver) {
modeTableObserver.disconnect();
@@ -320,6 +533,11 @@ function applyTableViewMode(table = findTableAfterControl()) {
refreshCellTooltips(table);
bindCellTooltip(table);
observeTableForViewMode(table);
+ if (isWide) {
+ ensureWideTableScrollDock(wrap);
+ } else {
+ removeWideTableScrollDock();
+ }
}
function scheduleApplyTableViewMode() {
@@ -573,6 +791,7 @@ onBeforeUnmount(() => {
modeFrame = 0;
}
clearTableViewClasses(modeTable);
+ removeWideTableScrollDock();
disconnectModeObserver();
clearTooltipListeners();
clearEnhancedHeaders();
diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css
index 76697db..6e49bc5 100644
--- a/frontend/src/styles/main.css
+++ b/frontend/src/styles/main.css
@@ -11588,12 +11588,12 @@ body .table-view-wide {
overflow-x: scroll !important;
overflow-y: hidden !important;
scrollbar-gutter: stable both-edges;
- scrollbar-width: thin;
- scrollbar-color: rgba(100, 116, 139, 0.5) rgba(226, 232, 240, 0.72);
+ scrollbar-width: none;
}
body .table-view-wide::-webkit-scrollbar {
- height: 12px;
+ width: 0;
+ height: 0;
}
body .table-view-wide::-webkit-scrollbar-track {
@@ -11611,6 +11611,158 @@ body .table-view-wide::-webkit-scrollbar-thumb:hover {
background: rgba(71, 85, 105, 0.72);
}
+body .table-view-wide + .table-scroll-dock,
+body .table-scroll-dock {
+ position: relative;
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) 34px;
+ gap: 10px;
+ align-items: center;
+ min-height: 56px;
+ margin-top: -1px;
+ padding: 10px 16px;
+ border: 1px solid rgba(205, 218, 233, 0.92);
+ border-radius: 0 0 18px 18px;
+ background:
+ linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.92),
+ 0 14px 26px rgba(15, 23, 42, 0.06);
+ box-sizing: border-box;
+}
+
+body .table-view-wide + .table-scroll-dock::before,
+body .table-view-wide + .table-scroll-dock::after,
+body .table-scroll-dock::before,
+body .table-scroll-dock::after {
+ position: absolute;
+ top: 1px;
+ bottom: 1px;
+ z-index: 1;
+ width: 58px;
+ pointer-events: none;
+ content: "";
+}
+
+body .table-view-wide + .table-scroll-dock::before,
+body .table-scroll-dock::before {
+ left: 0;
+ background: linear-gradient(90deg, rgba(248, 251, 255, 0.98), rgba(248, 251, 255, 0));
+}
+
+body .table-view-wide + .table-scroll-dock::after,
+body .table-scroll-dock::after {
+ right: 0;
+ background: linear-gradient(270deg, rgba(248, 251, 255, 0.98), rgba(248, 251, 255, 0));
+}
+
+body .table-scroll-dock-arrow {
+ position: relative;
+ z-index: 2;
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border: 1px solid rgba(191, 206, 224, 0.9);
+ border-radius: 12px;
+ background:
+ linear-gradient(180deg, #ffffff 0%, #eef5fc 100%);
+ color: #64748b;
+ font: inherit;
+ font-size: 30px;
+ font-weight: 500;
+ line-height: 1;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.96),
+ 0 6px 14px rgba(15, 23, 42, 0.06);
+ cursor: pointer;
+ transition:
+ border-color 0.16s ease,
+ color 0.16s ease,
+ transform 0.16s ease,
+ box-shadow 0.16s ease;
+}
+
+body .table-scroll-dock-arrow span {
+ transform: translateY(-2px);
+}
+
+body .table-scroll-dock-arrow:hover {
+ border-color: rgba(64, 158, 255, 0.48);
+ color: #1677ff;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.96),
+ 0 8px 18px rgba(22, 119, 255, 0.14);
+ transform: translateY(-1px);
+}
+
+body .table-scroll-dock-track {
+ position: relative;
+ z-index: 2;
+ height: 22px;
+ overflow: hidden;
+ border-radius: 999px;
+ background:
+ linear-gradient(180deg, rgba(226, 234, 243, 0.98), rgba(211, 222, 235, 0.98));
+ box-shadow:
+ inset 0 1px 2px rgba(15, 23, 42, 0.12),
+ inset 0 -1px 0 rgba(255, 255, 255, 0.82);
+ cursor: pointer;
+}
+
+body .table-scroll-dock-thumb {
+ position: absolute;
+ top: 3px;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 72px;
+ height: 16px;
+ border: 0;
+ border-radius: 999px;
+ background:
+ linear-gradient(90deg, #8fc4ff 0%, #409eff 35%, #1677ff 100%);
+ box-shadow:
+ 0 7px 16px rgba(22, 119, 255, 0.24),
+ inset 0 1px 0 rgba(255, 255, 255, 0.34);
+ cursor: grab;
+ touch-action: none;
+ transition:
+ box-shadow 0.16s ease,
+ filter 0.16s ease;
+}
+
+body .table-scroll-dock-thumb span {
+ width: 28px;
+ height: 10px;
+ background:
+ linear-gradient(90deg, transparent 0 5px, rgba(255, 255, 255, 0.68) 5px 7px, transparent 7px 11px, rgba(255, 255, 255, 0.68) 11px 13px, transparent 13px 17px, rgba(255, 255, 255, 0.68) 17px 19px, transparent 19px);
+}
+
+body .table-scroll-dock-thumb:hover,
+body .table-scroll-dock-dragging .table-scroll-dock-thumb {
+ box-shadow:
+ 0 10px 24px rgba(22, 119, 255, 0.32),
+ inset 0 1px 0 rgba(255, 255, 255, 0.42);
+ filter: saturate(1.08);
+}
+
+body .table-scroll-dock-dragging .table-scroll-dock-thumb {
+ cursor: grabbing;
+}
+
+body .table-scroll-dock-disabled {
+ display: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ body .table-scroll-dock-arrow,
+ body .table-scroll-dock-thumb {
+ transition: none;
+ }
+}
+
body .table-view-wide .data-table,
body .table-view-wide .compact-table,
body .table-view-wide .drawer-data-table,