Files
stash/stash-web/src/AppNext.tsx
T
CNWei ddef75f862 fix: 去掉顶部独立块,消除横向分割线
- 删除 top-dock 与其半透明填充/底边
- 搜索框保持 fixed 悬浮,页头实底
- 顶部与内容之间只保留页面背景自然渐变
- 验证:top-dock 数量为 0,背景连续
2026-10-31 16:54:44 +08:00

681 lines
22 KiB
TypeScript

import {
Empty,
Layout,
Menu,
Notice,
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 { SearchBar } from './components/search/SearchBar'
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 {
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 }
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
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 {
return group.entries.length + group.children.reduce((total, child) => total + groupEntryCount(child), 0)
}
/** 分组子树里的全部条目(格式沿根分组继承,所以可以放在同一个网格里) */
function collectEntries(group: GroupNode): Record<string, unknown>[] {
return [
...(group.entries as unknown as Record<string, unknown>[]),
...group.children.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')
source.onmessage = () => load()
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
}
// 左右两栏放进同一个滚动容器:滚动区域从搜索框下方开始,两栏位移完全一致
const sider = body.querySelector('aside')
body.id = 'page-scroll'
body.style.overflowY = 'auto'
body.style.overflowX = 'hidden'
body.style.marginTop = '88px'
body.style.height = 'calc(100% - 88px)'
content.style.overflow = 'visible'
content.style.marginTop = '0'
main.style.height = 'auto'
if (sider instanceof HTMLElement) {
sider.style.overflow = 'visible'
sider.style.height = 'auto'
sider.style.alignSelf = 'flex-start'
}
const onScroll = () => setScrolled(body.scrollTop > 4)
const updateSticky = () => {
const viewport = body.clientHeight
const maxLeft = Math.max(0, sider instanceof HTMLElement ? sider.offsetHeight - viewport : 0)
const offset = Math.max(0, body.scrollTop - maxLeft)
if (sider instanceof HTMLElement) {
sider.style.transform = offset > 0 ? `translateY(${offset}px)` : ''
}
}
body.addEventListener('scroll', onScroll, { passive: true })
body.addEventListener('scroll', updateSticky, { passive: true })
window.addEventListener('resize', updateSticky)
const observer = new ResizeObserver(updateSticky)
observer.observe(content)
if (sider instanceof HTMLElement) observer.observe(sider)
onScroll()
updateSticky()
return () => {
body.removeEventListener('scroll', onScroll)
body.removeEventListener('scroll', updateSticky)
window.removeEventListener('resize', updateSticky)
observer.disconnect()
body.id = ''
body.style.overflowY = ''
body.style.overflowX = ''
body.style.marginTop = ''
body.style.height = ''
content.style.overflow = ''
content.style.marginTop = ''
main.style.height = ''
if (sider instanceof HTMLElement) {
sider.style.overflow = ''
sider.style.height = ''
sider.style.alignSelf = ''
sider.style.transform = ''
}
}
}, [])
const site: SiteSettings = settings?.settings ?? {
header: { enabled: true, speed: 24, items: [] },
sidebar: {
show_profile: true,
show_service: true,
hidden: [],
order: [],
hidden_groups: [],
shown_groups: [],
group_order: {},
tree_hidden: [],
tree_order: {},
},
sections: { hidden: [], order: [], titles: {}, limits: {} },
profile: {
name: '本地管理员',
role: '管理员',
description: '',
location: '',
avatar: '',
},
}
const keywordLower = keyword.trim().toLowerCase()
const sidebarItems: MenuItem[] = useMemo(() => {
const hidden = new Set(site.sidebar.tree_hidden)
const sortNodes = (nodes: GroupNode[], parentKey: string): GroupNode[] => {
const ranks = new Map(
(site.sidebar.tree_order[parentKey] ?? []).map((key, index) => [key, index] as const),
)
return [...nodes].sort((a, b) => {
const left = ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER
const right = ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER
if (left === right) return a.sort - b.sort
return left - right
})
}
const build = (nodes: GroupNode[], parentKey: string): MenuItem[] =>
sortNodes(nodes, parentKey).flatMap((node) => {
if (node.enabled === false) return []
if (hidden.has(node.key)) return build(node.children, node.key)
const children = build(node.children, node.key)
return [
{
key: `g:${node.key}`,
label: node.title,
children: children.length > 0 ? children : undefined,
},
]
})
return [{ key: 'home', label: '首页' }, ...build(manage.groups, '')]
}, [manage.groups, site.sidebar.tree_hidden, site.sidebar.tree_order])
const activeKey =
route.kind === 'home'
? '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 all = collectEntries(group).filter((entry) => matches(entry, keywordLower))
if (all.length === 0) return null
return (
<div key={group.key} className="card-group-block">
{showTitle && <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 && item.group.enabled !== false,
)
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) {
return <Empty text={loading ? '正在读取内容库…' : '分组不存在'} />
}
const trail = findPath(manage.groups, key) ?? [group]
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">
{group.icon || '•'}
</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.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>
<button
type="button"
className="link-button"
onClick={() => navigate(`/g/${encodeURIComponent(child.key)}`)}
>
查看全部
</button>
</div>
{child.entries.length > 0 ? (
renderEntries(child, child.entries, { limit: 12 })
) : (
<Empty text="暂无内容" />
)}
</section>
))}
</Space>
</div>
)
}
function renderItemPage(key: string, itemId: string) {
const found = findEntry(manage.groups, key, itemId)
if (found === undefined) {
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"
contentOffset={88}
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}
authenticated={session?.authenticated === true}
username={session?.username ?? undefined}
onFocusSearch={() => searchRef.current?.focus()}
onLogin={() => setLoginOpen(true)}
onLogout={() => {
void logout().then(() => setSession({ authenticated: false, has_admin: true }))
}}
onSettings={() => window.location.assign('/admin')}
/>
}
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}
/>
)}
<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">
<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>
)}
{route.kind === 'home' && renderHome()}
{route.kind === 'group' && renderGroupPage(route.key)}
{route.kind === 'item' && renderItemPage(route.key, route.itemId)}
</div>
</Layout>
<BackToTop visible={scrolled} targetId="page-scroll" />
</>
)
}