refactor(web): 前端改用 @stash/ui 组件库

- 新增本地依赖 @stash/ui(file:../../stash-ui),引入其 style.css 与设计令牌
- 页面骨架改用 Layout(contained + header/sider),搜索框改为吸顶
- 卡片/菜单/按钮/标签/头像/分页/空态/提示全部换成组件库组件
- 删除本地 iconfont 与 1400 行手写组件样式,App.css 只保留页面级样式
- 数据层、路由、过滤逻辑未改动;33 个 Rust 测试通过
This commit is contained in:
stash
2026-10-28 12:44:50 +08:00
parent 19ead7b716
commit 64f1fbf906
35 changed files with 735 additions and 2154 deletions
+21 -23
View File
@@ -1,14 +1,9 @@
import { Avatar, ItemBadge, Space, Text } from '@stash/ui'
import type { AppItem } from '../../data/types'
/**
* 应用卡片:逐项复刻 Alright 主题「推荐作者」条目的结构。
*
* 对应主题的:
* .flex-avatar > img.avatar-48 + span.avatar-badge.badge_5
* .item-content > h3.font-title.text-md(名字 + span.item-badge.hot)
* + div.text-xs.text-muted.h-1x(单行描述)
*
* 热门条目才会同时出现头像角标和 HOT 徽标,普通条目两者都没有。
* 应用卡片:头像 + 名称(热门带 HOT 角标)+ 单行描述。
* 点击进入条目详情;按住修饰键时保持浏览器默认行为(新标签打开原始链接)。
*/
type AppCardProps = {
item: AppItem
@@ -28,22 +23,25 @@ export function AppCard({ item, onOpen }: AppCardProps) {
onOpen(item.id)
}}
>
<span className="app-card-avatar" aria-hidden="true">
{item.cover !== undefined ? (
<img src={item.cover} alt="" loading="lazy" />
) : (
<span className="app-card-letter">{item.icon}</span>
)}
{item.hot === true && <span className="app-card-verify" />}
</span>
<Avatar
size="md"
shape="square"
src={item.cover}
fallback={item.icon}
verified={item.hot === true}
/>
<span className="app-card-info">
<span className="app-card-title">
<span className="app-card-name">{item.name}</span>
{item.hot === true && <span className="app-card-badge" />}
</span>
<span className="app-card-desc">{item.desc}</span>
</span>
<div className="app-card-info">
<Space gap="xs">
<Text strong truncate className="app-card-name">
{item.name}
</Text>
{item.hot === true && <ItemBadge tone="hot">HOT</ItemBadge>}
</Space>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</div>
</a>
)
}
+4 -6
View File
@@ -1,10 +1,8 @@
import { Grid } from '@stash/ui'
import type { AppItem } from '../../data/types'
import { AppCard } from './AppCard'
/**
* 应用网格:默认 4 列,窄屏退化为 2 列。
* 首页预览与明细页共用,靠 modifier 调整间距。
*/
/** 应用网格:默认 4 列,平板 2 列,手机 2 列(组件库 Grid 负责断点) */
type AppGridProps = {
items: AppItem[]
/** 明细页使用更宽松的间距 */
@@ -15,10 +13,10 @@ type AppGridProps = {
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
return (
<div className={`app-grid${variant === 'detail' ? ' app-grid--detail' : ''}`}>
<Grid columns={4} tabletColumns={2} mobileColumns={2} gap={variant === 'detail' ? 'md' : 'sm'}>
{items.map((item) => (
<AppCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
@@ -1,3 +1,4 @@
import { Paragraph, Title } from '@stash/ui'
import type { ReactNode } from 'react'
/**
@@ -10,7 +11,6 @@ import type { ReactNode } from 'react'
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
*
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
* 需要完整语法时再换 react-markdown,渲染层已经隔离在这个组件里。
*/
type Block =
@@ -104,9 +104,9 @@ export function ArticleBody({ markdown }: ArticleBodyProps) {
{parseBlocks(markdown).map((block, index) => {
if (block.kind === 'heading') {
return (
<h3 className="article-body-heading" key={index}>
<Title level={2} as="h3" key={index}>
{renderInline(block.text)}
</h3>
</Title>
)
}
@@ -123,9 +123,9 @@ export function ArticleBody({ markdown }: ArticleBodyProps) {
}
return (
<p className="article-body-text" key={index}>
<Paragraph tone="main" key={index}>
{renderInline(block.text)}
</p>
</Paragraph>
)
})}
</>
@@ -12,24 +12,17 @@ type ArticleCardProps = {
export function ArticleCard({ item, onOpen }: ArticleCardProps) {
const content = (
<>
<img className="article-cover" src={item.cover} alt={item.title} loading="lazy" />
<span className="article-caption">
<span className="article-title">{item.title}</span>
</span>
<img className="cover cover--article" src={item.cover} alt={item.title} loading="lazy" />
<span className="cover-caption">{item.title}</span>
</>
)
if (onOpen === undefined) {
return <figure className="article-card">{content}</figure>
return <figure className="cover-card">{content}</figure>
}
return (
<button
type="button"
className="article-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
<button type="button" className="cover-card" title={item.title} onClick={() => onOpen(item.id)}>
{content}
</button>
)
@@ -1,3 +1,4 @@
import { Button, Card, Paragraph, Space, Text, Title } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { ArticleBody } from './ArticleBody'
@@ -19,34 +20,49 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
return (
<div className="detail-view article-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
返回
</button>
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
</div>
<Card padding="sm">
<Space justify="between" wrap>
<Button variant="ghost" size="sm" onClick={onBack}>
返回
</Button>
<Text size="sm" tone="sub">
{categoryTitle} / 文章
</Text>
</Space>
</Card>
<header className="article-head">
<h2 className="article-heading">
<Title level={2}>
{article.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
[编辑]
</button>
</h2>
<Button variant="ghost" size="sm" disabled className="item-edit">
编辑
</Button>
</Title>
<div className="article-meta">
<Space gap="sm" wrap>
{meta.map((text, index) => (
<span className="article-meta-item" key={text}>
{index > 0 && <i className="article-meta-dot" aria-hidden="true" />}
{text}
</span>
<Space gap="sm" key={text}>
{index > 0 && (
<span className="article-meta-dot" aria-hidden="true">
·
</span>
)}
<Text size="sm" tone="muted">
{text}
</Text>
</Space>
))}
</div>
</Space>
</header>
<article className="article-body card-box">
<ArticleBody markdown={article.body} />
</article>
<Card padding="lg">
<div className="article-body">
<Paragraph tone="main" className="article-lead">
{article.desc}
</Paragraph>
<ArticleBody markdown={article.body} />
</div>
</Card>
</div>
)
}
@@ -1,7 +1,8 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { ArticleCard } from './ArticleCard'
/** 图文网格:首页 4 列,窄屏 2 列 */
/** 图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type ArticleGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
@@ -10,10 +11,10 @@ type ArticleGridProps = {
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
return (
<div className="article-grid">
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="sm">
{items.map((item) => (
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
+25 -20
View File
@@ -1,3 +1,4 @@
import { Card, Space, Text } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
/**
@@ -10,28 +11,32 @@ type CesCardProps = {
}
export function CesCard({ item, onOpen }: CesCardProps) {
const content = (
<>
<img className="ces-cover" src={item.cover} alt={item.title} loading="lazy" />
<span className="ces-body">
<span className="ces-title">{item.title}</span>
<span className="ces-desc">{item.desc}</span>
</span>
</>
const body = (
<Space direction="column" gap="xs" align="stretch">
<Text strong truncate>
{item.title}
</Text>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</Space>
)
if (onOpen === undefined) {
return <article className="ces-card">{content}</article>
}
return (
<button
type="button"
className="ces-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
{content}
</button>
<Card padding="none" hoverable className="ces-card">
<img className="cover cover--article" src={item.cover} alt={item.title} loading="lazy" />
{onOpen === undefined ? (
<div className="ces-body">{body}</div>
) : (
<button
type="button"
className="ces-body ces-body--button"
title={item.title}
onClick={() => onOpen(item.id)}
>
{body}
</button>
)}
</Card>
)
}
@@ -1,7 +1,8 @@
import { Grid } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { CesCard } from './CesCard'
/** 明细页图文网格:4 列白卡,窄屏 2 列 */
/** 明细页图文网格:桌面 4 列,平板 2 列,手机 1 列 */
type CesCardGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
@@ -10,10 +11,10 @@ type CesCardGridProps = {
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
return (
<div className="ces-grid">
<Grid columns={4} tabletColumns={2} mobileColumns={1} gap="sm">
{items.map((item) => (
<CesCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
+21 -14
View File
@@ -1,3 +1,4 @@
import { Badge, Breadcrumb, Button, Card, Empty, Space, Title } from '@stash/ui'
import { categoryCount, type Category } from '../../data/types'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
@@ -28,10 +29,14 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
const title = activeGroup?.name ?? category.title
const count = activeGroup !== undefined ? activeGroup.items.length : categoryCount(category)
const breadcrumb =
const crumbs =
activeGroup !== undefined
? `首页 / ${category.title} / ${activeGroup.name}`
: `首页 / ${category.title}`
? [
{ label: '首页', href: '/' },
{ label: category.title },
{ label: activeGroup.name },
]
: [{ label: '首页', href: '/' }, { label: category.title }]
function renderBody() {
if (category.kind === 'posters') {
@@ -51,33 +56,35 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
}
if (category.groups.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
return <Empty text={category.emptyText ?? '暂无内容'} />
}
return (
<>
<Space direction="column" gap="lg" align="stretch">
{category.groups.map((group) => (
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
))}
</>
</Space>
)
}
return (
<div className="detail-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
返回首页
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
</div>
<Card padding="sm">
<Space justify="between" wrap>
<Button variant="ghost" size="sm" onClick={onBack}>
返回首页
</Button>
<Breadcrumb items={crumbs} />
</Space>
</Card>
<div className="detail-hero">
<span className="detail-hero-icon" aria-hidden="true">
{category.icon}
</span>
<h2 className="detail-hero-title">{title}</h2>
<span className="detail-hero-count">[{count}]</span>
<Title level={2}>{title}</Title>
<Badge count={count} tone="neutral" />
</div>
{renderBody()}
@@ -1,3 +1,4 @@
import { Space, Title } from '@stash/ui'
import type { AppGroup } from '../../data/types'
import { AppGrid } from './AppGrid'
@@ -12,9 +13,11 @@ type GroupBlockProps = {
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
return (
<section className="group-block">
<h3 className="group-title">{group.name}</h3>
<Space direction="column" gap="sm" align="stretch" className="group-block">
<Title level={4} as="h3">
{group.name}
</Title>
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
</section>
</Space>
)
}
@@ -1,6 +1,7 @@
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'
@@ -68,7 +69,7 @@ function renderPreview(
const items = source.slice(0, limit)
if (items.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
return <Empty text={category.emptyText ?? '暂无内容'} />
}
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
@@ -1,6 +1,6 @@
import { Button, Card, Icon, IconButton, Paragraph, Space, Tag, Text, Title } from '@stash/ui'
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
import { RelatedList } from './RelatedList'
/**
@@ -27,20 +27,24 @@ export function ItemDetailView({
}: ItemDetailViewProps) {
return (
<div className="detail-view item-view">
<div className="detail-nav card-box">
<button type="button" className="detail-back" onClick={onBack}>
返回
</button>
<span className="detail-breadcrumb">{breadcrumb}</span>
</div>
<Card padding="sm">
<Space justify="between" wrap>
<Button variant="ghost" size="sm" onClick={onBack}>
返回
</Button>
<Text size="sm" tone="sub">
{breadcrumb}
</Text>
</Space>
</Card>
<article className="item-card card-box">
<h2 className="item-title">
<Card padding="lg">
<Title level={2}>
{model.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
[编辑]
</button>
</h2>
<Button variant="ghost" size="sm" disabled className="item-edit">
编辑
</Button>
</Title>
<div className={`item-cover item-cover--${model.shape}`}>
{model.cover !== undefined ? (
@@ -51,68 +55,62 @@ export function ItemDetailView({
</div>
{model.tags.length > 0 && (
<div className="item-tags">
<Space gap="sm" wrap>
{model.tags.map((tag) => (
<span className="item-tag" key={tag}>
<Tag key={tag} shape="rounded">
{tag}
</span>
</Tag>
))}
</div>
</Space>
)}
{model.comment !== undefined && (
<div className="item-quote">
<span className="item-quote-mark" aria-hidden="true">
“
</span>
<p className="item-quote-text">{model.comment}</p>
</div>
<Paragraph tone="secondary" className="item-quote">
{model.comment}
</Paragraph>
)}
{model.paragraphs.map((paragraph, index) => (
<p className="item-desc" key={`${index}-${paragraph.slice(0, 8)}`}>
<Paragraph key={`${index}-${paragraph.slice(0, 8)}`} tone="main">
{paragraph}
</p>
</Paragraph>
))}
<div className="item-actions">
<span className="item-stat" title="浏览量">
<EyeIcon />
<em>{model.views}</em>
</span>
<span className="item-stat" title="点赞数">
<ThumbUpIcon />
<em>{model.likes}</em>
</span>
<button
type="button"
className="item-stat item-stat--icon"
title="分享(待接入)"
aria-label="分享"
>
<ShareIcon />
</button>
<Space justify="between" wrap className="item-actions">
<Space gap="lg">
<Space gap="xs" className="item-stat">
<Icon name="eye_line" />
<Text size="sm" tone="sub">
{model.views}
</Text>
</Space>
<Space gap="xs" className="item-stat">
<Icon name="thumb_up_2_line" />
<Text size="sm" tone="sub">
{model.likes}
</Text>
</Space>
<IconButton label="分享(待接入)" size="xs" icon={<Icon name="share_3_line" />} />
</Space>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
{model.href === '#' ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址">
<Button size="sm" disabled>
跳转到
<ExternalIcon />
</button>
<Icon name="a-external_link_line1" />
</Button>
) : (
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
<a className="link-button" href={model.href} target="_blank" rel="noreferrer">
跳转到
<ExternalIcon />
<Icon name="a-external_link_line1" />
</a>
)}
</div>
</Space>
{model.banner !== undefined && (
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
)}
</article>
</Card>
<RelatedList items={related} onOpen={onOpenItem} />
</div>
@@ -12,24 +12,17 @@ type PosterCardProps = {
export function PosterCard({ item, onOpen }: PosterCardProps) {
const content = (
<>
<img className="poster-cover" src={item.cover} alt={item.title} loading="lazy" />
<span className="poster-caption">
<span className="poster-title">{item.title}</span>
</span>
<img className="cover cover--poster" src={item.cover} alt={item.title} loading="lazy" />
<span className="cover-caption">{item.title}</span>
</>
)
if (onOpen === undefined) {
return <figure className="poster-card">{content}</figure>
return <figure className="cover-card">{content}</figure>
}
return (
<button
type="button"
className="poster-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
<button type="button" className="cover-card" title={item.title} onClick={() => onOpen(item.id)}>
{content}
</button>
)
@@ -1,7 +1,8 @@
import { Grid } from '@stash/ui'
import type { PosterItem } from '../../data/types'
import { PosterCard } from './PosterCard'
/** 海报网格:首页 6 列,明细页同样 6 列但间距更大 */
/** 海报网格:桌面 6 列,平板 4 列,手机 3 列 */
type PosterGridProps = {
items: PosterItem[]
variant?: 'home' | 'detail'
@@ -9,12 +10,12 @@ type PosterGridProps = {
onOpen?: (itemId: string) => void
}
export function PosterGrid({ items, variant = 'home', onOpen }: PosterGridProps) {
export function PosterGrid({ items, onOpen }: PosterGridProps) {
return (
<div className={`poster-grid${variant === 'detail' ? ' poster-grid--detail' : ''}`}>
<Grid columns={6} tabletColumns={4} mobileColumns={3} gap="sm">
{items.map((item) => (
<PosterCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
</Grid>
)
}
@@ -1,3 +1,4 @@
import { Avatar, Card, Grid, Space, Text, Title } from '@stash/ui'
import type { RelatedItem } from '../../data/types'
/**
@@ -13,33 +14,31 @@ export function RelatedList({ items, onOpen }: RelatedListProps) {
if (items.length === 0) return null
return (
<section className="related-card card-box" aria-label="推荐阅读">
<h3 className="related-title">
<span className="related-title-icon" aria-hidden="true">
📚
</span>
<Card padding="lg" className="related-card">
<Title level={4} as="h3">
推荐阅读
</h3>
</Title>
<ul className="related-grid">
<Grid columns={2} tabletColumns={2} mobileColumns={1} gap="sm">
{items.map((item) => (
<li key={item.id}>
<button type="button" className="related-item" onClick={() => onOpen(item.id)}>
<span className="related-avatar" aria-hidden="true">
{item.cover !== undefined ? (
<img src={item.cover} alt="" loading="lazy" />
) : (
(item.icon ?? '•')
)}
</span>
<span className="related-info">
<span className="related-name">{item.name}</span>
<span className="related-desc">{item.desc}</span>
</span>
</button>
</li>
<button
key={item.id}
type="button"
className="related-item"
onClick={() => onOpen(item.id)}
>
<Avatar size="sm" shape="square" src={item.cover} fallback={item.icon ?? '•'} />
<Space direction="column" gap="xs" align="start" className="related-info">
<Text size="sm" strong truncate>
{item.name}
</Text>
<Text size="xs" tone="muted" truncate>
{item.desc}
</Text>
</Space>
</button>
))}
</ul>
</section>
</Grid>
</Card>
)
}
@@ -1,9 +1,8 @@
import { Badge, Card, Icon, IconButton, Space, Title } from '@stash/ui'
import type { ReactNode } from 'react'
import { CountBadge } from '../common/CountBadge'
import { MoreIcon } from '../common/Icon'
/**
* 首页分区卡片:白底圆角容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
* 首页分区卡片:白底容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
*/
type SectionCardProps = {
@@ -18,30 +17,27 @@ type SectionCardProps = {
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
return (
<section className="section-card">
<header className="section-header">
<div className="section-title-wrapper">
<Card padding="lg" className="section-card">
<div className="section-header">
<Space gap="sm">
<span className="section-bar" style={{ backgroundColor: barColor }} aria-hidden="true" />
<span className="section-title-box">
<h2 className="section-title">{title}</h2>
<CountBadge count={count} />
</span>
</div>
<Title level={3} as="h2">
{title}
</Title>
<Badge count={count} tone="danger" />
</Space>
{onMore !== undefined && (
<button
type="button"
className="section-more"
title={`查看${title}全部`}
aria-label={`查看${title}全部`}
<IconButton
size="xs"
label={`查看${title}全部`}
icon={<Icon name="more_1_line" />}
onClick={onMore}
>
<MoreIcon />
</button>
/>
)}
</header>
</div>
{children}
</section>
</Card>
)
}