chore: 清理无引用的前端与旧模板资源
- 删除 13 个旧 App 时代的前端组件/数据层文件(无 import 可达)
- 删除 templates/ 6 个旧 Askama 原型页与 assets/dashboard.{css,js}
- README/设计文档同步当前路由与图标实现,移除失效引用
- stash-web 构建通过,cargo test 59 passed
This commit is contained in:
@@ -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" />
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 导航目标。
|
||||
*
|
||||
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
|
||||
* 这里只保留「点击后要去哪」的最小类型。
|
||||
*/
|
||||
|
||||
export type NavTarget = {
|
||||
categoryId: string
|
||||
/** 指定后明细页只展示该分组 */
|
||||
groupId?: string
|
||||
}
|
||||
@@ -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: '局域网',
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user