Files
stash/stash-web/src/App.tsx
T
stash 8f688885ed feat(edit): P2 条目就地编辑 + P3 备份/审计/改密
- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
2026-10-29 12:59:21 +08:00

427 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Empty, Layout, Notice } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { DetailView } from './components/content/DetailView'
import { HomeView } from './components/content/HomeView'
import { ItemDetailView } from './components/content/ItemDetailView'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { Sidebar } from './components/sidebar/Sidebar'
import { withLocalCategory } from './data/local'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
} from './data/detail'
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
type Category,
findAppItem,
findArticleItem,
findPosterItem,
relatedAppItems,
relatedPosterItems,
} from './data/types'
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 { ItemEditor } from './components/content/ItemEditor'
import { parseViewPath, viewPath, type View } from './data/view'
import './App.css'
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
const catalog = useCatalog()
const [keyword, setKeyword] = useState('')
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 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 [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
// 登录态:只用来切换页头按钮与后续的编辑入口
useEffect(() => {
let alive = true
fetchSession().then((next) => {
if (alive) setSession(next)
})
fetchSettings().then((next) => {
if (alive) setSettings(next)
})
return () => {
alive = false
}
}, [])
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
[projects, catalog.categories],
)
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区;
// 再按 /admin 里的顺序与显隐设置重排一遍
const sidebarCategories = useMemo(
() =>
applySidebarSettings(
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
settings.settings.sidebar,
),
[categories, settings.settings.sidebar],
)
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
const detailCategory =
detailView !== undefined
? categories.find((category) => category.id === detailView.categoryId)
: undefined
const itemCategory =
itemView !== undefined
? categories.find((category) => category.id === itemView.categoryId)
: undefined
// 应用与电影共用一个详情模型,这里按分类类型做映射
const detailModel = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return undefined
if (itemCategory.kind === 'apps') {
const item = findAppItem(itemCategory, itemView.itemId)
return item !== undefined ? appDetailModel(item) : undefined
}
if (itemCategory.kind === 'posters') {
const poster = findPosterItem(itemCategory, itemView.itemId)
return poster !== undefined ? posterDetailModel(poster) : undefined
}
return undefined
}, [itemView, itemCategory])
const relatedItems = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return []
if (itemCategory.kind === 'apps') {
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
}
if (itemCategory.kind === 'posters') {
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
}
return []
}, [itemView, itemCategory])
// 切换视图时把内容区(内部滚动)和窗口都回到顶部
useEffect(() => {
const scroller = document.getElementById('content-scroll')
scroller?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}, [view])
/**
* 滚动状态同时驱动「顶部渐隐」和「回到顶部」。
* 桌面端滚的是内容容器(scroll 事件不冒泡),所以用捕获阶段监听。
*/
useEffect(() => {
const sync = () => {
const inner = document.getElementById('content-scroll')?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
sync()
window.addEventListener('scroll', sync, { passive: true })
document.addEventListener('scroll', sync, { passive: true, capture: true })
return () => {
window.removeEventListener('scroll', sync)
document.removeEventListener('scroll', sync, { capture: true })
}
}, [])
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
if (category?.kind === 'apps' && category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
}
}
return { type: 'detail', categoryId }
}
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
if (category.kind !== 'apps') return undefined
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
const openItem = (categoryId: string, itemId: string) => {
const category = categories.find((item) => item.id === categoryId)
go({
type: 'item',
categoryId,
itemId,
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
})
}
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
*/
const navigate = (target: NavTarget) => {
if (target.groupId === undefined) {
openDetail(target.categoryId)
return
}
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
}
const goHome = () => go({ type: 'home' })
/** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */
const navigateHref = (href: string) => go(parseViewPath(href))
const searching = keyword.trim() !== ''
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
/**
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
*/
const dataPending =
viewCategoryId !== undefined &&
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
*/
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<ArticleDetailView
article={article}
categoryId={itemCategory.id}
categoryTitle={itemCategory.title}
onNavigate={navigateHref}
onEdit={() => setEditingKey(itemView.itemId)}
/>
)
}
if (detailModel === undefined) {
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
<ItemDetailView
crumbs={[
{ label: '首页', href: '/' },
{ label: itemCategory.title, href: viewPath({ type: 'detail', categoryId: itemCategory.id }) },
{ label: detailModel.title },
]}
model={detailModel}
related={relatedItems}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
onEdit={() => setEditingKey(itemView.itemId)}
/>
)
}
const handleLogout = () => {
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)
}}
/>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => catalog.reload()}
/>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<Layout
contained
maxWidth={1380}
gutter={20}
siderWidth={240}
gap={20}
headerHeight={60}
headerVariant="solid"
siderBordered={false}
contentPadding="none"
contentOffset={88}
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
<SiteHeader
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={() => setLoginOpen(true)}
onLogout={handleLogout}
onSettings={() => window.location.assign('/admin')}
/>
}
sider={
<Sidebar
projects={projects}
categories={sidebarCategories}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
homeActive={view.type === 'home'}
onNavigate={navigate}
onHome={goHome}
/>
}
>
<div className="content">
{/* 滚动后淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<Notice
tone="error"
title="内容库读取失败"
action={{ label: '重试', onClick: catalog.reload }}
>
{catalog.error}
</Notice>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
换个关键字试试,或清空搜索查看全部。
</Notice>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
)}
{itemView !== undefined && renderItemView()}
</div>
</Layout>
<BackToTop visible={scrolled} />
</>
)
}