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
+282 -104
View File
@@ -4,6 +4,7 @@ import {
FormField,
Icon,
ICON_NAMES,
IconButton,
Input,
Modal,
Notice,
@@ -58,7 +59,7 @@ import type { Project } from '../../api'
* - 内容是条目,挂在分组上;
* - 侧边栏、首页卡片都只是「勾选分组」的展示组合。
*/
type TabKey = 'groups' | 'content' | 'sidebar' | 'cards' | 'header' | 'profile' | 'system'
type TabKey = 'groups' | 'content' | 'home' | 'header' | 'profile' | 'system'
const FORMAT_OPTIONS = [
{ value: 'apps', label: '应用' },
@@ -124,6 +125,23 @@ export function AdminNext() {
void load()
}, [])
// 后台壳也要吃站点级设置:保存后标签标题 / favicon 立刻变,否则会以为「设了没反应」
// 注意:这里的 `document` 是设置文档,浏览器文档要用 `window.document`
useEffect(() => {
const meta = document?.settings.site
if (meta === undefined) return
const title = meta.title.trim()
if (title !== '') window.document.title = title
const href = meta.favicon.trim() === '' ? '/favicon.svg' : meta.favicon.trim()
let link = window.document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = window.document.createElement('link')
link.rel = 'icon'
window.document.head.appendChild(link)
}
link.href = href
}, [document?.settings.site.title, document?.settings.site.favicon])
const authenticated = session?.authenticated === true
const save = async () => {
@@ -173,36 +191,45 @@ export function AdminNext() {
await load()
}
const brand = document?.settings.site.brand ?? { name: 'STASH', mark: 'S', logo: '' }
const brandName = brand.name.trim()
const brandMark = brand.mark.trim() || brandName.slice(0, 1) || 'S'
const brandLogo = brand.logo.trim()
return (
<div className="admin-page">
<Toaster />
{/* 与前台页头同一套结构:品牌在左、图标操作在右,整条吸顶 */}
<header className="admin-topbar">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</a>
<Space gap="sm">
<Button
variant="ghost"
icon={<Icon name="arrow_left_line" />}
onClick={() => window.location.assign('/')}
>
返回首页
</Button>
{authenticated && (
<Button
variant="secondary"
icon={<Icon name="exit_line" />}
onClick={() => {
void logout().then(() => window.location.assign('/'))
}}
>
退出登录
</Button>
)}
</Space>
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
{brandLogo !== '' ? (
<img className="site-brand-logo" src={brandLogo} alt="" />
) : (
<span className="site-brand-mark" aria-hidden="true">
{brandMark}
</span>
)}
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
</a>
<div className="header-actions">
<IconButton
label="返回首页"
icon={<Icon name="arrow_left_line" />}
onClick={() => window.location.assign('/')}
/>
{authenticated && (
<IconButton
label="退出登录"
icon={<Icon name="exit_line" />}
onClick={() => {
void logout().then(() => window.location.assign('/'))
}}
/>
)}
</div>
</div>
</header>
<main className="admin-main">
@@ -230,8 +257,7 @@ export function AdminNext() {
items={[
{ key: 'groups', label: '分组管理' },
{ key: 'content', label: '内容' },
{ key: 'sidebar', label: '侧边栏' },
{ key: 'cards', label: '首页卡片' },
{ key: 'home', label: '首页' },
{ key: 'header', label: '页头' },
{ key: 'profile', label: '资料' },
{ key: 'system', label: '系统' },
@@ -250,42 +276,54 @@ export function AdminNext() {
{tab === 'content' && <ContentPanel groups={groups} onReload={load} />}
{tab === 'sidebar' && (
<SidebarPanel
groups={groups}
value={draft.sidebar}
saving={saving}
onToggle={(key, visible) => {
const hidden = new Set(draft.sidebar.tree_hidden)
if (visible) hidden.delete(key)
else hidden.add(key)
void saveSidebar({ ...draft.sidebar, tree_hidden: [...hidden] })
}}
onToggleCard={(card, visible) => {
void saveSidebar({
...draft.sidebar,
...(card === 'profile'
? { show_profile: visible }
: { show_service: visible }),
})
}}
/>
)}
{tab === 'home' && (
<div className="admin-home">
<section className="admin-home-section">
<Text size="md" strong>
侧边栏
</Text>
<SidebarPanel
value={draft.sidebar}
saving={saving}
onToggleCard={(card, visible) => {
void saveSidebar({
...draft.sidebar,
...(card === 'profile'
? { show_profile: visible }
: card === 'service'
? { show_service: visible }
: { show_nav: visible }),
})
}}
/>
</section>
{tab === 'cards' && (
<CardsPanel
groups={groups}
cards={cards}
projects={projects}
onReload={load}
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
/>
<div className="sidebar-divider" />
<section className="admin-home-section">
<Text size="md" strong>
首页卡片
</Text>
<Text size="sm" tone="muted">
卡片 = 一张首页区块 + 勾选它包含哪些分组;分组本身的启停仍由「分组管理」决定。
</Text>
<CardsPanel
groups={groups}
cards={cards}
projects={projects}
onReload={load}
onDelete={(id, title) => setPending({ kind: 'card', id: String(id), title })}
/>
</section>
</div>
)}
{tab === 'header' && (
<HeaderPanel
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
site={draft.site}
onChangeSite={(site) => setDraft({ ...draft, site })}
/>
)}
@@ -1200,41 +1238,18 @@ function EntryDialog({
// ---------- 侧边栏(勾选分组树) ----------
function SidebarPanel({
groups,
value,
onToggle,
onToggleCard,
saving,
}: {
groups: GroupNode[]
value: SiteSettings['sidebar']
onToggle: (key: string, visible: boolean) => void
onToggleCard: (card: 'profile' | 'service', visible: boolean) => void
onToggleCard: (card: 'profile' | 'service' | 'nav', visible: boolean) => void
saving: boolean
}) {
const hidden = new Set(value.tree_hidden)
function render(nodes: GroupNode[], depth: number) {
return nodes.map((node) => (
<div key={node.key} style={{ marginLeft: depth * 22 }}>
<div className="sidebar-toggle-row">
<Text className="admin-item-title">{node.title}</Text>
<Switch
checked={!hidden.has(node.key)}
onChange={(visible) => onToggle(node.key, visible)}
label={`侧栏显示 ${node.title}`}
disabled={saving}
/>
</div>
{render(node.children, depth + 1)}
</div>
))
}
return (
<Space direction="column" gap="md" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
侧栏卡片与分组导航分开控制;开关修改后立即保存。
分组在侧边栏的显隐只由「分组管理 → 状态」决定;这里只管三块侧栏卡片。
</Text>
<Text size="sm" strong>
@@ -1258,16 +1273,15 @@ function SidebarPanel({
disabled={saving}
/>
</div>
<div className="sidebar-divider" />
<Text size="sm" strong>
分组导航
</Text>
<Text size="sm" tone="muted">
勾选要在侧边栏显示的分组;子分组可以单独勾选,父分组不勾选也不影响子项。
</Text>
{render(groups, 0)}
<div className="sidebar-toggle-row">
<Text className="admin-item-title">分组导航</Text>
<Switch
checked={value.show_nav !== false}
onChange={(visible) => onToggleCard('nav', visible)}
label="显示分组导航卡片"
disabled={saving}
/>
</div>
</Space>
)
}
@@ -1552,7 +1566,7 @@ function CardTable({
onDelete,
}: {
card: CardItem
options: { key: string; title: string; depth: number }[]
options: { key: string; title: string; depth: number; enabled: boolean }[]
onReload: () => void
onDelete: (id: number, title: string) => void
}) {
@@ -1568,13 +1582,20 @@ function CardTable({
),
)
const [busy, setBusy] = useState(false)
/** 勾选的分组按此顺序在首页卡片里出现;启用先后不再决定顺序 */
const [order, setOrder] = useState<string[]>(() => card.groups.map((group) => group.groupKey))
const persistGroups = async (nextSelected: Record<string, string>) => {
const persistGroups = async (nextSelected: Record<string, string>, nextOrder: string[]) => {
setBusy(true)
const groups = Object.entries(nextSelected).map(([groupKey, limit]) => ({
groupKey,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
}))
const groups = nextOrder
.filter((key) => key in nextSelected)
.map((groupKey) => {
const limit = nextSelected[groupKey] ?? ''
return {
groupKey,
previewLimit: limit.trim() === '' ? undefined : Number(limit),
}
})
const result = await setCardGroups(card.id, groups)
setBusy(false)
if (!result.ok) {
@@ -1587,10 +1608,28 @@ function CardTable({
const toggle = (key: string, checked: boolean) => {
const next = { ...selected }
if (checked) next[key] = ''
else delete next[key]
let nextOrder = order
if (checked) {
next[key] = ''
if (!order.includes(key)) nextOrder = [...order, key]
} else {
delete next[key]
nextOrder = order.filter((item) => item !== key)
}
setSelected(next)
void persistGroups(next)
setOrder(nextOrder)
void persistGroups(next, nextOrder)
}
const moveGroup = (key: string, delta: number) => {
const index = order.indexOf(key)
const target = index + delta
if (index < 0 || target < 0 || target >= order.length) return
const nextOrder = [...order]
nextOrder.splice(index, 1)
nextOrder.splice(target, 0, key)
setOrder(nextOrder)
void persistGroups(selected, nextOrder)
}
const saveTitle = async () => {
@@ -1646,7 +1685,9 @@ function CardTable({
<thead>
<tr>
<th>分组名称</th>
<th className="cards-col-state">状态</th>
<th className="cards-col-limit">预览条数</th>
<th className="cards-col-order">顺序</th>
<th className="group-col-status">展示</th>
</tr>
</thead>
@@ -1660,6 +1701,11 @@ function CardTable({
<span className="group-name">{option.title}</span>
</div>
</td>
<td>
<span className={option.enabled ? 'group-state-on' : 'group-off-tag'}>
{option.enabled ? '启用' : '已停用'}
</span>
</td>
<td>
{checked ? (
<Input
@@ -1673,18 +1719,41 @@ function CardTable({
[option.key]: event.target.value,
}))
}
onBlur={() => void persistGroups(selected)}
onBlur={() => void persistGroups(selected, order)}
/>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td>
{checked ? (
<div className="card-order-cell">
<span className="card-order-index">{order.indexOf(option.key) + 1}</span>
<IconButton
label={`${option.title} 上移`}
icon={<Icon name="arrow_up_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === 0}
onClick={() => moveGroup(option.key, -1)}
/>
<IconButton
label={`${option.title} 下移`}
icon={<Icon name="arrow_down_line" />}
size="xs"
disabled={busy || order.indexOf(option.key) === order.length - 1}
onClick={() => moveGroup(option.key, 1)}
/>
</div>
) : (
<span className="entry-href entry-href-empty">—</span>
)}
</td>
<td>
<Switch
checked={checked}
onChange={(next) => toggle(option.key, next)}
label={`卡片包含 ${option.title}`}
disabled={busy}
disabled={busy || !option.enabled}
/>
</td>
</tr>
@@ -1704,13 +1773,122 @@ const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label:
function HeaderPanel({
value,
onChange,
site,
onChangeSite,
}: {
value: SiteSettings['header']
onChange: (header: SiteSettings['header']) => void
site: SiteSettings['site']
onChangeSite: (site: SiteSettings['site']) => void
}) {
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<Text size="sm" strong>
站点信息
</Text>
<FormField label="页面标题" htmlFor="site-title" hint="浏览器标签页上的文字">
<Input
id="site-title"
value={site.title}
placeholder="Stash · 本地应用工作区"
onChange={(event) => onChangeSite({ ...site, title: event.target.value })}
/>
</FormField>
<FormField label="Favicon 地址" htmlFor="site-favicon" hint="留空使用内置图标;支持 .svg / .png 或完整 URL">
<Input
id="site-favicon"
value={site.favicon}
placeholder="/favicon.svg"
onChange={(event) => onChangeSite({ ...site, favicon: event.target.value })}
/>
</FormField>
<Text size="sm" strong>
首页 Logo
</Text>
<div className="brand-preview" aria-hidden="true">
{site.brand.logo.trim() !== '' ? (
<img className="site-brand-logo" src={site.brand.logo} alt="" />
) : (
<span className="site-brand-mark">
{(site.brand.mark.trim() || site.brand.name.trim().slice(0, 1) || 'S').slice(0, 4)}
</span>
)}
{site.brand.name.trim() !== '' && (
<span className="site-brand-name">{site.brand.name}</span>
)}
</div>
<FormField label="Logo 图片地址" htmlFor="site-logo" hint="留空则用右侧的首字圆形标记">
<Input
id="site-logo"
value={site.brand.logo}
placeholder="https://… 或 /logo.svg"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, logo: event.target.value } })
}
/>
</FormField>
<div className="admin-row">
<FormField label="品牌文字" htmlFor="site-brand-name" hint="可留空只显示 logo">
<Input
id="site-brand-name"
value={site.brand.name}
placeholder="STASH"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, name: event.target.value } })
}
/>
</FormField>
<FormField label="首字标记" htmlFor="site-brand-mark" hint="1–4 个字">
<Input
id="site-brand-mark"
value={site.brand.mark}
placeholder="S"
onChange={(event) =>
onChangeSite({ ...site, brand: { ...site.brand, mark: event.target.value } })
}
/>
</FormField>
</div>
<Text size="sm" strong>
操作工具
</Text>
<div className="sidebar-toggle-row">
<Text>刷新</Text>
<Switch
checked={site.actions.refresh}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, refresh: next } })
}
label="页头显示刷新按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>站点设置</Text>
<Switch
checked={site.actions.settings}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, settings: next } })
}
label="页头显示站点设置按钮"
/>
</div>
<div className="sidebar-toggle-row">
<Text>登录 / 退出</Text>
<Switch
checked={site.actions.session}
onChange={(next) =>
onChangeSite({ ...site, actions: { ...site.actions, session: next } })
}
label="页头显示登录或退出按钮"
/>
</div>
<Text size="sm" strong>
滚动信息
</Text>
<div className="sidebar-toggle-row">
<Text>显示页头滚动信息</Text>
<Switch
checked={value.enabled}
+75 -23
View File
@@ -1,8 +1,15 @@
import { useEffect, useRef } from 'react'
import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
import {
fillHeaderText,
type HeaderItem,
type SettingsTone,
type SiteMetaSettings,
} from '../../settings'
/**
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 品牌(logo / 文字)与操作图标是否出现,都来自「页头」Tab 的站点设置。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
*/
type SiteHeaderProps = {
@@ -15,10 +22,14 @@ type SiteHeaderProps = {
headerEnabled: boolean
/** 滚动一轮的秒数 */
headerSpeed: number
/** 品牌与操作工具配置 */
site: SiteMetaSettings
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
authenticated: boolean
username?: string
onFocusSearch: () => void
/** 刷新中:刷新按钮转圈 */
refreshing?: boolean
onRefresh: () => void
onLogin: () => void
onLogout: () => void
onSettings: () => void
@@ -72,25 +83,62 @@ export function SiteHeader({
headerItems,
headerEnabled,
headerSpeed,
site,
authenticated,
username,
onFocusSearch,
refreshing = false,
onRefresh,
onLogin,
onLogout,
onSettings,
}: SiteHeaderProps) {
const brandName = site.brand.name.trim()
const brandMark = site.brand.mark.trim() || brandName.slice(0, 1) || 'S'
const brandLogo = site.brand.logo.trim()
// 跑马灯的位移要正好等于「一组」的宽度,循环才接得上。
// CSS 里的 -100%/3 会按轨道的 border-box(只有一组宽)算,导致提前 2/3 回跳,
// 所以这里量一次真实宽度写进 --ticker-shift。
const trackRef = useRef<HTMLDivElement>(null)
const tickerKey = `${headerItems.map((item) => `${item.tag ?? ''}:${item.text}`).join('|')}@${headerSpeed}`
useEffect(() => {
const track = trackRef.current
if (track === null) return
const sync = () => {
const group = track.firstElementChild
if (!(group instanceof HTMLElement)) return
track.style.setProperty('--ticker-shift', `${-group.getBoundingClientRect().width}px`)
}
sync()
const observer = new ResizeObserver(sync)
observer.observe(track)
window.addEventListener('resize', sync)
return () => {
observer.disconnect()
window.removeEventListener('resize', sync)
}
}, [tickerKey])
return (
<div className="site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
<a className="site-brand" href="/" aria-label={`${brandName || '首页'} 首页`}>
{brandLogo !== '' ? (
<img className="site-brand-logo" src={brandLogo} alt="" />
) : (
<span className="site-brand-mark" aria-hidden="true">
{brandMark}
</span>
)}
{brandName !== '' && <span className="site-brand-name">{brandName}</span>}
</a>
{headerEnabled && (
<div className="header-ticker" role="status" aria-label="运行状态">
<div
ref={trackRef}
className="header-ticker-track"
style={{ animationDuration: `${headerSpeed}s` }}
>
@@ -120,23 +168,27 @@ export function SiteHeader({
)}
<div className="header-actions">
<IconButton
label="聚焦搜索框(Ctrl+K)"
icon={<Icon name="search_3_line" />}
onClick={onFocusSearch}
/>
{authenticated && (
{site.actions.refresh && (
<IconButton
label="刷新(重扫项目并重拉内容)"
icon={<Icon name="refresh_3_line" />}
spinning={refreshing}
onClick={onRefresh}
/>
)}
{site.actions.settings && authenticated && (
<IconButton label="站点设置" icon={<Icon name="settings_1_line" />} onClick={onSettings} />
)}
{authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
)}
{site.actions.session &&
(authenticated ? (
<IconButton
label={username === undefined ? '退出登录' : `退出登录(${username})`}
icon={<Icon name="exit_line" />}
onClick={onLogout}
/>
) : (
<IconButton label="登录" icon={<Icon name="user_3_line" />} onClick={onLogin} />
))}
</div>
</div>
)