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:
+62
-18
@@ -773,36 +773,80 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- 登录页 ---------- */
|
||||
/* ---------- 设置页 ---------- */
|
||||
|
||||
.login-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: var(--su-space-6);
|
||||
background-color: var(--su-bg-page);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
width: min(380px, 100%);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-4);
|
||||
height: 64px;
|
||||
padding: 0 var(--su-space-6);
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.login-back {
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
text-align: center;
|
||||
.admin-main {
|
||||
width: min(880px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: var(--su-space-8) 0 var(--su-space-10);
|
||||
}
|
||||
|
||||
.login-back:hover {
|
||||
color: var(--su-color-primary);
|
||||
.admin-locked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-4);
|
||||
margin-top: var(--su-space-5);
|
||||
padding: var(--su-space-4);
|
||||
border: 1px dashed var(--su-border-strong);
|
||||
border-radius: var(--su-radius-md);
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
margin-top: var(--su-space-5);
|
||||
}
|
||||
|
||||
.admin-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.admin-item-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 160px 120px auto;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.admin-item-row > :first-child:not(.admin-item-title) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.admin-item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-nav-row {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.admin-item-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-item-row .su-switch {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
|
||||
+42
-6
@@ -29,6 +29,13 @@ import { useProjects } from './useProjects'
|
||||
import { useCatalog } from './useCatalog'
|
||||
import { useViewRoute } from './useViewRoute'
|
||||
import { fetchSession, logout as logoutRequest, type SessionState } from './auth'
|
||||
import {
|
||||
applySidebarSettings,
|
||||
defaultSettings,
|
||||
fetchSettings,
|
||||
type SettingsDocument,
|
||||
} from './settings'
|
||||
import { LoginDialog } from './components/auth/LoginDialog'
|
||||
import { parseViewPath, viewPath, type View } from './data/view'
|
||||
import './App.css'
|
||||
|
||||
@@ -40,6 +47,11 @@ export default function App() {
|
||||
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
|
||||
const { view, go } = useViewRoute()
|
||||
const [session, setSession] = useState<SessionState | null>(null)
|
||||
const [settings, setSettings] = useState<SettingsDocument>({
|
||||
version: 0,
|
||||
settings: defaultSettings(),
|
||||
})
|
||||
const [loginOpen, setLoginOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -49,6 +61,9 @@ export default function App() {
|
||||
fetchSession().then((next) => {
|
||||
if (alive) setSession(next)
|
||||
})
|
||||
fetchSettings().then((next) => {
|
||||
if (alive) setSettings(next)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
@@ -63,13 +78,17 @@ export default function App() {
|
||||
[categories, keyword],
|
||||
)
|
||||
|
||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区
|
||||
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
|
||||
// 再按 /admin 里的顺序与显隐设置重排一遍
|
||||
const sidebarCategories = useMemo(
|
||||
() =>
|
||||
categories.filter(
|
||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
||||
applySidebarSettings(
|
||||
categories.filter(
|
||||
(category) => category.id !== 'local' && category.showInSidebar !== false,
|
||||
),
|
||||
settings.settings.sidebar,
|
||||
),
|
||||
[categories],
|
||||
[categories, settings.settings.sidebar],
|
||||
)
|
||||
|
||||
const detailView = view.type === 'detail' ? view : undefined
|
||||
@@ -258,11 +277,24 @@ export default function App() {
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logoutRequest().then(() => window.location.assign('/'))
|
||||
void logoutRequest().then(() => {
|
||||
setSession({ authenticated: false, has_admin: true })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
hasAdmin={session?.has_admin ?? false}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onSuccess={(next) => {
|
||||
setSession(next)
|
||||
setLoginOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
|
||||
<div className="site-background" aria-hidden="true" />
|
||||
|
||||
@@ -287,11 +319,15 @@ export default function App() {
|
||||
projectCount={projects.length}
|
||||
sampleProject={projects[0]?.name}
|
||||
live={live}
|
||||
headerItems={settings.settings.header.items}
|
||||
headerEnabled={settings.settings.header.enabled}
|
||||
headerSpeed={settings.settings.header.speed}
|
||||
authenticated={session?.authenticated === true}
|
||||
username={session?.username ?? undefined}
|
||||
onFocusSearch={() => searchRef.current?.focus()}
|
||||
onLogin={() => window.location.assign('/login')}
|
||||
onLogin={() => setLoginOpen(true)}
|
||||
onLogout={handleLogout}
|
||||
onSettings={() => window.location.assign('/admin')}
|
||||
/>
|
||||
}
|
||||
sider={
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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})`}
|
||||
|
||||
+17
-7
@@ -3,15 +3,25 @@ import { createRoot } from 'react-dom/client'
|
||||
import '@stash/ui/style.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { LoginPage } from './components/auth/LoginPage'
|
||||
import { AdminPage } from './components/admin/AdminPage'
|
||||
|
||||
/**
|
||||
* 入口分流:登录页是独立壳(不挂 Layout / 侧栏 / 搜索),
|
||||
* 且必须在 App 的 `useViewRoute` 把地址规范化之前判定,
|
||||
* 所以放在这里用首次加载的 pathname 决定,而不是放进 App 里。
|
||||
* 入口分流:
|
||||
* - `/admin`:设置页独立壳;
|
||||
* - `/login`:老地址,直接回首页(登录已改为页内弹窗);
|
||||
* - 其它:正常站点。
|
||||
*
|
||||
* 判定放在这里而不是 App 里,是因为 App 的 `useViewRoute` 会把地址规范化,
|
||||
* 组件内部再读 pathname 已经晚了。
|
||||
*/
|
||||
const isLoginPage = window.location.pathname === '/login'
|
||||
const path = window.location.pathname
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>{isLoginPage ? <LoginPage /> : <App />}</StrictMode>,
|
||||
if (path === '/login') {
|
||||
window.location.replace('/')
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
|
||||
root.render(
|
||||
<StrictMode>{path === '/admin' ? <AdminPage /> : <App />}</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 站点设置:页头滚动信息 + 侧栏顺序/显隐。
|
||||
*
|
||||
* 接口是公开读、登录后写;写的时候带 `version` 做乐观锁,
|
||||
* 冲突(多标签页同时改)时返回 409,页面提示刷新。
|
||||
*/
|
||||
export type SettingsTone = 'plain' | 'primary' | 'success' | 'neutral'
|
||||
|
||||
export type HeaderItem = {
|
||||
text: string
|
||||
tag?: string | null
|
||||
tone: SettingsTone
|
||||
}
|
||||
|
||||
export type SiteSettings = {
|
||||
header: {
|
||||
enabled: boolean
|
||||
/** 滚动一轮的秒数(8–120) */
|
||||
speed: number
|
||||
items: HeaderItem[]
|
||||
}
|
||||
sidebar: {
|
||||
hidden: string[]
|
||||
order: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type SettingsDocument = {
|
||||
version: number
|
||||
settings: SiteSettings
|
||||
}
|
||||
|
||||
export type SettingsResult =
|
||||
| { ok: true; document: SettingsDocument }
|
||||
| { ok: false; status: number; error: string }
|
||||
|
||||
export const TONE_OPTIONS: { value: SettingsTone; label: string }[] = [
|
||||
{ value: 'plain', label: '无底色' },
|
||||
{ value: 'neutral', label: '灰色' },
|
||||
{ value: 'primary', label: '品牌色' },
|
||||
{ value: 'success', label: '成功绿' },
|
||||
]
|
||||
|
||||
export function defaultSettings(): SiteSettings {
|
||||
return {
|
||||
header: {
|
||||
enabled: true,
|
||||
speed: 24,
|
||||
items: [
|
||||
{ text: '共 {count} 个本地应用', tone: 'plain' },
|
||||
{ text: '{project}', tag: '本地', tone: 'primary' },
|
||||
{ text: '{status}', tag: '状态', tone: 'success' },
|
||||
],
|
||||
},
|
||||
sidebar: { hidden: [], order: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/** 占位符替换:`{count}` / `{project}` / `{status}` */
|
||||
export function fillHeaderText(
|
||||
text: string,
|
||||
context: { count: number; project?: string; live: boolean },
|
||||
): string {
|
||||
return text
|
||||
.replaceAll('{count}', String(context.count))
|
||||
.replaceAll('{project}', context.project ?? '—')
|
||||
.replaceAll('{status}', context.live ? '实时同步已连接' : '等待服务连接')
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<SettingsDocument> {
|
||||
try {
|
||||
const response = await fetch('/api/settings', { credentials: 'same-origin' })
|
||||
if (!response.ok) throw new Error(String(response.status))
|
||||
return (await response.json()) as SettingsDocument
|
||||
} catch {
|
||||
return { version: 0, settings: defaultSettings() }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
version: number,
|
||||
settings: SiteSettings,
|
||||
): Promise<SettingsResult> {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ version, settings }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, document: (await response.json()) as SettingsDocument }
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: payload?.error ?? `保存失败(${response.status})`,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error instanceof Error ? error.message : '网络错误',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
|
||||
*/
|
||||
export function applySidebarSettings<T extends { id: string }>(
|
||||
categories: T[],
|
||||
sidebar: SiteSettings['sidebar'],
|
||||
): T[] {
|
||||
const hidden = new Set(sidebar.hidden)
|
||||
const ranks = new Map(sidebar.order.map((id, index) => [id, index]))
|
||||
|
||||
return categories
|
||||
.filter((category) => !hidden.has(category.id))
|
||||
.map((category, index) => ({
|
||||
category,
|
||||
index,
|
||||
rank: ranks.get(category.id) ?? Number.MAX_SAFE_INTEGER,
|
||||
}))
|
||||
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
|
||||
.map((entry) => entry.category)
|
||||
}
|
||||
Reference in New Issue
Block a user