From 79ce5cfd2eacf7f2834c57c829e7c80b1a767c90 Mon Sep 17 00:00:00 2001 From: voson Date: Thu, 30 Jul 2026 09:18:31 +0800 Subject: [PATCH] v0.6.3: hero detail bottom drawer that pushes role tags, matches page summary dedupe. Co-authored-by: Cursor --- CHANGELOG.md | 7 ++ web/export_relations_site.py | 2 +- web/frontend/app.js | 214 ++++++++++++++++++++++++++++++++++- web/frontend/config.js | 2 +- web/frontend/index.html | 15 +-- web/frontend/style.css | 111 +++++++++++++----- 6 files changed, 308 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b3c7f..a84a614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +## [0.6.3] - 2026-07-30 + +### Changed + +- 英雄详情改为自底部上滑抽屉:高度展开顶起定位标签(标签不挂在面板上);点击空白或下滑可关闭;去掉标签行全宽底与打开时的网格遮罩条。 +- 比赛页去掉筛选侧栏重复的「共 X 场 · 第 X/X 页」(仅保留列表底部分页区统计)。 + ## [0.6.2] - 2026-07-30 ### Added diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 444273f..ec59500 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -56,7 +56,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.2" +SITE_VERSION = "0.6.3" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" diff --git a/web/frontend/app.js b/web/frontend/app.js index 5860c08..c6aa509 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -425,6 +425,15 @@ function onHeroClick(key) { function clearHeroDetail() { if (!state.selectedKey) return false; + const drawer = $("#detail-drawer"); + if ( + drawer && + !drawer.classList.contains("is-collapsed") && + !detailDrawerClosing + ) { + collapseDetailDrawerThenClear(); + return true; + } state.selectedKey = null; state.inspect = null; syncStateToUrl(); @@ -432,6 +441,188 @@ function clearHeroDetail() { return true; } +let detailDrawerDrag = null; +let detailDrawerSuppressClickUntil = 0; +let detailDrawerClosing = false; + +function detailDrawerFullHeight() { + const panel = $("#detail"); + if (panel && panel.offsetHeight) return panel.offsetHeight; + return Math.min(720, Math.round((window.innerHeight || 800) * 0.88)); +} + +function syncDetailDrawer() { + const drawer = $("#detail-drawer"); + if (!drawer) return; + const open = state.page === "heroes" && !!state.selectedKey && !detailDrawerClosing; + drawer.setAttribute("aria-hidden", open ? "false" : "true"); + document.body.classList.toggle("detail-drawer-open", open); + if (!open) { + drawer.classList.add("is-collapsed"); + drawer.style.maxHeight = ""; + resetDetailDrawerDrag(); + return; + } + drawer.classList.remove("is-collapsed"); + drawer.style.maxHeight = ""; +} + +function resetDetailDrawerDrag() { + const drawer = $("#detail-drawer"); + if (drawer) { + drawer.classList.remove("is-dragging"); + if (!detailDrawerClosing) drawer.style.maxHeight = ""; + } + detailDrawerDrag = null; +} + +function setDetailDrawerDragOffset(dy) { + const drawer = $("#detail-drawer"); + if (!drawer) return; + const full = detailDrawerDrag?.fullH || detailDrawerFullHeight() || 1; + const h = Math.max(0, full - Math.max(0, dy)); + drawer.style.maxHeight = `${h}px`; +} + +function endDetailDrawerDrag(dy, velocityY) { + const drawer = $("#detail-drawer"); + if (!drawer) { + resetDetailDrawerDrag(); + return; + } + const full = detailDrawerDrag?.fullH || detailDrawerFullHeight() || 1; + const shouldClose = dy > Math.min(140, full * 0.22) || velocityY > 0.85; + if (detailDrawerDrag?.moved) { + detailDrawerSuppressClickUntil = performance.now() + 280; + } + if (shouldClose) { + collapseDetailDrawerThenClear(); + return; + } + drawer.classList.remove("is-dragging"); + drawer.style.maxHeight = ""; + detailDrawerDrag = null; +} + +function collapseDetailDrawerThenClear() { + const drawer = $("#detail-drawer"); + if (!state.selectedKey) return; + if (!drawer || detailDrawerClosing) { + state.selectedKey = null; + state.inspect = null; + syncStateToUrl(); + render(); + return; + } + detailDrawerClosing = true; + const fromH = drawer.offsetHeight || detailDrawerFullHeight(); + drawer.classList.add("is-dragging"); + drawer.style.maxHeight = `${fromH}px`; + void drawer.offsetHeight; + drawer.classList.remove("is-dragging"); + drawer.classList.add("is-collapsed"); + drawer.style.maxHeight = "0px"; + document.body.classList.remove("detail-drawer-open"); + let done = false; + const finish = () => { + if (done) return; + done = true; + drawer.removeEventListener("transitionend", finish); + detailDrawerClosing = false; + drawer.style.maxHeight = ""; + resetDetailDrawerDrag(); + state.selectedKey = null; + state.inspect = null; + syncStateToUrl(); + render(); + }; + const reduce = + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) { + finish(); + return; + } + drawer.addEventListener("transitionend", finish); + window.setTimeout(finish, 360); +} + +/** True when the event target is drawer chrome (handle / head / empty padding), not interactive content. */ +function isDetailDrawerDismissTarget(target) { + if (!(target instanceof Element)) return false; + if (target.closest(".detail-drawer-handle, .detail-head")) return true; + if (target.closest("button, a, input, select, textarea, label, video, .hero-inspect, .detail-tab-body, .detail-tabs, .detail-abilities, .detail-items, .detail-skills-layout, .detail-items-layout, .detail-stats-layout, .detail-matchups-layout, .detail-matches-layout, .detail-streamers-layout, .detail-patches-layout")) { + return false; + } + return target.id === "detail" || target.classList.contains("detail-drawer-panel"); +} + +function bindDetailDrawerGestures() { + const drawer = $("#detail-drawer"); + const panel = $("#detail"); + if (!drawer || !panel) return; + + panel.addEventListener("click", (e) => { + if (state.page !== "heroes" || !state.selectedKey) return; + if (performance.now() < detailDrawerSuppressClickUntil) return; + if (!isDetailDrawerDismissTarget(e.target)) return; + collapseDetailDrawerThenClear(); + }); + + const onPointerDown = (e) => { + if (state.page !== "heroes" || !state.selectedKey) return; + if (e.button != null && e.button !== 0) return; + if (!e.target.closest(".detail-drawer-handle, .detail-head")) return; + const fullH = detailDrawerFullHeight(); + detailDrawerDrag = { + pointerId: e.pointerId, + startY: e.clientY, + lastY: e.clientY, + lastT: performance.now(), + dy: 0, + velocityY: 0, + moved: false, + fullH, + }; + drawer.classList.add("is-dragging"); + drawer.style.maxHeight = `${fullH}px`; + try { + drawer.setPointerCapture(e.pointerId); + } catch (_) { + /* ignore */ + } + }; + + const onPointerMove = (e) => { + if (!detailDrawerDrag || e.pointerId !== detailDrawerDrag.pointerId) return; + const now = performance.now(); + const dy = e.clientY - detailDrawerDrag.startY; + const dt = Math.max(1, now - detailDrawerDrag.lastT); + detailDrawerDrag.velocityY = (e.clientY - detailDrawerDrag.lastY) / dt; + detailDrawerDrag.lastY = e.clientY; + detailDrawerDrag.lastT = now; + detailDrawerDrag.dy = dy; + if (Math.abs(dy) > 4) detailDrawerDrag.moved = true; + if (dy > 0) setDetailDrawerDragOffset(dy); + else setDetailDrawerDragOffset(0); + }; + + const onPointerUp = (e) => { + if (!detailDrawerDrag || e.pointerId !== detailDrawerDrag.pointerId) return; + const { dy, velocityY, moved } = detailDrawerDrag; + if (!moved && dy <= 0) { + resetDetailDrawerDrag(); + return; + } + endDetailDrawerDrag(Math.max(0, dy), velocityY); + }; + + drawer.addEventListener("pointerdown", onPointerDown); + drawer.addEventListener("pointermove", onPointerMove); + drawer.addEventListener("pointerup", onPointerUp); + drawer.addEventListener("pointercancel", onPointerUp); +} + function renderTagbar() { const bar = $("#tagbar"); if (!bar) return; @@ -2957,18 +3148,27 @@ function renderDetail() { root.innerHTML = ""; if (!state.selectedKey) { - root.classList.add("empty", "is-collapsed"); + root.classList.add("empty"); + root.innerHTML = ""; + syncDetailDrawer(); return; } - root.classList.remove("is-collapsed", "empty"); + root.classList.remove("empty"); const hero = heroByKey(state.selectedKey); if (!hero) { root.classList.add("empty"); root.textContent = "未找到英雄数据"; + syncDetailDrawer(); return; } + const handle = document.createElement("button"); + handle.type = "button"; + handle.className = "detail-drawer-handle"; + handle.setAttribute("aria-label", "下滑或点击关闭详情"); + root.appendChild(handle); + const head = document.createElement("div"); head.className = "detail-head"; const title = document.createElement("h2"); @@ -3112,6 +3312,7 @@ function renderDetail() { layout.appendChild(renderHeroInspect(state.selectedKey)); root.appendChild(layout); } + syncDetailDrawer(); } function shopCatalog() { @@ -3932,7 +4133,6 @@ function renderMatchesPage() { const body = $("#matches-body"); const originsEl = $("#matches-origins"); const playersEl = $("#matches-players"); - const sub = $("#matches-sub"); if (!body) return; const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin) @@ -4039,7 +4239,6 @@ function renderMatchesPage() { const summary = total ? `共 ${total} 场 · 第 ${page}/${pageCount} 页` : ""; - if (sub) sub.textContent = summary; if (!rows.length) { body.innerHTML = state.matchesPlayerId @@ -5608,6 +5807,7 @@ function syncChrome() { document.querySelectorAll(".main-tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.page === state.page); }); + const detailDrawer = $("#detail-drawer"); const heroesTb = $("#heroes-toolbar"); const heroSearch = $("#q"); const itemSearch = $("#q-item"); @@ -5619,8 +5819,8 @@ function syncChrome() { const mechanicsView = $("#mechanics-view"); const itemsView = $("#items-view"); const patchesView = $("#patches-view"); - const detail = $("#detail"); if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes"); + if (detailDrawer) detailDrawer.classList.toggle("hidden", state.page !== "heroes"); if (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes"); if (itemSearch) itemSearch.classList.toggle("hidden", state.page !== "items"); if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes"); @@ -5631,7 +5831,8 @@ function syncChrome() { if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics"); if (itemsView) itemsView.classList.toggle("hidden", state.page !== "items"); if (patchesView) patchesView.classList.toggle("hidden", state.page !== "patches"); - if (detail) detail.classList.toggle("hidden", state.page !== "heroes"); + // Drawer open animation needs detail content laid out first; heroes path syncs in renderDetail. + if (state.page !== "heroes") syncDetailDrawer(); } function render() { @@ -5842,6 +6043,7 @@ function bindHeroesDismiss() { clearHeroDetail(); }); } + bindDetailDrawerGestures(); } async function main() { diff --git a/web/frontend/config.js b/web/frontend/config.js index 887a74c..1de8741 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.2"; +var SITE_VERSION = "0.6.3"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index 5d8e24e..248ae31 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

DOTA2 上分帝

@@ -150,7 +150,6 @@
-

@@ -229,11 +228,13 @@
-
+
- - - + + + diff --git a/web/frontend/style.css b/web/frontend/style.css index 9de8a4b..fdbb60e 100644 --- a/web/frontend/style.css +++ b/web/frontend/style.css @@ -318,7 +318,12 @@ body { min-height: 52px; margin: 0 14px var(--space-sm); padding: var(--space-sm) var(--space-md); - background: rgba(8, 12, 20, 0.62); + position: relative; + z-index: 3; + background: transparent; +} +body.detail-drawer-open .hero-role-toolbar { + margin-bottom: 0; } .toolbar.hidden, .board.hidden, @@ -464,8 +469,7 @@ body { overflow: auto; } -/* Hero picker fills leftover space above role toolbar + pinned detail; - scrolls inside when the grid is taller than the region. */ +/* Hero grid above role tags; detail sits below tags and grows upward to push them. */ #heroes-view { flex: 1 1 auto; min-height: 0; @@ -473,25 +477,81 @@ body { overflow: auto; } -/* Heroes-only (#detail hidden on items page): pin to bottom with a gap above - the hero grid + role toolbar. One shared fixed height across all tabs so - 技能 ↔ 核心装备 ↔ 被克装备 does not jump; tall tabs (走势/对位/…) scroll - inside .detail-tab-body. Prefer a taller detail on large viewports — the - hero grid only needs ~one board of portraits (+ scroll), not half the screen. */ -.detail { - --detail-fixed-h: min(640px, calc(100dvh - clamp(280px, 32vh, 380px) - 7rem)); +/* In-flow bottom drawer: height animates so the tagbar above is pushed up. */ +.detail-drawer { + --detail-fixed-h: min(720px, 88dvh); + flex: 0 0 auto; + position: relative; + z-index: 2; + width: 100%; + max-height: 0; + overflow: hidden; + pointer-events: none; + transition: max-height 320ms cubic-bezier(0.32, 0.72, 0, 1); +} +.detail-drawer:not(.is-collapsed) { + max-height: var(--detail-fixed-h); + pointer-events: auto; +} +.detail-drawer.is-dragging { + transition: none; +} +.detail-drawer-panel.detail { + --detail-fixed-h: inherit; display: flex; flex-direction: column; align-items: center; - flex: 0 0 auto; - margin: 0 14px var(--space-md); - padding: var(--space-md) var(--space-lg); + width: 100%; + margin: 0; + padding: var(--space-sm) var(--space-lg) var(--space-md); border: 1px solid var(--line); - border-radius: var(--radius-lg); - background: var(--panel-soft); - min-height: 0; - overflow: hidden; + border-bottom: none; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + background: var(--panel); + box-shadow: 0 -12px 36px rgba(0, 0, 0, 0.35); box-sizing: border-box; + overflow: hidden; +} +.detail-drawer-handle { + flex: 0 0 auto; + align-self: center; + width: 56px; + height: 20px; + margin: 0 auto 6px; + border: 0; + padding: 0; + background: transparent; + cursor: grab; + touch-action: none; + position: relative; +} +.detail-drawer-handle::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 44px; + height: 4px; + border-radius: var(--radius-full); + background: rgba(140, 170, 210, 0.38); + transform: translate(-50%, -50%); +} +.detail-drawer.is-dragging .detail-drawer-handle { + cursor: grabbing; +} +body.detail-drawer-open { + overflow: hidden; +} +@media (prefers-reduced-motion: reduce) { + .detail-drawer { + transition: none; + } +} + +.detail { + display: flex; + flex-direction: column; + align-items: center; } .heroes-site-foot { flex: 0 0 auto; @@ -510,22 +570,17 @@ body { min-height: var(--detail-fixed-h); max-height: var(--detail-fixed-h); } -.detail:not(.empty) > * { +.detail:not(.empty) > *:not(.detail-drawer-handle) { width: 100%; max-width: none; } -.detail.is-collapsed { - display: none; -} -.detail.empty:not(.is-collapsed) { +.detail.empty { height: auto; - min-height: 64px; + min-height: 0; max-height: none; - justify-content: center; - color: var(--muted); - font-size: var(--fs-body); - line-height: var(--lh-body); - letter-spacing: 0.04em; + padding: 0; + border: none; + box-shadow: none; } .detail-head { display: flex;