- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页 - 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400) - setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order) - /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效 - 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐 - 测试 42 passed(settings 5 例)
144 lines
4.0 KiB
TypeScript
144 lines
4.0 KiB
TypeScript
import { Icon, IconButton, Tag } from '@stash/ui'
|
||
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
|
||
|
||
/**
|
||
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
|
||
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
|
||
*/
|
||
type SiteHeaderProps = {
|
||
projectCount: number
|
||
/** 用于展示的第一个项目名,没有项目时留空 */
|
||
sampleProject?: string
|
||
live: boolean
|
||
/** 页头滚动信息;为空时用默认三条 */
|
||
headerItems: HeaderItem[]
|
||
headerEnabled: boolean
|
||
/** 滚动一轮的秒数 */
|
||
headerSpeed: number
|
||
/** 是否已登录:决定右侧是「登录」还是「退出登录」 */
|
||
authenticated: boolean
|
||
username?: string
|
||
onFocusSearch: () => void
|
||
onLogin: () => void
|
||
onLogout: () => void
|
||
onSettings: () => void
|
||
}
|
||
|
||
const tagTone: Record<SettingsTone, 'neutral' | 'primary' | 'success'> = {
|
||
plain: 'neutral',
|
||
neutral: 'neutral',
|
||
primary: 'primary',
|
||
success: 'success',
|
||
}
|
||
|
||
type InfoGroupProps = {
|
||
items: HeaderItem[]
|
||
projectCount: number
|
||
sampleProject?: string
|
||
live: boolean
|
||
'aria-hidden'?: boolean
|
||
}
|
||
|
||
/** 运行信息:同一份内容渲染三遍,轨道平移 1/3 形成无缝滚动 */
|
||
function InfoGroup({
|
||
items,
|
||
projectCount,
|
||
sampleProject,
|
||
live,
|
||
'aria-hidden': ariaHidden,
|
||
}: InfoGroupProps) {
|
||
const context = { count: projectCount, project: sampleProject, live }
|
||
|
||
return (
|
||
<span className="header-ticker-group" aria-hidden={ariaHidden}>
|
||
{items.map((item, index) => (
|
||
<span className="header-ticker-item" key={`${index}-${item.text}`}>
|
||
{item.tag !== undefined && item.tag !== null && item.tag !== '' && (
|
||
<Tag tone={tagTone[item.tone]} shape="rounded">
|
||
{item.tag}
|
||
</Tag>
|
||
)}
|
||
{fillHeaderText(item.text, context)}
|
||
</span>
|
||
))}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
export function SiteHeader({
|
||
projectCount,
|
||
sampleProject,
|
||
live,
|
||
headerItems,
|
||
headerEnabled,
|
||
headerSpeed,
|
||
authenticated,
|
||
username,
|
||
onFocusSearch,
|
||
onLogin,
|
||
onLogout,
|
||
onSettings,
|
||
}: SiteHeaderProps) {
|
||
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>
|
||
|
||
{headerEnabled && (
|
||
<div className="header-ticker" role="status" aria-label="运行状态">
|
||
<div
|
||
className="header-ticker-track"
|
||
style={{ animationDuration: `${headerSpeed}s` }}
|
||
>
|
||
<InfoGroup
|
||
items={headerItems}
|
||
projectCount={projectCount}
|
||
sampleProject={sampleProject}
|
||
live={live}
|
||
/>
|
||
<InfoGroup
|
||
items={headerItems}
|
||
projectCount={projectCount}
|
||
sampleProject={sampleProject}
|
||
live={live}
|
||
// 后两份只用于视觉无缝,读屏忽略
|
||
aria-hidden
|
||
/>
|
||
<InfoGroup
|
||
items={headerItems}
|
||
projectCount={projectCount}
|
||
sampleProject={sampleProject}
|
||
live={live}
|
||
aria-hidden
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="header-actions">
|
||
<IconButton
|
||
label="聚焦搜索框(Ctrl+K)"
|
||
icon={<Icon name="search_3_line" />}
|
||
onClick={onFocusSearch}
|
||
/>
|
||
{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} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|