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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user