feat(admin): 分区增删、资料设置、操作条与交互收尾

- POST/DELETE /api/categories(需登录,级联,local 保护)
- profile 设置(头像/名称/角色/简介/位置)应用到资料卡
- 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链
- 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条
- 登录密码可见切换
- 测试 48 passed
This commit is contained in:
stash
2026-10-29 20:15:56 +08:00
parent 3c6c20e534
commit 427338b329
15 changed files with 851 additions and 54 deletions
+60 -1
View File
@@ -464,8 +464,10 @@
}
.item-title {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
gap: var(--su-space-3);
font-size: var(--su-font-size-xl);
font-weight: var(--su-font-weight-semibold);
line-height: 1.5;
@@ -904,3 +906,60 @@
transition: none;
}
}
/* ---------- 登录密码框 / 条目操作条 / 设置页细节 ---------- */
.password-field {
display: flex;
align-items: center;
gap: var(--su-space-2);
}
.password-field > :first-child {
flex: 1;
min-width: 0;
}
.item-stat {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
padding: 0;
border: none;
background: none;
color: var(--su-text-sub);
font: inherit;
cursor: pointer;
}
.item-stat.is-liked,
.item-stat.is-liked svg {
color: var(--su-color-primary);
}
.item-title .item-edit {
margin-left: 0;
align-self: center;
line-height: 1;
}
.admin-actions {
margin-top: var(--su-space-6);
}
.admin-tabs {
overflow: visible;
}
.admin-create {
margin-top: var(--su-space-4);
padding-top: var(--su-space-4);
border-top: 1px dashed var(--su-border-strong);
}
.admin-create-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 120px auto;
gap: var(--su-space-2);
align-items: center;
}
+5 -1
View File
@@ -1,4 +1,4 @@
import { Empty, Layout, Notice } from '@stash/ui'
import { Empty, Layout, Notice, Toaster } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
@@ -267,6 +267,7 @@ export default function App() {
return (
<ItemDetailView
itemKey={itemView.itemId}
crumbs={[
{ label: '首页', href: '/' },
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
@@ -290,6 +291,8 @@ export default function App() {
return (
<>
<Toaster />
{/* 登录弹窗:成功后直接把会话状态写回,不刷新页面 */}
<LoginDialog
open={loginOpen}
@@ -347,6 +350,7 @@ export default function App() {
<Sidebar
projects={projects}
categories={sidebarCategories}
profile={settings.settings.profile}
live={live}
refreshing={refreshing}
onRefresh={refresh}
+171 -14
View File
@@ -10,16 +10,22 @@ import {
Switch,
Tabs,
Text,
Textarea,
Title,
Toaster,
toast,
} from '@stash/ui'
import { useEffect, useMemo, useState } from 'react'
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
import { LoginDialog } from '../auth/LoginDialog'
import {
createCategory,
deleteCategory,
fetchSettings,
saveSettings,
TONE_OPTIONS,
type HeaderItem,
type ProfileSettings,
type SectionSettings,
type SettingsDocument,
type SettingsTone,
@@ -49,7 +55,6 @@ export function AdminPage() {
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()])
@@ -75,25 +80,36 @@ export function AdminPage() {
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 })
toast.error(result.error)
return
}
setDocument(result.document)
setDraft(clone(result.document.settings))
setMessage({ tone: 'info', text: '已保存,回到首页即可看到效果' })
toast.success('已保存')
}
const removeCategory = async (card: CategorySummary) => {
if (!window.confirm(`删除「${card.title}」会同时删除它下面的所有条目,确定继续?`)) return
const result = await deleteCategory(card.id)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(`已删除「${card.title}」`)
await load()
}
const authenticated = session?.authenticated === true
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">
@@ -141,22 +157,18 @@ export function AdminPage() {
{authenticated && draft !== null && (
<>
<Tabs
className="admin-tabs"
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sections', label: '首页卡片' },
{ key: 'sidebar', label: '侧栏导航' },
{ key: 'profile', label: '资料' },
{ key: 'system', label: '系统' },
]}
activeKey={tab}
onChange={setTab}
/>
{message !== null && (
<Notice tone={message.tone} title={message.tone === 'info' ? '保存成功' : '保存失败'}>
{message.text}
</Notice>
)}
{tab === 'header' ? (
<HeaderForm
value={draft.header}
@@ -167,23 +179,31 @@ export function AdminPage() {
categories={categories}
value={draft.sections}
onChange={(sections) => setDraft({ ...draft, sections })}
onDelete={removeCategory}
onReload={load}
/>
) : tab === 'sidebar' ? (
<SidebarForm
categories={categories}
value={draft.sidebar}
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
onDelete={removeCategory}
onReload={load}
/>
) : tab === 'profile' ? (
<ProfileForm
value={draft.profile}
onChange={(profile) => setDraft({ ...draft, profile })}
/>
) : (
<SystemPanel />
)}
<Space gap="sm" justify="end">
<Space gap="sm" justify="end" className="admin-actions">
<Button
variant="secondary"
onClick={() => {
setDraft(clone(draft))
setMessage(null)
void load()
}}
>
@@ -316,10 +336,14 @@ function SidebarForm({
categories,
value,
onChange,
onDelete,
onReload,
}: {
categories: CategorySummary[]
value: SiteSettings['sidebar']
onChange: (sidebar: SiteSettings['sidebar']) => void
onDelete: (card: CategorySummary) => void
onReload: () => void
}) {
const ordered = useMemo(() => {
const ranks = new Map(value.order.map((id, index) => [id, index]))
@@ -367,13 +391,18 @@ function SidebarForm({
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={undefined}
onRemove={() => onDelete(category)}
/>
</div>
)
})}
{ordered.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
<Text size="sm" tone="muted">
新增或删除分类会同时影响首页卡片与侧栏。
</Text>
<CategoryCreator onCreated={onReload} />
</Space>
)
}
@@ -384,10 +413,14 @@ function SectionForm({
categories,
value,
onChange,
onDelete,
onReload,
}: {
categories: CategorySummary[]
value: SectionSettings
onChange: (sections: SectionSettings) => void
onDelete: (card: CategorySummary) => void
onReload: () => void
}) {
const cards = useMemo(() => {
const base = [
@@ -470,12 +503,136 @@ function SectionForm({
<IconButtonGroup
onUp={() => move(index, -1)}
onDown={() => move(index, 1)}
onRemove={undefined}
onRemove={card.id === 'local' ? undefined : () => onDelete(card)}
/>
</div>
))}
{cards.length === 0 && <Text tone="muted">内容库还没有加载完成。</Text>}
<CategoryCreator onCreated={onReload} />
</Space>
)
}
// ---------- 新增分类 ----------
function CategoryCreator({ onCreated }: { onCreated: () => void }) {
const [id, setId] = useState('')
const [title, setTitle] = useState('')
const [kind, setKind] = useState<'apps' | 'posters' | 'articles'>('posters')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
const result = await createCategory({ id: id.trim(), title: title.trim(), kind })
setBusy(false)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success('分类已创建')
setId('')
setTitle('')
onCreated()
}
return (
<Space direction="column" gap="sm" align="stretch" className="admin-create">
<Text size="sm" tone="muted">
新增分类(会同时出现在首页卡片与侧栏)。标识只允许小写字母、数字与连字符。
</Text>
<div className="admin-create-row">
<Input
value={id}
placeholder="标识,如 books"
aria-label="新分类标识"
onChange={(event) => setId(event.target.value)}
/>
<Input
value={title}
placeholder="标题,如 书单"
aria-label="新分类标题"
onChange={(event) => setTitle(event.target.value)}
/>
<Select
options={[
{ value: 'apps', label: '应用' },
{ value: 'posters', label: '海报' },
{ value: 'articles', label: '图文' },
]}
value={kind}
onChange={(next) => setKind(next as typeof kind)}
/>
<Button
loading={busy}
disabled={id.trim() === '' || title.trim() === ''}
icon={<Icon name="add_line" />}
onClick={submit}
>
新增
</Button>
</div>
</Space>
)
}
// ---------- 资料 ----------
function ProfileForm({
value,
onChange,
}: {
value: ProfileSettings
onChange: (profile: ProfileSettings) => void
}) {
const update = (patch: Partial<ProfileSettings>) => onChange({ ...value, ...patch })
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<FormField label="头像地址" htmlFor="profile-avatar" hint="留空则用图标占位;支持 https 图片地址">
<Input
id="profile-avatar"
value={value.avatar}
placeholder="https://…"
onChange={(event) => update({ avatar: event.target.value })}
/>
</FormField>
<FormField label="名称" htmlFor="profile-name" required>
<Input
id="profile-name"
value={value.name}
onChange={(event) => update({ name: event.target.value })}
/>
</FormField>
<FormField label="角色" htmlFor="profile-role">
<Input
id="profile-role"
value={value.role}
onChange={(event) => update({ role: event.target.value })}
/>
</FormField>
<FormField label="简介" htmlFor="profile-description">
<Textarea
id="profile-description"
rows={3}
value={value.description}
onChange={(event) => update({ description: event.target.value })}
/>
</FormField>
<FormField label="位置" htmlFor="profile-location" hint="直接显示在资料卡底部,如「IP属地:局域网」">
<Input
id="profile-location"
value={value.location}
onChange={(event) => update({ location: event.target.value })}
/>
</FormField>
</Space>
)
}
+8 -15
View File
@@ -1,6 +1,7 @@
import { Button, FormField, Icon, Input, Modal, Notice, Space, Text } from '@stash/ui'
import { Button, FormField, Icon, Modal, Notice, Space, Text } from '@stash/ui'
import { useEffect, useState } from 'react'
import { login, setup, type SessionState } from '../../auth'
import { PasswordInput } from './PasswordInput'
/**
* 登录弹窗:首次使用显示「设置密码 + 确认」,之后只显示密码。
@@ -84,32 +85,24 @@ export function LoginDialog({ open, hasAdmin, onClose, onSuccess }: LoginDialogP
)}
<FormField label="密码" htmlFor="login-password" required>
<Input
<PasswordInput
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()
}}
onChange={setPassword}
onEnter={() => void submit()}
/>
</FormField>
{isSetup && (
<FormField label="确认密码" htmlFor="login-confirm" required>
<Input
<PasswordInput
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()
}}
onChange={setConfirm}
onEnter={() => void submit()}
/>
</FormField>
)}
@@ -0,0 +1,48 @@
import { Icon, IconButton, Input } from '@stash/ui'
import { useState } from 'react'
/**
* 语言无关的密码可见切换:右侧一个眼睛按钮,切换 input type。
* 错误提示后再点仍然有效(状态在组件里,不会被错误重置)。
*/
type PasswordInputProps = {
id: string
value: string
placeholder?: string
autoFocus?: boolean
onChange: (value: string) => void
onEnter?: () => void
}
export function PasswordInput({
id,
value,
placeholder,
autoFocus = false,
onChange,
onEnter,
}: PasswordInputProps) {
const [visible, setVisible] = useState(false)
return (
<div className="password-field">
<Input
id={id}
type={visible ? 'text' : 'password'}
icon="lock_line"
placeholder={placeholder}
value={value}
autoFocus={autoFocus}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onEnter?.()
}}
/>
<IconButton
label={visible ? '隐藏密码' : '显示密码'}
icon={<Icon name={visible ? 'eye_close_line' : 'eye_line'} />}
onClick={() => setVisible((current) => !current)}
/>
</div>
)
}
@@ -8,8 +8,10 @@ import {
Tag,
Text,
Title,
toast,
type BreadcrumbItem,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { RelatedList } from './RelatedList'
@@ -22,6 +24,8 @@ import { RelatedList } from './RelatedList'
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
/** 条目业务键:浏览 / 点赞要写回数据库 */
itemKey: string
crumbs: BreadcrumbItem[]
model: ItemDetailModel
related: RelatedItem[]
@@ -34,6 +38,7 @@ type ItemDetailViewProps = {
}
export function ItemDetailView({
itemKey,
crumbs,
model,
related,
@@ -42,6 +47,74 @@ export function ItemDetailView({
onEdit,
canEdit,
}: ItemDetailViewProps) {
const [views, setViews] = useState(model.views)
const [likes, setLikes] = useState(model.likes)
const [liked, setLiked] = useState(() => {
try {
return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1'
} catch {
return false
}
})
// 进入详情页记一次浏览(每个页面打开一次)
useEffect(() => {
let alive = true
fetch(`/api/items/${encodeURIComponent(itemKey)}/view`, {
method: 'POST',
credentials: 'same-origin',
})
.then((response) => (response.ok ? response.json() : null))
.then((payload: { views?: number; likes?: number } | null) => {
if (!alive || payload === null) return
if (typeof payload.views === 'number') setViews(payload.views)
if (typeof payload.likes === 'number') setLikes(payload.likes)
})
.catch(() => undefined)
return () => {
alive = false
}
}, [itemKey])
const toggleLike = () => {
const next = !liked
setLiked(next)
setLikes((current) => Math.max(0, current + (next ? 1 : -1)))
try {
window.localStorage.setItem(`stash-liked:${itemKey}`, next ? '1' : '0')
} catch {
// 隐私模式下 localStorage 不可用,忽略即可
}
fetch(`/api/items/${encodeURIComponent(itemKey)}/like`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ liked: next }),
})
.then((response) => (response.ok ? response.json() : null))
.then((payload: { views?: number; likes?: number } | null) => {
if (payload === null) return
if (typeof payload.views === 'number') setViews(payload.views)
if (typeof payload.likes === 'number') setLikes(payload.likes)
})
.catch(() => undefined)
}
const share = () => {
const url = window.location.href
const clipboard = navigator.clipboard
if (clipboard === undefined) {
toast.error('当前浏览器不支持复制,请手动复制地址栏')
return
}
void clipboard
.writeText(url)
.then(() => toast.success('链接已复制'))
.catch(() => toast.error('复制失败,请手动复制地址栏'))
}
return (
<div className="detail-view item-view">
<Card padding="none" className="detail-nav">
@@ -98,19 +171,35 @@ export function ItemDetailView({
<div className="item-actions">
<Space gap="sm">
<Space gap="xs" className="item-stat">
<button
type="button"
className="item-stat"
title="浏览量"
onClick={() => toast.success(`浏览量 ${views}`)}
>
<Icon name="eye_line" />
<Text size="xs" tone="sub">
{model.views}
{views}
</Text>
</Space>
<Space gap="xs" className="item-stat">
</button>
<button
type="button"
className={`item-stat${liked ? ' is-liked' : ''}`}
title={liked ? '取消点赞' : '点赞'}
aria-pressed={liked}
onClick={toggleLike}
>
<Icon name="thumb_up_2_line" />
<Text size="xs" tone="sub">
{model.likes}
{likes}
</Text>
</Space>
<IconButton label="分享(待接入)" size="sm" icon={<Icon name="share_3_line" />} />
</button>
<IconButton
label="分享链接"
size="sm"
icon={<Icon name="share_3_line" />}
onClick={share}
/>
</Space>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
@@ -1,28 +1,34 @@
import { Avatar, Card, Icon, Text, Title } from '@stash/ui'
import { PLACEHOLDER_PROFILE } from '../../profile'
import type { ProfileSettings } from '../../settings'
/**
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
* 数据来自 profile.ts 的占位对象,后端接入鉴权后替换。
* 内容来自站点设置(/admin → 资料),未设置时用默认文案。
*/
export function ProfileCard() {
const { name, role, bio, location } = PLACEHOLDER_PROFILE
type ProfileCardProps = {
profile: ProfileSettings
}
export function ProfileCard({ profile }: ProfileCardProps) {
return (
<Card padding="none" className="profile-card">
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
{profile.avatar !== '' ? (
<Avatar size="xl" src={profile.avatar} alt={profile.name} verified />
) : (
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
)}
<Title level={4} weight="semibold" className="profile-name">
{name}
{profile.name}
</Title>
<span className="profile-role">{role}</span>
<span className="profile-role">{profile.role}</span>
<Text size="xs" tone="sub" className="profile-bio">
{bio}
{profile.description}
</Text>
<span className="profile-location">IP属地:{location}</span>
<span className="profile-location">{profile.location}</span>
</Card>
)
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import type { ProfileSettings } from '../../settings'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
@@ -13,6 +14,7 @@ type SidebarProps = {
projects: Project[]
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
profile: ProfileSettings
live: boolean
refreshing: boolean
onRefresh: () => void
@@ -28,6 +30,7 @@ type SidebarProps = {
export function Sidebar({
projects,
categories,
profile,
live,
refreshing,
onRefresh,
@@ -39,7 +42,7 @@ export function Sidebar({
}: SidebarProps) {
return (
<div className="sidebar">
<ProfileCard />
<ProfileCard profile={profile} />
<ServicePanel
projects={projects}
+57 -6
View File
@@ -19,6 +19,16 @@ export type SectionSettings = {
limits: Record<string, number>
}
export type ProfileSettings = {
name: string
role: string
description: string
/** 完整文案,如「IP属地:局域网」 */
location: string
/** 头像地址;空表示用图标占位 */
avatar: string
}
export type SiteSettings = {
header: {
enabled: boolean
@@ -31,6 +41,7 @@ export type SiteSettings = {
order: string[]
}
sections: SectionSettings
profile: ProfileSettings
}
export type SettingsDocument = {
@@ -62,6 +73,13 @@ export function defaultSettings(): SiteSettings {
},
sidebar: { hidden: [], order: [] },
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: 'IP属地:局域网',
avatar: '',
},
}
}
@@ -89,8 +107,7 @@ export async function fetchSettings(): Promise<SettingsDocument> {
export async function saveSettings(
version: number,
settings: SiteSettings,
): Promise<SettingsResult> {
try {
): Promise<SettingsResult> { try {
const response = await fetch('/api/settings', {
method: 'PATCH',
credentials: 'same-origin',
@@ -119,8 +136,7 @@ export async function saveSettings(
/**
* 按设置重排侧栏:先按 `order`,未列出的保持原顺序,`hidden` 过滤掉。
*/
export function applySidebarSettings<T extends { id: string }>(
*/export function applySidebarSettings<T extends { id: string }>(
categories: T[],
sidebar: SiteSettings['sidebar'],
): T[] {
@@ -155,8 +171,7 @@ export function sectionLimit(
return typeof override === 'number' && override > 0 ? override : fallback
}
function orderAndFilter<T extends { id: string }>(
categories: T[],
function orderAndFilter<T extends { id: string }>( categories: T[],
config: { hidden: string[]; order: string[] },
): T[] {
const hidden = new Set(config.hidden)
@@ -172,3 +187,39 @@ function orderAndFilter<T extends { id: string }>(
.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank))
.map((entry) => entry.category)
}
// ---------- 分区(首页卡片 / 侧栏项)的新增与删除 ----------
export type NewCategory = { id: string; title: string; kind: 'apps' | 'posters' | 'articles' }
export type SimpleResult = { ok: true } | { ok: false; error: string }
export async function createCategory(input: NewCategory): Promise<SimpleResult> {
try {
const response = await fetch('/api/categories', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `创建失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function deleteCategory(id: string): Promise<SimpleResult> {
try {
const response = await fetch(`/api/categories/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (response.ok) return { ok: true }
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, error: payload?.error ?? `删除失败(${response.status})` }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : '网络错误' }
}
}