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:
+122
-101
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user