chore: 清理无引用的前端与旧模板资源

- 删除 13 个旧 App 时代的前端组件/数据层文件(无 import 可达)
- 删除 templates/ 6 个旧 Askama 原型页与 assets/dashboard.{css,js}
- README/设计文档同步当前路由与图标实现,移除失效引用
- stash-web 构建通过,cargo test 59 passed
This commit is contained in:
2026-11-02 20:10:48 +08:00
parent 8889515373
commit 4621f8cb22
23 changed files with 11 additions and 5669 deletions
@@ -1,13 +0,0 @@
import { Badge } from '@stash/ui'
/**
* 数量角标:贴在分区标题旁边的小红标。
* 组件库的 Badge 已经处理了两位数和 99+ 的宽度,这里只做语义封装。
*/
type CountBadgeProps = {
count: number
}
export function CountBadge({ count }: CountBadgeProps) {
return <Badge count={count} tone="danger" />
}
-57
View File
@@ -1,57 +0,0 @@
import { Icon } from '@stash/ui'
/**
* 语义图标:stash 内部一直按用途引用(SearchIcon / RefreshIcon…),
* 这里统一映射到 @stash/ui 的 iconfont 字形,避免各页面记字形名。
*/
type IconProps = {
className?: string
}
export function SearchIcon({ className }: IconProps) {
return <Icon name="search_3_line" className={className} />
}
export function UserIcon({ className }: IconProps) {
return <Icon name="user_3_line" className={className} />
}
export function RefreshIcon({ className }: IconProps) {
return <Icon name="refresh_3_line" className={className} />
}
export function LinkIcon({ className }: IconProps) {
return <Icon name="link_3_line" className={className} />
}
export function ExternalIcon({ className }: IconProps) {
return <Icon name="a-external_link_line1" className={className} />
}
export function EyeIcon({ className }: IconProps) {
return <Icon name="eye_line" className={className} />
}
export function ThumbUpIcon({ className }: IconProps) {
return <Icon name="thumb_up_2_line" className={className} />
}
export function ShareIcon({ className }: IconProps) {
return <Icon name="share_3_line" className={className} />
}
export function CloseIcon({ className }: IconProps) {
return <Icon name="close_line" className={className} />
}
export function MoreIcon({ className }: IconProps) {
return <Icon name="more_1_line" className={className} />
}
export function ArrowUpIcon({ className }: IconProps) {
return <Icon name="arrow_up_line" className={className} />
}
export function ArrowDownIcon({ className }: IconProps) {
return <Icon name="arrow_down_line" className={className} />
}
@@ -1,23 +0,0 @@
import { Cover } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type ArticleCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
return (
<Cover
src={item.cover}
alt={item.title}
caption={item.title}
ratio="article"
onClick={onOpen === undefined ? undefined : () => onOpen(item.id)}
/>
)
}
@@ -1,20 +0,0 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { ArticleCard } from './ArticleCard'
/** 图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type ArticleGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
return (
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="md">
{items.map((item) => (
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
))}
</Grid>
)
}
@@ -1,138 +0,0 @@
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
import {
categoryCount,
type AppItem,
type ArticleItem,
type Category,
type PosterItem,
} from '../../data/types'
import { viewPath } from '../../data/view'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
import { GroupBlock } from './GroupBlock'
import { PosterGrid } from './PosterGrid'
/**
* 分类明细视图:面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
*
* 内容区按分类类型切换:
* - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
* - posters:6 列海报;
* - articles:上图下文白卡网格。
*/
type DetailViewProps = {
category: Category
/** 从侧栏子菜单进入时指定的分组 */
groupId?: string
/** 点击面包屑中间项(SPA 路由) */
onNavigate: (href: string) => void
/** 点击卡片 / 海报进入条目详情 */
onOpenItem: (itemId: string) => void
}
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
// 三种类型共用「分区 → 分组 → 条目」结构;这里抹平联合类型,渲染时再按 kind 分派
type RenderItem = AppItem | PosterItem | ArticleItem
const groups = category.groups as {
id: string
name: string
items: RenderItem[]
}[]
const activeGroup =
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
const title = activeGroup?.name ?? category.title
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
const crumbs =
activeGroup !== undefined
? [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
{ label: activeGroup.name },
]
: [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
]
/** 按类型渲染一组条目 */
function renderItems(items: RenderItem[]) {
if (category.kind === 'posters') {
return <PosterGrid items={items as PosterItem[]} variant="detail" onOpen={onOpenItem} />
}
if (category.kind === 'articles') {
return <CesCardGrid items={items as ArticleItem[]} onOpen={onOpenItem} />
}
return <AppGrid items={items as AppItem[]} variant="detail" onOpen={onOpenItem} />
}
function renderBody() {
if (activeGroup !== undefined) {
return renderItems(activeGroup.items)
}
const direct = (category.items ?? []) as RenderItem[]
// 空分组不占篇幅(管理后台仍然保留,之后可以往里加条目)
const filledGroups = groups.filter((group) => group.items.length > 0)
if (filledGroups.length === 0) {
return direct.length > 0 ? (
renderItems(direct)
) : (
<Empty text={'emptyText' in category ? (category.emptyText ?? '暂无内容') : '暂无内容'} />
)
}
if (filledGroups.length === 1 && direct.length === 0) {
return renderItems(filledGroups[0].items)
}
return (
<Space direction="column" gap="lg" align="stretch">
{filledGroups.map((group) => (
<GroupBlock
key={group.id}
name={group.name}
barColor={category.barColor !== '' ? category.barColor : undefined}
>
{renderItems(group.items)}
</GroupBlock>
))}
{direct.length > 0 && (
<GroupBlock
name="未分组"
barColor={category.barColor !== '' ? category.barColor : undefined}
>
{renderItems(direct)}
</GroupBlock>
)}
</Space>
)
}
return (
<div className="detail-view">
<Card padding="none" className="detail-nav">
<Breadcrumb
className="detail-breadcrumb"
items={crumbs}
onNavigate={onNavigate}
/>
</Card>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{category.icon}
</span>
<Title level={1} as="h2" weight="strong" className="detail-hero-title">
{title}
</Title>
<span className="detail-hero-count">[{count}]</span>
</div>
{renderBody()}
</div>
)
}
@@ -1,33 +0,0 @@
import { Space, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
* 明细页的分组块:色条 + 小标题 + 该组的内容网格。
*
* 色条沿用分区的 `barColor`,与首页卡片标题保持同一套视觉语言;
* 具体网格由调用方传入(应用 / 海报 / 图文共用同一层外壳)。
*/
type GroupBlockProps = {
name: string
/** 分区色条颜色;缺省用主题主色 */
barColor?: string
children: ReactNode
}
export function GroupBlock({ name, barColor, children }: GroupBlockProps) {
return (
<Space direction="column" gap="sm" align="stretch" className="group-block">
<div className="group-title">
<span
className="group-bar"
style={barColor !== undefined ? { backgroundColor: barColor } : undefined}
aria-hidden="true"
/>
<Title level={4} as="h3">
{name}
</Title>
</div>
{children}
</Space>
)
}
@@ -1,118 +0,0 @@
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
import { AppGrid } from './AppGrid'
import { ArticleGrid } from './ArticleGrid'
import { Empty } from '@stash/ui'
import { PosterGrid } from './PosterGrid'
import { SectionCard } from './SectionCard'
import {
applySectionSettings,
sectionLimit,
sectionTitle,
type SectionSettings,
} from '../../settings'
/**
* 首页:把每个分类渲染成一张分区卡片。
*
* 每个分区只展示前若干条;标题、条数、顺序与显隐都可以在 `/admin`
* 的「首页卡片」里覆盖(见 settings.sections)。
*/
type HomeViewProps = {
categories: Category[]
sections: SectionSettings
onOpenDetail: (categoryId: string) => void
onOpenItem: (categoryId: string, itemId: string) => void
}
export function HomeView({ categories, sections, onOpenDetail, onOpenItem }: HomeViewProps) {
const cards = applySectionSettings(categories, sections)
return (
<div className="home-view">
{cards.map((category) => (
<SectionCard
key={category.id}
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
// 没配(如「本地服务」)才用分类标题;设置里的覆盖值优先
title={sectionTitle(
category.id,
category.block?.title ?? category.title,
sections,
)}
count={cardCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
{renderPreview(category, sections, onOpenItem)}
</SectionCard>
))}
</div>
)
}
/**
* 卡片右上角的数量角标。
*
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
return categoryCount(category)
}
/** 某个分区配的「首页预览分组」;没配或找不到返回 undefined */
function blockGroup<T>(category: { block?: { nodeKey?: string }; groups: { id: string; items: T[] }[] }) {
if (category.block?.nodeKey === undefined) return undefined
return category.groups.find((group) => group.id === category.block?.nodeKey)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
sections: SectionSettings,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
// 配了展示块且指向分组时只展示该组;展示块指向分区本身(「整个分区」)时展示全部条目
const group = blockGroup(category)
const source = group !== undefined ? group.items : flattenAppItems(category)
const fallback = category.block?.previewLimit ?? category.previewLimit ?? 4
const limit = sectionLimit(category.id, fallback, sections)
const items = source.slice(0, limit)
if (items.length === 0) {
return <Empty text={category.emptyText ?? '暂无内容'} />
}
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
}
if (category.kind === 'posters') {
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<PosterGrid
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 6, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
const group = blockGroup(category)
const source = group
? group.items
: [...category.items, ...category.groups.flatMap((item) => item.items)]
return (
<ArticleGrid
items={source.slice(0, sectionLimit(category.id, category.previewLimit ?? 4, sections))}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
-49
View File
@@ -1,49 +0,0 @@
import type { Category } from './types'
/**
* 按关键字过滤分类内容。
*
* 应用类分类会逐组过滤并丢掉空组,海报 / 图文类按标题或描述匹配;
* 空关键字直接原样返回,避免无谓的对象重建。
*/
export function filterCategories(categories: Category[], keyword: string): Category[] {
const query = keyword.trim().toLowerCase()
if (query === '') return categories
const result: Category[] = []
for (const category of categories) {
if (category.kind === 'apps') {
const groups = category.groups
.map((group) => ({
...group,
items: group.items.filter((item) => matches(query, item.name, item.desc)),
}))
.filter((group) => group.items.length > 0)
if (groups.length > 0) {
result.push({ ...category, groups })
}
continue
}
if (category.kind === 'posters') {
const items = category.items.filter((item) => matches(query, item.title))
if (items.length > 0) {
result.push({ ...category, items })
}
continue
}
const items = category.items.filter((item) => matches(query, item.title, item.desc))
if (items.length > 0) {
result.push({ ...category, items })
}
}
return result
}
function matches(query: string, ...fields: string[]): boolean {
return fields.some((field) => field.toLowerCase().includes(query))
}
-63
View File
@@ -1,63 +0,0 @@
import type { AppCategory, AppItem, Category } from './types'
/**
* 「本地服务」分类的组装逻辑。
*
* 这一块不来自内容库:托管目录里有哪些项目是由文件系统决定的,
* 后端每次扫描后通过 `/api/projects` 推送,所以本地分类始终在前端拼出来,
* 内容库只负责其余的分类(常用推荐 / 推荐电影 / CES 现场报道)。
*/
/** 后端 `/api/projects` 返回的项目结构 */
export type HostedProject = {
name: string
route_path: string
}
/**
* 把托管项目映射成应用条目。
*
* id 用项目名(就是目录名):它在前端各处(首页卡片、明细、详情)唯一,
* 又正好是可以直接放进地址栏的一段——条目详情地址是 `/c/local/<分组>/i/<项目名>`。
* 访问地址仍然用 `route_path`(`/demo/`),两者不再混用。
* 本地项目没有封面图,详情页会退化成字符头像。
*/
function toLocalItem(project: HostedProject): AppItem {
return {
id: project.name,
name: project.name,
desc: project.route_path,
icon: project.name.slice(0, 1).toUpperCase(),
href: project.route_path,
comment: '本地静态站点,由 Stash 直接托管。',
badge: '本地项目',
views: 0,
likes: 0,
}
}
/** 生成「本地服务」分类;没有项目时给出一句引导文案 */
export function buildLocalCategory(projects: HostedProject[]): AppCategory {
const items = projects.map(toLocalItem)
return {
id: 'local',
kind: 'apps',
title: '本地服务',
barColor: '#3858f6',
icon: '🖥️',
groups: items.length > 0 ? [{ id: 'local-all', name: '全部项目', items }] : [],
previewLimit: 8,
emptyText: '还没有项目:在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。',
}
}
/**
* 拼接首页全部分类:本地服务永远排第一,其后跟进内容库返回的分类。
*/
export function withLocalCategory(
projects: HostedProject[],
categories: Category[],
): Category[] {
return [buildLocalCategory(projects), ...categories]
}
-12
View File
@@ -1,12 +0,0 @@
/**
* 导航目标。
*
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
* 这里只保留「点击后要去哪」的最小类型。
*/
export type NavTarget = {
categoryId: string
/** 指定后明细页只展示该分组 */
groupId?: string
}
-19
View File
@@ -1,19 +0,0 @@
/**
* 登录后展示的用户模块数据。
*
* 后端目前没有鉴权 / 用户信息接口,这里先用占位数据,
* 等类似 `/api/me` 的接口就绪后替换为真实数据即可。
*/
export type UserProfile = {
name: string
role: string
bio: string
location: string
}
export const PLACEHOLDER_PROFILE: UserProfile = {
name: '本地管理员',
role: '管理员',
bio: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
location: '局域网',
}
-79
View File
@@ -1,79 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { fetchCatalog, subscribeCatalog } from './api'
import type { Category } from './data/types'
export type CatalogStatus = 'loading' | 'ready' | 'error'
export type CatalogState = {
status: CatalogStatus
categories: Category[]
error: string | null
}
export type CatalogController = CatalogState & {
/** 手动重新拉取;内容库被外部修改后也会自动调用 */
reload: () => void
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。
*
* 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据,
* 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。
*/
export function useCatalog(): CatalogController {
const [state, setState] = useState<CatalogState>({
status: 'loading',
categories: [],
error: null,
})
const controllerRef = useRef<AbortController | null>(null)
/**
* 拉一次内容目录。
*
* 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新,
* 只保留最后一次请求,避免旧响应把新数据覆盖回去。
*/
const load = useCallback(() => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
fetchCatalog(controller.signal)
.then((catalog) => {
if (!controller.signal.aborted) {
setState({
status: 'ready',
categories: catalog.categories,
error: null,
})
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((previous) => ({
// 已经有内容时只提示错误,不清空列表
status: previous.categories.length > 0 ? 'ready' : 'error',
categories: previous.categories,
error: toErrorMessage(error),
}))
}
})
}, [])
// 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState
useEffect(() => {
load()
return () => controllerRef.current?.abort()
}, [load])
// 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取
useEffect(() => subscribeCatalog(load), [load])
return { ...state, reload: load }
}
-105
View File
@@ -1,105 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { parseViewPath, viewPath, type View } from './data/view'
/**
* 视图 ↔ 地址同步。
*
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
*/
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
const HISTORY_INDEX_KEY = 'stashIndex'
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
function historyIndex(): number {
const state = window.history.state as StashHistoryState | null
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
return typeof index === 'number' ? index : 0
}
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
function currentHistoryState(): StashHistoryState {
const state = window.history.state as StashHistoryState | null
return state === null ? {} : { ...state }
}
export type ViewRoute = {
/** 当前视图,初值来自地址栏 */
view: View
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
go: (view: View, options?: { replace?: boolean }) => void
/**
* 返回。
*
* 应用内有上一页时交给浏览器(`history.back()`),
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
*/
back: (fallback: View) => void
}
export function useViewRoute(): ViewRoute {
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
useEffect(() => {
const canonical = viewPath(parseViewPath(window.location.pathname))
const state = currentHistoryState()
const recorded = state[HISTORY_INDEX_KEY] !== undefined
if (canonical === window.location.pathname && recorded) return
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
}, [])
// 前进后退:地址变了,视图跟着变
useEffect(() => {
const handlePopState = () => setView(parseViewPath(window.location.pathname))
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
const go = useCallback((next: View, options?: { replace?: boolean }) => {
const path = viewPath(next)
const index = historyIndex()
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
if (path === window.location.pathname) {
setView(next)
return
}
const replace = options?.replace === true
const state: StashHistoryState = {
...currentHistoryState(),
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
}
if (replace) {
window.history.replaceState(state, '', path)
} else {
window.history.pushState(state, '', path)
}
setView(next)
}, [])
const back = useCallback(
(fallback: View) => {
if (historyIndex() > 0) {
window.history.back()
return
}
go(fallback, { replace: true })
},
[go],
)
return { view, go, back }
}