feat(admin): 登录改页内弹窗 + P1 站点设置(页头/侧栏)

- 登录:LoginDialog(Modal)替代 /login 页面;/login 重定向回首页
- 设置接口:GET /api/settings 公开读、PATCH 需登录 + version 乐观锁(409/400)
- setting 表存 site JSON:页头(启用/速度/条目+标签+色调)、侧栏(hidden/order)
- /admin:Tabs 页头/侧栏,条目增删与上下移,保存后首页生效
- 首页应用设置:ticker 文本占位符与速度、侧栏顺序与显隐
- 测试 42 passed(settings 5 例)
This commit is contained in:
stash
2026-10-29 12:52:29 +08:00
parent dea7781b86
commit 9ae5a455c5
14 changed files with 1205 additions and 195 deletions
+65 -35
View File
@@ -1,7 +1,8 @@
import { Icon, IconButton, Tag } from '@stash/ui'
import { fillHeaderText, type HeaderItem, type SettingsTone } from '../../settings'
/**
* 顶部栏:左品牌、中间滚动信息、右侧操作图标。
* 顶部栏:左品牌、中间滚动信息(可在 /admin 配置)、右侧操作图标。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
*/
type SiteHeaderProps = {
@@ -9,47 +10,57 @@ type SiteHeaderProps = {
/** 用于展示的第一个项目名,没有项目时留空 */
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
}
/** 运行信息:同一份内容渲染两遍,轨道平移 50% 形成无缝滚动 */
/** 运行信息:同一份内容渲染三遍,轨道平移 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}>
<span className="header-ticker-item">共 {projectCount} 个本地应用</span>
{sampleProject !== undefined && (
<span className="header-ticker-item">
<Tag tone="primary" shape="rounded">
本地
</Tag>
{sampleProject}
{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 className="header-ticker-item">
<Tag tone={live ? 'success' : 'neutral'} shape="rounded">
状态
</Tag>
{live ? '实时同步已连接' : '等待服务连接'}
</span>
))}
</span>
)
}
@@ -58,11 +69,15 @@ export function SiteHeader({
projectCount,
sampleProject,
live,
headerItems,
headerEnabled,
headerSpeed,
authenticated,
username,
onFocusSearch,
onLogin,
onLogout,
onSettings,
}: SiteHeaderProps) {
return (
<div className="site-header-inner">
@@ -73,24 +88,36 @@ export function SiteHeader({
<span className="site-brand-name">STASH</span>
</a>
<div className="header-ticker" role="status" aria-label="运行状态">
<div className="header-ticker-track">
<InfoGroup projectCount={projectCount} sampleProject={sampleProject} live={live} />
<InfoGroup
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
// 后两份只用于视觉无缝,读屏忽略
aria-hidden
/>
<InfoGroup
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
aria-hidden
/>
{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>
)}
<div className="header-actions">
<IconButton
@@ -98,6 +125,9 @@ export function SiteHeader({
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})`}