feat: 搜索带并入 Layout toolbar,后台重组首页配置

前台
- 搜索框从内容列移到 Layout 的 toolbar 插槽,去掉 fixed + TOP_OFFSET + top-blur 三处补偿
- 左右栏滚动容器不变,左栏吸顶改用 CSS sticky(修回顶时侧栏抖动)
- 只让分组导航滚动,资料卡与本地服务固定
- 页头操作支持配置:搜索改为刷新(重扫项目 + 重拉内容库)
- 站点级设置:页面标题 / favicon / 品牌 logo / 品牌文字
- 停用的分组在侧边栏、首页卡片、分组页、条目页统一不再出现

后台
- 「侧边栏」「首页卡片」合并为「首页」Tab
- 首页卡片表格新增状态列与顺序列(上下箭头即时保存)
- 分组显隐只由「分组管理 → 状态」决定,去掉侧栏独立勾选
- 页头 Tab 增加站点信息、首页 Logo(带实时预览)、操作工具开关
- 后台页头与前台同构并吸顶

后端
- settings 新增 site(标题 / favicon / 品牌 / 操作工具)与 sidebar.show_nav
- 写接口成功后广播内容库变更,SSE 改为「广播 + data_version 兜底」
- catalog SSE 事件名与前端监听对齐(catalog)
This commit is contained in:
2026-11-01 15:15:20 +08:00
parent bb5a615ed2
commit 59fd61d832
9 changed files with 822 additions and 296 deletions
+122 -101
View File
@@ -132,14 +132,24 @@ function findEntry(
}
function groupEntryCount(group: GroupNode): number {
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
const children = group.children.filter((child) => child.enabled !== false)
return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */
function isGroupVisible(groups: GroupNode[], key: string): boolean {
const path = findPath(groups, key)
if (path === undefined) return false
return path.every((node) => node.enabled !== false)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children.flatMap((child) => collectEntries(child)),
...group.children
.filter((child) => child.enabled !== false)
.flatMap((child) => collectEntries(child)),
]
}
@@ -161,8 +171,6 @@ function matches(entry: Record<string, unknown>, keyword: string): boolean {
return text.includes(keyword)
}
const TOP_OFFSET = 104
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
@@ -189,7 +197,14 @@ export default function AppNext() {
void fetchSettings().then(setSettings)
const source = new EventSource('/api/catalog/events')
source.onmessage = () => load()
// 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接;
// 收到后连站点设置一起重拉,改完设置不用手动刷新页面。
const onCatalogChange = () => {
load()
void fetchSettings().then(setSettings)
}
source.onmessage = onCatalogChange
source.addEventListener('catalog', onCatalogChange)
return () => source.close()
}, [])
@@ -205,69 +220,42 @@ export default function AppNext() {
return
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
const sider = body.querySelector('aside')
// 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。
// 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside):
// 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。
// 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto,
// 侧栏比视口高时就没法在栏内滚动了。
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
body.style.marginTop = `${TOP_OFFSET}px`
body.style.height = `calc(100% - ${TOP_OFFSET}px)`
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
if (sider instanceof HTMLElement) {
sider.style.overflow = 'visible'
sider.style.height = 'auto'
sider.style.alignSelf = 'flex-start'
}
const onScroll = () => setScrolled(body.scrollTop > 4)
// 顶部软虚化层:覆盖搜索区域与内容顶部,边缘用 mask 渐隐,避免硬分割线
const blur = document.createElement('div')
blur.className = 'top-blur'
blur.setAttribute('aria-hidden', 'true')
const appRoot = document.querySelector('.app')
appRoot?.appendChild(blur)
const updateSticky = () => {
const viewport = body.clientHeight
const maxLeft = Math.max(0, sider instanceof HTMLElement ? sider.offsetHeight - viewport : 0)
const offset = Math.max(0, body.scrollTop - maxLeft)
if (sider instanceof HTMLElement) {
sider.style.transform = offset > 0 ? `translateY(${offset}px)` : ''
}
// 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
// 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
const forwardWheel = (event: WheelEvent) => {
if (event.target instanceof Node && body.contains(event.target)) return
// deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
event.preventDefault()
}
window.addEventListener('wheel', forwardWheel, { passive: false })
body.addEventListener('scroll', onScroll, { passive: true })
body.addEventListener('scroll', updateSticky, { passive: true })
window.addEventListener('resize', updateSticky)
const observer = new ResizeObserver(updateSticky)
observer.observe(content)
if (sider instanceof HTMLElement) observer.observe(sider)
onScroll()
updateSticky()
return () => {
body.removeEventListener('scroll', onScroll)
body.removeEventListener('scroll', updateSticky)
window.removeEventListener('resize', updateSticky)
observer.disconnect()
blur.remove()
window.removeEventListener('wheel', forwardWheel)
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 = ''
sider.style.alignSelf = ''
sider.style.transform = ''
}
}
}, [])
@@ -276,6 +264,7 @@ export default function AppNext() {
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
@@ -292,41 +281,52 @@ export default function AppNext() {
location: '',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { refresh: true, settings: true, session: true },
},
}
const keywordLower = keyword.trim().toLowerCase()
const sidebarItems: MenuItem[] = useMemo(() => {
const hidden = new Set(site.sidebar.tree_hidden)
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
if (title !== '') document.title = title
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
const ranks = new Map(
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
)
return [...nodes].sort((a, b) => {
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
if (left === right) return a.sort - b.sort
return left - right
})
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
sortNodes(nodes, parentKey).flatMap((node) => {
if (node.enabled === false) return []
if (hidden.has(node.key)) return build(node.children, node.key)
const children = build(node.children, node.key)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
const sidebarItems: MenuItem[] = useMemo(() => {
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
const build = (nodes: GroupNode[]): MenuItem[] =>
[...nodes]
.sort((a, b) => a.sort - b.sort)
.flatMap((node) => {
if (node.enabled === false) return []
const children = build(node.children)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups])
const activeKey =
route.kind === 'home'
@@ -367,11 +367,16 @@ export default function AppNext() {
}
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
const all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
// 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次
const rootGroup = group.parentKey === undefined || group.parentKey === null
const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && <h3 className="card-group-title">{group.title}</h3>}
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
{renderEntries(group, all, { limit })}
</div>
)
@@ -406,7 +411,7 @@ export default function AppNext() {
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
.filter(
(item): item is { ref: typeof item.ref; group: GroupNode } =>
item.group !== undefined && item.group.enabled !== false,
item.group !== undefined && isGroupVisible(manage.groups, item.ref.groupKey),
)
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
@@ -445,11 +450,13 @@ export default function AppNext() {
function renderGroupPage(key: string) {
const group = findGroup(manage.groups, key)
if (group === undefined) {
if (group === undefined || !isGroupVisible(manage.groups, key)) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return (
<div className="detail-view">
@@ -486,7 +493,9 @@ export default function AppNext() {
)}
<Space direction="column" gap="lg" align="stretch">
{group.children.map((child) => (
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<section key={child.key} className="group-child-block">
<div className="group-title">
<span
@@ -495,16 +504,18 @@ export default function AppNext() {
aria-hidden="true"
/>
<h3>{child.title}</h3>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
{!rootGroup && (
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
)}
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 })
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
@@ -517,7 +528,7 @@ export default function AppNext() {
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined) {
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
}
@@ -613,7 +624,6 @@ export default function AppNext() {
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={TOP_OFFSET}
height="screen"
scrollable
hideScrollbar
@@ -627,9 +637,14 @@ export default function AppNext() {
headerItems={site.header.items}
headerEnabled={site.header.enabled}
headerSpeed={site.header.speed}
site={site.site}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
refreshing={refreshing}
onRefresh={() => {
refresh()
load()
}}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
@@ -637,6 +652,11 @@ export default function AppNext() {
onSettings={() => window.location.assign('/admin')}
/>
}
toolbar={
<div className="search-toolbar">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
}
sider={
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
@@ -649,23 +669,24 @@ export default function AppNext() {
onRefresh={refresh}
/>
)}
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/>
</div>
{site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/>
</div>
)}
</div>
</div>
}
>
<div className="content">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (