diff --git a/HANDOFF.md b/HANDOFF.md
index e8a15c7..f07643b 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -167,6 +167,7 @@ GET /api/backup SQLite 快照附件 需登录
- 当前 `PRAGMA user_version = 22`;`schema::migrate` 逐级升级:0 建全量、15→16→17→18→19→20→21→22、更高版本拒绝启动。
- 格式在 `entry.format`(apps / posters / articles),同组可混合;`node` 的旧 `view_mode` 仅保留兼容,不再决定渲染。
- `settings.sidebar.show_profile / show_service` 控制侧栏两张卡片显隐(默认 true)。
+- 首页滚动:`#page-scroll`(Layout 的 body)是唯一滚动容器,搜索框固定在其上方;左右两栏放在同一容器内,位移天然一致。
- 本地服务是运行时卡片,但 `card` 表里有一行 `local-service` 只用于排序;内容来自 `/api/projects`,后端禁止删除/改名/配置分组。
- `node` 是统一分组树(根节点 `kind='section'`,子节点 `kind='group'` 可多层);`enabled` 控制启停;`icon` 可为空。
- 主要表:`node`、`entry`、`entry_tag`、`section_block`、`admin`、`session`、`setting`、`audit`。
diff --git a/docs/ADMIN-PLAN.md b/docs/ADMIN-PLAN.md
index f914066..d152e6d 100644
--- a/docs/ADMIN-PLAN.md
+++ b/docs/ADMIN-PLAN.md
@@ -147,6 +147,13 @@ DELETE /api/items/:id
## 11. 实施记录
+### P19(2026-10-31,左右两栏共用滚动容器)
+
+- 不再用 `sider.scrollTop = content.scrollTop` 模拟同步:把 Layout 的 body(`#page-scroll`)设为滚动容器,`margin-top: 88px`、高度 `calc(100% - 88px)`,左右两栏都在这个容器里;
+- 搜索框仍 fixed 在页头下方;滚动内容从搜索框下方开始;左右两栏位移完全一致(滚动 300px 后两边 top 都是 -152);
+- 内容区 `overflow: visible`,侧栏高度改为 auto;返回顶部与路由切换都改用 `#page-scroll`;
+- 验证:滚动后两栏 top 相同、搜索框位置不变、返回顶部回到 0、切分组后滚动归零。
+
### P18(2026-10-31,搜索框下滚动、本地服务排序、拖拽末位修复)
- 前台恢复「内容区内部滚动」:页头与搜索框固定,滚动区域从搜索框下方开始;侧栏不再独立滚动,内容区滚动时同步设置侧栏 `scrollTop`,滚轮在侧栏上也驱动内容区;
diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx
index fb1caa3..7752e3d 100644
--- a/stash-web/src/AppNext.tsx
+++ b/stash-web/src/AppNext.tsx
@@ -80,7 +80,7 @@ function useRoute() {
if (window.location.pathname === path) return
window.history.pushState({}, '', path)
setRoute(parseRoute(path))
- document.getElementById('content-scroll')?.scrollTo({ top: 0 })
+ document.getElementById('page-scroll')?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}
@@ -191,50 +191,51 @@ export default function AppNext() {
return () => source.close()
}, [])
- useEffect(() => {
- const sync = () => {
- const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
- setScrolled(Math.max(inner, window.scrollY) > 4)
- }
- sync()
- window.addEventListener('scroll', sync, { passive: true })
- document.addEventListener('scroll', sync, { passive: true, capture: true })
- return () => {
- window.removeEventListener('scroll', sync)
- document.removeEventListener('scroll', sync, { capture: true })
- }
- }, [])
-
useEffect(() => {
const content = document.getElementById('content-scroll')
- const sider = document.querySelector('aside')
- if (!(content instanceof HTMLElement) || !(sider instanceof HTMLElement)) return
-
- // 左右两栏共用一个滚动位置:侧栏没有独立滚动条,滚轮也驱动内容区
- sider.id = 'sidebar-scroll'
- sider.style.overflow = 'hidden'
- let frame = 0
- const sync = () => {
- if (frame !== 0) return
- frame = window.requestAnimationFrame(() => {
- frame = 0
- sider.scrollTop = content.scrollTop
- })
- }
- const onWheel = (event: WheelEvent) => {
- event.preventDefault()
- content.scrollTop += event.deltaY
+ const main = content?.parentElement
+ const body = main?.parentElement
+ if (
+ !(content instanceof HTMLElement) ||
+ !(main instanceof HTMLElement) ||
+ !(body instanceof HTMLElement)
+ ) {
+ return
}
- content.addEventListener('scroll', sync, { passive: true })
- sider.addEventListener('wheel', onWheel, { passive: false })
+ // 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
+ const sider = body.querySelector('aside')
+ body.id = 'page-scroll'
+ body.style.overflowY = 'auto'
+ body.style.overflowX = 'hidden'
+ body.style.marginTop = '88px'
+ body.style.height = 'calc(100% - 88px)'
+ content.style.overflow = 'visible'
+ content.style.marginTop = '0'
+ main.style.height = 'auto'
+ if (sider instanceof HTMLElement) {
+ sider.style.overflow = 'visible'
+ sider.style.height = 'auto'
+ }
+
+ const onScroll = () => setScrolled(body.scrollTop > 4)
+ body.addEventListener('scroll', onScroll, { passive: true })
+ onScroll()
return () => {
- content.removeEventListener('scroll', sync)
- sider.removeEventListener('wheel', onWheel)
- if (frame !== 0) window.cancelAnimationFrame(frame)
- sider.id = ''
- sider.style.overflow = ''
+ body.removeEventListener('scroll', onScroll)
+ body.id = ''
+ body.style.overflowY = ''
+ body.style.overflowX = ''
+ body.style.marginTop = ''
+ body.style.height = ''
+ content.style.overflow = ''
+ content.style.marginTop = ''
+ main.style.height = ''
+ if (sider instanceof HTMLElement) {
+ sider.style.overflow = ''
+ sider.style.height = ''
+ }
}
}, [])
@@ -650,7 +651,7 @@ export default function AppNext() {
-
+
>
)
}
diff --git a/stash-web/src/components/common/BackToTop.tsx b/stash-web/src/components/common/BackToTop.tsx
index 68056ff..3cc4b68 100644
--- a/stash-web/src/components/common/BackToTop.tsx
+++ b/stash-web/src/components/common/BackToTop.tsx
@@ -6,8 +6,11 @@ import { ScrollTop } from '@stash/ui'
type BackToTopProps = {
/** 页面是否已滚动(由 App 传入) */
visible: boolean
+ /** 滚动容器 id,默认旧版内容区 */
+ targetId?: string
}
-export function BackToTop({ visible }: BackToTopProps) {
- return
+export function BackToTop({ visible, targetId = 'content-scroll' }: BackToTopProps) {
+ return
}
+