fix: 左右两栏共用滚动容器,滚动位移保持一致

- 将 Layout body (#page-scroll) 作为唯一滚动容器,左右两栏同容器滚动
- 滚动区域从搜索框下方开始,搜索框固定
- 内容区 overflow visible、侧栏 height auto;返回顶部/路由归零改用 page-scroll
- 验证:滚动 300px 后两侧 top 均为 -152,搜索框不动,返回顶部有效
This commit is contained in:
2026-10-31 15:40:08 +08:00
parent 57ab90a1fa
commit 54fd0edf84
4 changed files with 54 additions and 42 deletions
+1
View File
@@ -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`。
+7
View File
@@ -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`,滚轮在侧栏上也驱动内容区;
+41 -40
View File
@@ -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() {
</div>
</Layout>
<BackToTop visible={scrolled} />
<BackToTop visible={scrolled} targetId="page-scroll" />
</>
)
}
@@ -6,8 +6,11 @@ import { ScrollTop } from '@stash/ui'
type BackToTopProps = {
/** 页面是否已滚动(由 App 传入) */
visible: boolean
/** 滚动容器 id,默认旧版内容区 */
targetId?: string
}
export function BackToTop({ visible }: BackToTopProps) {
return <ScrollTop visible={visible} targetId="content-scroll" offset={30} />
export function BackToTop({ visible, targetId = 'content-scroll' }: BackToTopProps) {
return <ScrollTop visible={visible} targetId={targetId} offset={30} />
}