- 迁移 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
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
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} />
|
||
</>
|
||
)
|
||
}
|