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
@@ -0,0 +1,391 @@
import {
Button,
Card,
FormField,
Icon,
Input,
Notice,
Select,
Space,
Switch,
Tabs,
Text,
Title,
} from '@stash/ui'
import { useEffect, useMemo, useState } from 'react'
import { fetchSession, logout, type SessionState } from '../../auth'
import { LoginDialog } from '../auth/LoginDialog'
import {
fetchSettings,
saveSettings,
TONE_OPTIONS,
type HeaderItem,
type SettingsDocument,
type SettingsTone,
type SiteSettings,
} from '../../settings'
/**
* 站点设置页(/admin):页头滚动信息 + 侧栏顺序/显隐。
*
* 只做「全局配置」;条目内容仍在详情页就地编辑(P2)。
* 保存用 `version` 乐观锁,冲突时提示刷新。
*/
type CategorySummary = { id: string; title: string }
const clone = (settings: SiteSettings): SiteSettings => structuredClone(settings)
export function AdminPage() {
const [session, setSession] = useState<SessionState | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [document, setDocument] = useState<SettingsDocument | null>(null)
const [draft, setDraft] = useState<SiteSettings | null>(null)
const [categories, setCategories] = useState<CategorySummary[]>([])
const [tab, setTab] = useState('header')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
const load = async () => {
const [nextSession, nextDocument] = await Promise.all([fetchSession(), fetchSettings()])
setSession(nextSession)
setDocument(nextDocument)
setDraft(clone(nextDocument.settings))
try {
const response = await fetch('/api/catalog', { credentials: 'same-origin' })
if (response.ok) {
const payload = (await response.json()) as { categories?: CategorySummary[] }
setCategories(payload.categories ?? [])
}
} catch {
setCategories([])
}
}
useEffect(() => {
void load()
}, [])
const save = async () => {
if (document === null || draft === null) return
setSaving(true)
setMessage(null)
const result = await saveSettings(document.version, draft)
setSaving(false)
if (!result.ok) {
setMessage({ tone: 'error', text: result.error })
return
}
setDocument(result.document)
setDraft(clone(result.document.settings))
setMessage({ tone: 'info', text: '已保存,回到首页即可看到效果' })
}
const authenticated = session?.authenticated === true
return (
<div className="admin-page">
<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>
</header>
<main className="admin-main">
<Card padding="lg">
<Space direction="column" gap="sm" align="stretch">
<Title level={2}>站点设置</Title>
<Text size="sm" tone="muted">
页头滚动信息与侧栏导航都保存在内容库里,改完立即对新打开的页面生效。
</Text>
</Space>
{!authenticated && (
<div className="admin-locked">
<Text>需要登录后才能修改设置。</Text>
<Button icon={<Icon name="lock_line" />} onClick={() => setLoginOpen(true)}>
登录
</Button>
</div>
)}
{authenticated && draft !== null && (
<>
<Tabs
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sidebar', label: '侧栏导航' },
]}
activeKey={tab}
onChange={setTab}
/>
{message !== null && (
<Notice tone={message.tone} title={message.tone === 'info' ? '保存成功' : '保存失败'}>
{message.text}
</Notice>
)}
{tab === 'header' ? (
<HeaderForm
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
/>
) : (
<SidebarForm
categories={categories}
value={draft.sidebar}
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
/>
)}
<Space gap="sm" justify="end">
<Button
variant="secondary"
onClick={() => {
setDraft(clone(draft))
setMessage(null)
void load()
}}
>
撤销改动
</Button>
<Button loading={saving} icon={<Icon name="check_line" />} onClick={save}>
保存
</Button>
</Space>
</>
)}
</Card>
</main>
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? true}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
void load()
}}
/>
</div>
)
}
// ---------- 页头表单 ----------
const TONE_SELECT = TONE_OPTIONS.map((option) => ({ value: option.value, label: option.label }))
function HeaderForm({
value,
onChange,
}: {
value: SiteSettings['header']
onChange: (header: SiteSettings['header']) => void
}) {
const update = (index: number, patch: Partial<HeaderItem>) => {
onChange({
...value,
items: value.items.map((item, itemIndex) =>
itemIndex === index ? { ...item, ...patch } : item,
),
})
}
const move = (index: number, step: -1 | 1) => {
const target = index + step
if (target < 0 || target >= value.items.length) return
const items = [...value.items]
const [item] = items.splice(index, 1)
items.splice(target, 0, item)
onChange({ ...value, items })
}
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<div className="admin-row">
<Text>显示页头滚动信息</Text>
<Switch
checked={value.enabled}
onChange={(enabled) => onChange({ ...value, enabled })}
label="显示页头滚动信息"
/>
<Text size="sm" tone="muted">
关闭后页头中间留空
</Text>
</div>
<FormField label="滚动速度" htmlFor="header-speed" hint="秒数越大越慢,范围 8–120">
<Input
id="header-speed"
type="number"
value={String(value.speed)}
onChange={(event) => {
const speed = Number(event.target.value)
if (Number.isFinite(speed)) onChange({ ...value, speed })
}}
/>
</FormField>
<Space direction="column" gap="sm" align="stretch">
<Text size="sm" tone="muted">
支持占位符:{'{count}'} 应用数、{'{project}'} 项目名、{'{status}'} 连接状态
</Text>
{value.items.map((item, index) => (
<div className="admin-item-row" key={index}>
<Input
value={item.text}
placeholder="信息文本"
onChange={(event) => update(index, { text: event.target.value })}
/>
<Input
value={item.tag ?? ''}
placeholder="标签(可选)"
onChange={(event) => update(index, { tag: event.target.value })}
/>
<Select
options={TONE_SELECT}
value={item.tone}
onChange={(tone) => update(index, { tone: tone as SettingsTone })}
/>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={() => onChange({ ...value, items: value.items.filter((_, i) => i !== index) })}
/>
</div>
))}
<Button
variant="secondary"
icon={<Icon name="add_line" />}
disabled={value.items.length >= 8}
onClick={() => onChange({ ...value, items: [...value.items, { text: '', tag: '', tone: 'plain' }] })}
>
添加一条
</Button>
</Space>
</Space>
)
}
// ---------- 侧栏表单 ----------
function SidebarForm({
categories,
value,
onChange,
}: {
categories: CategorySummary[]
value: SiteSettings['sidebar']
onChange: (sidebar: SiteSettings['sidebar']) => void
}) {
const ordered = useMemo(() => {
const ranks = new Map(value.order.map((id, index) => [id, index]))
return [...categories].sort((a, b) => {
const left = ranks.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (left === right) return 0
return left - right
})
}, [categories, value.order])
const move = (index: number, step: -1 | 1) => {
const target = index + step
if (target < 0 || target >= ordered.length) return
const ids = ordered.map((category) => category.id)
const [id] = ids.splice(index, 1)
ids.splice(target, 0, id)
onChange({ ...value, order: ids })
}
const toggle = (id: string, visible: boolean) => {
const hidden = visible
? value.hidden.filter((entry) => entry !== id)
: [...new Set([...value.hidden, id])]
onChange({ ...value, hidden, order: ordered.map((category) => category.id) })
}
return (
<Space direction="column" gap="sm" align="stretch" className="admin-form">
<Text size="sm" tone="muted">
顺序决定侧栏显示位置;关掉的分类仍可通过地址直接访问。
</Text>
{ordered.map((category, index) => {
const visible = !value.hidden.includes(category.id)
return (
<div className="admin-item-row admin-nav-row" key={category.id}>
<Switch
checked={visible}
onChange={(next) => toggle(category.id, next)}
label={`显示 ${category.title}`}
/>
<Text className="admin-item-title">{category.title}</Text>
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={undefined}
/>
</div>
)
})}
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
</Space>
)
}
// ---------- 行内按钮组 ----------
function IconButtonGroup({
onUp,
onDown,
onRemove,
}: {
onUp: () => void
onDown: () => void
onRemove?: (() => void) | undefined
}) {
return (
<Space gap="xs" className="admin-item-actions">
<Button variant="ghost" size="sm" icon={<Icon name="arrow_up_line" />} onClick={onUp} aria-label="上移" />
<Button variant="ghost" size="sm" icon={<Icon name="arrow_down_line" />} onClick={onDown} aria-label="下移" />
{onRemove !== undefined && (
<Button
variant="ghost"
size="sm"
icon={<Icon name="delete_2_line" />}
onClick={onRemove}
aria-label="删除"
/>
)}
</Space>
)
}
@@ -0,0 +1,119 @@
import { Button, FormField, Icon, Input, Modal, Notice, Space, Text } from '@stash/ui'
import { useEffect, useState } from 'react'
import { login, setup, type SessionState } from '../../auth'
/**
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
*
* 成功后由父组件刷新会话状态;弹窗本身不跳转页面。
*/
type LoginDialogProps = {
open: boolean
hasAdmin: boolean
onClose: () => void
onSuccess: (session: SessionState) => void
}
export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogProps) {
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// 每次打开重置,避免上次的错误/输入残留
useEffect(() => {
if (!open) return
setPassword('')
setConfirm('')
setError('')
setBusy(false)
}, [open])
const isSetup = !hasAdmin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (!result.ok) {
setError(result.error)
return
}
onSuccess({
authenticated: true,
has_admin: true,
username: 'admin',
})
}
return (
<Modal
open={open}
onClose={onClose}
title={isSetup ? '设置管理员密码' : '登录'}
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
</Space>
}
>
<Space direction="column" gap="md" align="stretch">
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它登录编辑。'
: '登录后可以编辑页头滚动信息、侧栏顺序与显示。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField label="密码" htmlFor="login-password" required>
<Input
id="login-password"
type="password"
icon="lock_line"
placeholder="输入密码"
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="login-confirm" required>
<Input
id="login-confirm"
type="password"
icon="keyhole_line"
placeholder="再输入一次"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
</Space>
</Modal>
)
}
-126
View File
@@ -1,126 +0,0 @@
import { Button, Card, FormField, Icon, Input, Notice, Space, Text, Title } from '@stash/ui'
import { useEffect, useState } from 'react'
import { fetchSession, login, setup, type SessionState } from '../../auth'
/**
* 登录页:首次打开是「设置密码」,之后是「登录」。
*
* 成功后整页跳回首页(而不是 SPA 内跳转),让服务端重新下发 Cookie 后的首帧
* 也带上登录态,避免首屏闪一下未登录 UI。
*/
export function LoginPage() {
const [session, setSession] = useState<SessionState | null>(null)
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (!alive) return
if (next.authenticated) {
window.location.assign('/')
return
}
setSession(next)
})
return () => {
alive = false
}
}, [])
const isSetup = session !== null && !session.has_admin
const submit = async () => {
setError('')
if (isSetup && password !== confirm) {
setError('两次输入的密码不一致')
return
}
setBusy(true)
const result = isSetup ? await setup(password) : await login(password)
setBusy(false)
if (result.ok) {
window.location.assign('/')
return
}
setError(result.error)
}
return (
<div className="login-page">
<Card padding="lg" className="login-card">
<Space gap="sm" className="login-brand">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</Space>
<Title level={2}>{isSetup ? '设置管理员密码' : '登录'}</Title>
<Text size="sm" tone="muted">
{isSetup
? '首次使用:设置一个至少 8 位的密码,之后用它在任意设备上登录。'
: '登录后可以编辑侧栏、页头滚动信息与内容。'}
</Text>
{error !== '' && (
<Notice tone="error" title="操作失败">
{error}
</Notice>
)}
<FormField
label="密码"
htmlFor="password"
required
hint={isSetup ? '至少 8 位,建议混合字母与数字' : undefined}
>
<Input
id="password"
type="password"
size="lg"
icon="lock_line"
placeholder="输入密码"
value={password}
autoFocus
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="confirm" required>
<Input
id="confirm"
type="password"
size="lg"
icon="keyhole_line"
placeholder="再输入一次"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
/>
</FormField>
)}
<Button size="lg" block loading={busy} icon={<Icon name="lock_line" />} onClick={submit}>
{isSetup ? '设置并登录' : '登录'}
</Button>
<a className="login-back" href="/">
返回首页
</a>
</Card>
</div>
)
}
+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})`}