Files
stash/stash-web/src/AppNext.tsx
T
CNWei 259eb20411 feat: 「查看接口」改为站内路由,只换右侧内容
- ServicePanel 新增 onOpenApi,链接点击接管为 navigate('/interface');
  按住 Ctrl/Cmd/Shift/Alt 仍保留新标签打开
- 前台内容区已有 route.kind === 'api' 分支,直接渲染接口一览
- 顺手删掉两个没人引用的旧组件:sidebar/Sidebar.tsx、sidebar/CategoryNav.tsx
2026-11-01 22:22:57 +08:00

757 lines
26 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,
Menu,
Notice,
SearchField,
Space,
Toaster,
type MenuItem,
} from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { AppGrid } from './components/content/AppGrid'
import { CesCardGrid } from './components/content/CesCardGrid'
import { ItemDetailView } from './components/content/ItemDetailView'
import { ItemEditor } from './components/content/ItemEditor'
import { PosterGrid } from './components/content/PosterGrid'
import { SectionCard } from './components/content/SectionCard'
import { SiteHeader } from './components/header/SiteHeader'
import { ApiDocs } from './components/api/ApiDocs'
import { GroupMark } from './components/common/GroupMark'
import { LoginDialog } from './components/auth/LoginDialog'
import { ProfileCard } from './components/sidebar/ProfileCard'
import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth'
import {
fetchSettings,
type SettingsDocument,
type SiteSettings,
} from './settings'
import {
isDarkScheme,
readThemeOverride,
writeThemeOverride,
type ThemeChoice,
} from './theme'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
type ItemDetailModel,
} from './data/detail'
import type { AppItem, ArticleItem, PosterItem, RelatedItem } from './data/types'
import { fetchManage, type CardItem, type GroupNode, type ManageData } from './manage'
import './App.css'
/**
* 新前台:分组树 + 卡片组合。
*
* 路由:
* / 首页(卡片按勾选分组渲染)
* /g/<分组 key> 分组页(自身条目 + 子分组)
* /g/<分组 key>/i/<条目> 条目详情
* 旧 /c/... 地址按第一个路径段当成分组 key,兼容老书签。
*/
type Route =
| { kind: 'home' }
| { kind: 'group'; key: string }
| { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
if (
(segments[0] === 'g' || segments[0] === 'c') &&
segments[1] !== undefined
) {
if (segments[2] === 'i' && segments[3] !== undefined) {
return { kind: 'item', key: decodeURIComponent(segments[1]), itemId: decodeURIComponent(segments[3]) }
}
return { kind: 'group', key: decodeURIComponent(segments[1]) }
}
return { kind: 'home' }
}
function useRoute() {
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname))
useEffect(() => {
const sync = () => setRoute(parseRoute(window.location.pathname))
window.addEventListener('popstate', sync)
return () => window.removeEventListener('popstate', sync)
}, [])
const navigate = (path: string) => {
if (window.location.pathname === path) return
window.history.pushState({}, '', path)
setRoute(parseRoute(path))
document.getElementById('page-scroll')?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}
return { route, navigate }
}
function findGroup(groups: GroupNode[], key: string): GroupNode | undefined {
for (const group of groups) {
if (group.key === key) return group
const found = findGroup(group.children, key)
if (found !== undefined) return found
}
return undefined
}
function findPath(groups: GroupNode[], key: string, trail: GroupNode[] = []): GroupNode[] | undefined {
for (const group of groups) {
const next = [...trail, group]
if (group.key === key) return next
const found = findPath(group.children, key, next)
if (found !== undefined) return found
}
return undefined
}
function findEntryInGroup(
group: GroupNode,
entryId: string,
): { group: GroupNode; entry: Record<string, unknown> } | undefined {
const direct = group.entries.find((candidate) => candidate.id === entryId)
if (direct !== undefined) {
return { group, entry: direct as unknown as Record<string, unknown> }
}
for (const child of group.children) {
const found = findEntryInGroup(child, entryId)
if (found !== undefined) return found
}
return undefined
}
function findEntry(
groups: GroupNode[],
groupKey: string,
entryId: string,
): { group: GroupNode; entry: Record<string, unknown> } | undefined {
const group = findGroup(groups, groupKey)
if (group === undefined) return undefined
return findEntryInGroup(group, entryId)
}
function groupEntryCount(group: GroupNode): number {
const children = group.children.filter((child) => child.enabled !== false)
return group.entries.length + children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组自身与所有祖先都启用时才可见:停用父级会连带停用整棵子树 */
function isGroupVisible(groups: GroupNode[], key: string): boolean {
const path = findPath(groups, key)
if (path === undefined) return false
return path.every((node) => node.enabled !== false)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children
.filter((child) => child.enabled !== false)
.flatMap((child) => collectEntries(child)),
]
}
function defaultLimit(group: GroupNode): number {
return group.previewLimit ?? 6
}
function entryFormat(entry: Record<string, unknown>): 'apps' | 'posters' | 'articles' {
const value = entry.format
return value === 'posters' || value === 'articles' ? value : 'apps'
}
function matches(entry: Record<string, unknown>, keyword: string): boolean {
if (keyword === '') return true
const text = [entry.name, entry.title, entry.description, entry.desc]
.filter((value) => typeof value === 'string')
.join(' ')
.toLowerCase()
return text.includes(keyword)
}
export default function AppNext() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const { route, navigate } = useRoute()
const [manage, setManage] = useState<ManageData>({ groups: [], cards: [] })
const [loading, setLoading] = useState(true)
const [keyword, setKeyword] = useState('')
const [session, setSession] = useState<SessionState | null>(null)
const [settings, setSettings] = useState<SettingsDocument | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const load = () => {
void fetchManage().then((data) => {
setManage(data)
setLoading(false)
})
}
useEffect(() => {
load()
void fetchSession().then(setSession)
void fetchSettings().then(setSettings)
const source = new EventSource('/api/catalog/events')
// 服务端推的是具名事件 `catalog`(不是默认的 message),两种都接;
// 收到后连站点设置一起重拉,改完设置不用手动刷新页面。
const onCatalogChange = () => {
load()
void fetchSettings().then(setSettings)
}
source.onmessage = onCatalogChange
source.addEventListener('catalog', onCatalogChange)
return () => source.close()
}, [])
useEffect(() => {
const content = document.getElementById('content-scroll')
const main = content?.parentElement
const body = main?.parentElement
if (
!(content instanceof HTMLElement) ||
!(main instanceof HTMLElement) ||
!(body instanceof HTMLElement)
) {
return
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索条下方开始,两栏位移完全一致。
// 左栏的吸顶交给 CSS position: sticky(App.css 里 #page-scroll > aside):
// 之前用 scroll 事件改 transform,主线程永远落后平滑滚动一帧,看起来就在抖。
// 注意别再给 aside 设内联 overflow,否则会盖掉 CSS 里的 overflow-y: auto,
// 侧栏比视口高时就没法在栏内滚动了。
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
const onScroll = () => setScrolled(body.scrollTop > 4)
// 滚动容器只覆盖 1380 的两栏,窗口两侧的空白、页头与搜索条都在它外面,
// 滚轮落在这些地方时浏览器找不到可滚动元素。统一转发给内容区。
const forwardWheel = (event: WheelEvent) => {
if (event.target instanceof Node && body.contains(event.target)) return
// deltaMode 1 = 按行(Firefox 鼠标滚轮),换算成像素
body.scrollTop += event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY
event.preventDefault()
}
window.addEventListener('wheel', forwardWheel, { passive: false })
body.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => {
body.removeEventListener('scroll', onScroll)
window.removeEventListener('wheel', forwardWheel)
body.id = ''
body.style.overflowY = ''
body.style.overflowX = ''
content.style.overflow = ''
content.style.marginTop = ''
main.style.height = ''
}
}, [])
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, gap: 26, items: [] },
sidebar: {
show_profile: true,
show_service: true,
show_nav: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '',
location: '',
avatar: '',
},
site: {
title: 'Stash · 本地应用工作区',
favicon: '',
theme: 'auto',
brand: { name: 'STASH', mark: 'S', logo: '' },
actions: { theme: true, refresh: true, settings: true, session: true },
},
}
const keywordLower = keyword.trim().toLowerCase()
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
readThemeOverride(),
)
const effectiveTheme = themeOverride ?? site.site.theme
useEffect(() => {
document.documentElement.dataset.scheme = effectiveTheme
}, [effectiveTheme])
/** 当前实际是深色吗(auto 时按系统偏好算) */
const isDark = isDarkScheme(effectiveTheme)
/** 页头的明暗切换:只写本机偏好,不需要登录;站点默认值在后台改 */
const toggleTheme = () => {
const next: ThemeChoice = isDark ? 'light' : 'dark'
setThemeOverride(next)
writeThemeOverride(next)
}
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
if (title !== '') document.title = title
const href = site.site.favicon.trim() === '' ? '/favicon.svg' : site.site.favicon.trim()
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link === null) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = href
link.type = href.endsWith('.svg') ? 'image/svg+xml' : ''
}, [site.site.title, site.site.favicon])
const sidebarItems: MenuItem[] = useMemo(() => {
// 侧边栏的显隐只认「分组管理 → 状态」:停用的分组整棵子树都不进导航,
// 顺序按分组自己的排序权重,不再有侧栏专属的隐藏/排序状态。
const build = (nodes: GroupNode[]): MenuItem[] =>
[...nodes]
.sort((a, b) => a.sort - b.sort)
.flatMap((node) => {
if (node.enabled === false) return []
const children = build(node.children)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups)]
}, [manage.groups])
const activeKey =
route.kind === 'home' || route.kind === 'api'
? 'home'
: route.kind === 'group'
? `g:${route.key}`
: `g:${route.key}`
function renderEntries(
group: GroupNode,
entries: Record<string, unknown>[],
options: { detail?: boolean; limit?: number } = {},
) {
const filtered = entries.filter((entry) => matches(entry, keywordLower))
const limit = options.limit ?? filtered.length
const items = filtered.slice(0, limit)
const open = (itemId: string) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(itemId)}`)
return (
<>
{(['apps', 'posters', 'articles'] as const).map((format) => {
const subset = items.filter((entry) => entryFormat(entry) === format)
if (subset.length === 0) return null
return (
<div className="mixed-format-block" key={format}>
{format === 'posters' ? (
<PosterGrid items={subset as unknown as PosterItem[]} onOpen={open} />
) : format === 'articles' ? (
<CesCardGrid items={subset as unknown as ArticleItem[]} onOpen={open} />
) : (
<AppGrid items={subset as unknown as AppItem[]} onOpen={open} />
)}
</div>
)
})}
</>
)
}
function renderGroupBlock(group: GroupNode, limit: number, showTitle: boolean) {
// 根分组(分区)只展示直接挂在它下面的条目,子分组各自成块,避免同一批内容出现两次
const rootGroup = group.parentKey === undefined || group.parentKey === null
const source = rootGroup
? (group.entries as unknown as Record<string, unknown>[])
: collectEntries(group)
const all = source.filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && !rootGroup && <h3 className="card-group-title">{group.title}</h3>}
{renderEntries(group, all, { limit })}
</div>
)
}
function renderLocalService(card: CardItem) {
const items: AppItem[] = projects.map((project) => ({
id: project.name,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
badge: '本地项目',
comment: '本地静态站点,由 Stash 直接托管。',
views: 0,
likes: 0,
}))
return (
<SectionCard key={card.id} title="本地服务" count={items.length} barColor="#3858f6">
{items.length > 0 ? (
<AppGrid items={items} />
) : (
<Empty text="还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。" />
)}
</SectionCard>
)
}
function renderCard(card: CardItem) {
const resolved = card.groups
.map((ref) => ({ ref, group: findGroup(manage.groups, ref.groupKey) }))
.filter(
(item): item is { ref: typeof item.ref; group: GroupNode } =>
item.group !== undefined && isGroupVisible(manage.groups, item.ref.groupKey),
)
const total = resolved.reduce((sum, item) => sum + groupEntryCount(item.group), 0)
const first = resolved[0]?.group
if (resolved.length === 0 || total === 0) return null
return (
<SectionCard
key={card.id}
title={card.title}
count={total}
barColor={first?.barColor}
>
{resolved.map(({ group, ref }) =>
renderGroupBlock(group, ref.previewLimit ?? defaultLimit(group), resolved.length > 1),
)}
</SectionCard>
)
}
function renderHome() {
const cards = manage.cards
return (
<div className="home-view">
{loading && <Empty text="正在读取内容库…" />}
{!loading && cards.length === 0 && <Empty text="后台还没有创建首页卡片" />}
{cards.map((card) =>
card.key === 'local-service' ? renderLocalService(card) : renderCard(card),
)}
{keywordLower !== '' && cards.length > 0 && (
<Notice title={`搜索结果:${keyword.trim()}`}>在卡片与分组内按标题过滤。</Notice>
)}
</div>
)
}
function renderGroupPage(key: string) {
const group = findGroup(manage.groups, key)
if (group === undefined || !isGroupVisible(manage.groups, key)) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
// 根分组页就是「这个分区的全部内容」:所有启用的子分组整段铺开,不再需要「查看全部」
const rootGroup = group.parentKey === undefined || group.parentKey === null
return (
<div className="detail-view">
<nav className="detail-breadcrumb group-crumbs">
<button type="button" className="link-button" onClick={() => navigate('/')}>
首页
</button>
{trail.map((node) => (
<span key={node.key}>
<span className="group-crumb-sep">/</span>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(node.key)}`)}
>
{node.title}
</button>
</span>
))}
</nav>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
<GroupMark icon={group.icon} fallback="•" />
</span>
<h2 className="detail-hero-title">{group.title}</h2>
<span className="detail-hero-count">[{groupEntryCount(group)}]</span>
</div>
{group.entries.length > 0 ? (
renderEntries(group, group.entries)
) : group.children.some((child) => groupEntryCount(child) > 0) ? null : (
<Empty text="这个分组还没有内容" />
)}
<Space direction="column" gap="lg" align="stretch">
{group.children
.filter((child) => child.enabled !== false)
.map((child) => (
<section key={child.key} className="group-child-block">
<div className="group-title">
<span
className="group-bar"
style={{ backgroundColor: child.barColor || undefined }}
aria-hidden="true"
/>
<h3>{child.title}</h3>
{!rootGroup && (
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
)}
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, rootGroup ? {} : { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
</section>
))}
</Space>
</div>
)
}
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined || !isGroupVisible(manage.groups, found.group.key)) {
return <Empty text={loading ? '正在读取内容库…' : '条目不存在'} />
}
const { group, entry } = found
const trail = findPath(manage.groups, group.key) ?? [group]
const crumbs = [
{ label: '首页', href: '/' },
...trail.map((node) => ({
label: node.title,
href: `/g/${encodeURIComponent(node.key)}`,
})),
{ label: String(entry.name ?? entry.title ?? entry.id) },
]
const format = entryFormat(entry)
if (format === 'articles') {
const article = entry as unknown as ArticleItem
return (
<ArticleDetailView
article={article}
categoryId={group.key}
categoryTitle={group.title}
onNavigate={navigate}
onEdit={() => setEditingKey(String(entry.id))}
canEdit={session?.authenticated === true}
/>
)
}
const model: ItemDetailModel =
format === 'posters'
? posterDetailModel(entry as unknown as PosterItem)
: appDetailModel(entry as unknown as AppItem)
const related: RelatedItem[] =
format === 'posters'
? group.entries
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
.slice(0, 4)
.map((item) => posterToRelatedItem(item as unknown as PosterItem))
: group.entries
.filter((item) => item.id !== entry.id && entryFormat(item as unknown as Record<string, unknown>) === format)
.slice(0, 4)
.map((item) => toRelatedItem(item as unknown as AppItem))
return (
<ItemDetailView
itemKey={String(entry.id)}
crumbs={crumbs}
model={model}
related={related}
onNavigate={navigate}
onOpenItem={(next) => navigate(`/g/${encodeURIComponent(group.key)}/i/${encodeURIComponent(next)}`)}
onEdit={() => setEditingKey(String(entry.id))}
canEdit={session?.authenticated === true}
/>
)
}
return (
<>
<Toaster />
<LoginDialog
open={loginOpen}
hasAdmin={session?.has_admin ?? true}
onClose={() => setLoginOpen(false)}
onSuccess={(next) => {
setSession(next)
setLoginOpen(false)
}}
/>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => {
setEditingKey(null)
load()
}}
/>
<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"
height="screen"
scrollable
hideScrollbar
contentId="content-scroll"
className="app"
header={
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
headerItems={site.header.items}
headerEnabled={site.header.enabled}
headerSpeed={site.header.speed}
headerGap={site.header.gap}
site={site.site}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
refreshing={refreshing}
dark={isDark}
onToggleTheme={() => void toggleTheme()}
onRefresh={() => {
// 刷新 = 整页重载:项目扫描、内容库、设置、主题都重新走一遍
window.location.reload()
}}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}}
onSettings={() => window.location.assign('/admin')}
/>
}
toolbar={
<div className="search-toolbar">
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
placeholder="搜索本地项目…"
label="搜索本地项目"
variant="card"
hotkey="k"
/>
</div>
}
sider={
<div className="sidebar-wrap">
<div className="sidebar sidebar-home">
{site.sidebar.show_profile !== false && <ProfileCard profile={site.profile} />}
{site.sidebar.show_service !== false && (
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={refresh}
onOpenApi={() => navigate('/interface')}
/>
)}
{site.sidebar.show_nav !== false && (
<div className="nav-card">
<Menu
items={sidebarItems}
activeKey={activeKey}
itemHeight={39}
onSelect={(key) => {
if (key === 'home') navigate('/')
else navigate(`/g/${encodeURIComponent(key.slice(2))}`)
}}
/>
</div>
)}
</div>
</div>
}
>
<div className="content">
<h1 className="sr-only">Stash 本地应用工作区</h1>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
{route.kind === 'home' && renderHome()}
{route.kind === 'api' && <ApiDocs />}
{route.kind === 'group' && renderGroupPage(route.key)}
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
</div>
</Layout>
<BackToTop visible={scrolled} targetId="page-scroll" />
</>
)
}