feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
@@ -0,0 +1,133 @@
import type { ReactNode } from 'react'
/**
* 极简 Markdown 渲染:只覆盖文章正文实际用到的子集。
*
* 支持:
* - `## 小标题`(1~6 级井号都渲染成同一级小标题,标题层级由版式决定)
* - 空行分段
* - 单独一行的 `![alt](url)` 作为配图
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
*
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
* 需要完整语法时再换 react-markdown,渲染层已经隔离在这个组件里。
*/
type Block =
| { kind: 'heading'; text: string }
| { kind: 'image'; alt: string; src: string }
| { kind: 'paragraph'; text: string }
/** 行内语法:粗体 / 代码 / 链接;其余按纯文本处理 */
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g
/** 把 Markdown 文本切成块;规则尽量简单,宁可少解析也不误伤正文 */
function parseBlocks(markdown: string): Block[] {
const blocks: Block[] = []
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
let paragraph: string[] = []
// 空行或遇到标题/图片时,把攒着的行收成一个段落
const flush = () => {
if (paragraph.length === 0) return
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') })
paragraph = []
}
for (const raw of lines) {
const line = raw.trim()
if (line === '') {
flush()
continue
}
const heading = /^#{1,6}\s+(.*)$/.exec(line)
if (heading !== null) {
flush()
blocks.push({ kind: 'heading', text: heading[1] })
continue
}
const image = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line)
if (image !== null) {
flush()
blocks.push({ kind: 'image', alt: image[1], src: image[2] })
continue
}
paragraph.push(line)
}
flush()
return blocks
}
/** 渲染一行里的行内语法 */
function renderInline(text: string): ReactNode[] {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== '')
.map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={index}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={index}>{part.slice(1, -1)}</code>
}
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part)
if (link !== null) {
return (
<a key={index} href={link[2]} target="_blank" rel="noreferrer">
{link[1]}
</a>
)
}
return <span key={index}>{part}</span>
})
}
type ArticleBodyProps = {
/** Markdown 正文;为空时组件渲染空内容 */
markdown?: string
}
export function ArticleBody({ markdown }: ArticleBodyProps) {
if (markdown === undefined || markdown.trim() === '') return null
return (
<>
{parseBlocks(markdown).map((block, index) => {
if (block.kind === 'heading') {
return (
<h3 className="article-body-heading" key={index}>
{renderInline(block.text)}
</h3>
)
}
if (block.kind === 'image') {
return (
<img
className="article-body-image"
key={index}
src={block.src}
alt={block.alt}
loading="lazy"
/>
)
}
return (
<p className="article-body-text" key={index}>
{renderInline(block.text)}
</p>
)
})}
</>
)
}
@@ -1,12 +1,12 @@
import type { ArticleItem } from '../../data/types'
import { ArrowLeftIcon } from '../common/Icon'
import { ArticleBody } from './ArticleBody'
/**
* 文章详情页(对应模板图二):
* 大标题 + [编辑] + 元信息行 + 白色正文卡(段落 / 小标题 / 配图混排)。
* 大标题 + [编辑] + 元信息行 + 白色正文卡。
*
* 正文是 block 数组,后端以后可以直接把富文本结构映射过来,
* 不用再改渲染层。
* 正文是一整段 Markdown,交给 ArticleBody 解析渲染;
* 渲染细节(支持哪些语法)都收在那一个组件里。
*/
type ArticleDetailViewProps = {
article: ArticleItem
@@ -21,7 +21,6 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
<div className="detail-view article-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
<ArrowLeftIcon />
返回
</button>
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
@@ -46,33 +45,7 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
</header>
<article className="article-body card-box">
{(article.blocks ?? []).map((block, index) => {
if (block.type === 'heading') {
return (
<h3 className="article-body-heading" key={index}>
{block.text}
</h3>
)
}
if (block.type === 'image') {
return (
<img
className="article-body-image"
key={index}
src={block.src}
alt=""
loading="lazy"
/>
)
}
return (
<p className="article-body-text" key={index}>
{block.text}
</p>
)
})}
<ArticleBody markdown={article.body} />
</article>
</div>
)
@@ -1,5 +1,4 @@
import { categoryCount, type Category } from '../../data/types'
import { ArrowLeftIcon } from '../common/Icon'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
import { GroupBlock } from './GroupBlock'
@@ -68,7 +67,6 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
<div className="detail-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
<ArrowLeftIcon />
返回首页
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
+28 -3
View File
@@ -22,8 +22,10 @@ export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps
{categories.map((category) => (
<SectionCard
key={category.id}
title={category.title}
count={categoryCount(category)}
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
// 没配(如「本地服务」)才用分类标题
title={category.block?.title ?? category.title}
count={cardCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
@@ -34,13 +36,36 @@ export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps
)
}
/**
* 卡片右上角的数量角标。
*
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
* 否则(电影 / 文章 / 本地服务)数整个分区。
*/
function cardCount(category: Category): number {
if (category.kind === 'apps' && category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) return group.items.length
}
return categoryCount(category)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
const items = flattenAppItems(category).slice(0, category.previewLimit ?? 4)
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
const blockGroup =
category.block !== undefined
? category.groups.find((group) => group.id === category.block?.nodeKey)
: undefined
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
const limit = category.block?.previewLimit ?? category.previewLimit ?? 4
const items = source.slice(0, limit)
if (items.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
@@ -1,7 +1,6 @@
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
import { ArrowLeftIcon } from '../common/Icon'
import { RelatedList } from './RelatedList'
/**
@@ -30,7 +29,6 @@ export function ItemDetailView({
<div className="detail-view item-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
<ArrowLeftIcon />
返回
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>