feat(web): 对齐 Alright 主题视觉规范(配色/字体/图标/背景/动效),并补充设计规范文档

This commit is contained in:
2026-10-22 20:27:10 +08:00
parent e522a98f6f
commit 386c0f77c7
50 changed files with 6344 additions and 1094 deletions
@@ -0,0 +1,49 @@
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 徽标,普通条目两者都没有。
*/
type AppCardProps = {
item: AppItem
onOpen?: (itemId: string) => void
}
export function AppCard({ item, onOpen }: AppCardProps) {
return (
<a
className="app-card"
href={item.href}
title={item.name}
onClick={(event) => {
if (onOpen === undefined) return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
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>
<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>
</a>
)
}
@@ -0,0 +1,24 @@
import type { AppItem } from '../../data/types'
import { AppCard } from './AppCard'
/**
* 应用网格:默认 4 列,窄屏退化为 2 列。
* 首页预览与明细页共用,靠 modifier 调整间距。
*/
type AppGridProps = {
items: AppItem[]
/** 明细页使用更宽松的间距 */
variant?: 'home' | 'detail'
/** 点击卡片打开条目详情 */
onOpen?: (itemId: string) => void
}
export function AppGrid({ items, variant = 'home', onOpen }: AppGridProps) {
return (
<div className={`app-grid${variant === 'detail' ? ' app-grid--detail' : ''}`}>
{items.map((item) => (
<AppCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
)
}
@@ -0,0 +1,36 @@
import type { ArticleItem } from '../../data/types'
/**
* 首页图文卡:4:3 封面 + 底部渐变条上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type ArticleCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
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>
</>
)
if (onOpen === undefined) {
return <figure className="article-card">{content}</figure>
}
return (
<button
type="button"
className="article-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
{content}
</button>
)
}
@@ -0,0 +1,79 @@
import type { ArticleItem } from '../../data/types'
import { ArrowLeftIcon } from '../common/Icon'
/**
* 文章详情页(对应模板图二):
* 大标题 + [编辑] + 元信息行 + 白色正文卡(段落 / 小标题 / 配图混排)。
*
* 正文是 block 数组,后端以后可以直接把富文本结构映射过来,
* 不用再改渲染层。
*/
type ArticleDetailViewProps = {
article: ArticleItem
categoryTitle: string
onBack: () => void
}
export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDetailViewProps) {
const meta = article.meta ?? ['2 年前', categoryTitle]
return (
<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>
</div>
<header className="article-head">
<h2 className="article-heading">
{article.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
[编辑]
</button>
</h2>
<div className="article-meta">
{meta.map((text, index) => (
<span className="article-meta-item" key={text}>
{index > 0 && <i className="article-meta-dot" aria-hidden="true" />}
{text}
</span>
))}
</div>
</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>
)
})}
</article>
</div>
)
}
@@ -0,0 +1,19 @@
import type { ArticleItem } from '../../data/types'
import { ArticleCard } from './ArticleCard'
/** 图文网格:首页 4 列,窄屏 2 列 */
type ArticleGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function ArticleGrid({ items, onOpen }: ArticleGridProps) {
return (
<div className="article-grid">
{items.map((item) => (
<ArticleCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
)
}
@@ -0,0 +1,37 @@
import type { ArticleItem } from '../../data/types'
/**
* 明细页图文卡:上图下文的白卡。
* 传入 onOpen 时整张卡变成按钮,点击进入文章详情。
*/
type CesCardProps = {
item: ArticleItem
onOpen?: (itemId: string) => void
}
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>
</>
)
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>
)
}
@@ -0,0 +1,19 @@
import type { ArticleItem } from '../../data/types'
import { CesCard } from './CesCard'
/** 明细页图文网格:4 列白卡,窄屏 2 列 */
type CesCardGridProps = {
items: ArticleItem[]
/** 点击卡片打开文章详情 */
onOpen?: (itemId: string) => void
}
export function CesCardGrid({ items, onOpen }: CesCardGridProps) {
return (
<div className="ces-grid">
{items.map((item) => (
<CesCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
)
}
@@ -0,0 +1,88 @@
import { categoryCount, type Category } from '../../data/types'
import { ArrowLeftIcon } from '../common/Icon'
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
onBack: () => void
/** 点击卡片 / 海报进入条目详情 */
onOpenItem: (itemId: string) => void
}
export function DetailView({ category, groupId, onBack, onOpenItem }: DetailViewProps) {
const groups = category.kind === 'apps' ? category.groups : []
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 breadcrumb =
activeGroup !== undefined
? `首页 / ${category.title} / ${activeGroup.name}`
: `首页 / ${category.title}`
function renderBody() {
if (category.kind === 'posters') {
return <PosterGrid items={category.items} variant="detail" onOpen={onOpenItem} />
}
if (category.kind === 'articles') {
return <CesCardGrid items={category.items} onOpen={onOpenItem} />
}
if (activeGroup !== undefined) {
return <AppGrid items={activeGroup.items} variant="detail" onOpen={onOpenItem} />
}
if (category.groups.length === 1) {
return <AppGrid items={category.groups[0].items} variant="detail" onOpen={onOpenItem} />
}
if (category.groups.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
}
return (
<>
{category.groups.map((group) => (
<GroupBlock key={group.id} group={group} onOpen={onOpenItem} />
))}
</>
)
}
return (
<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>
</div>
<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>
</div>
{renderBody()}
</div>
)
}
@@ -0,0 +1,20 @@
import type { AppGroup } from '../../data/types'
import { AppGrid } from './AppGrid'
/**
* 明细页的分组块:小标题 + 该组的应用网格。
* 分类被拆成多个分组时重复使用,单个分组时明细页不渲染标题。
*/
type GroupBlockProps = {
group: AppGroup
onOpen?: (itemId: string) => void
}
export function GroupBlock({ group, onOpen }: GroupBlockProps) {
return (
<section className="group-block">
<h3 className="group-title">{group.name}</h3>
<AppGrid items={group.items} variant="detail" onOpen={onOpen} />
</section>
)
}
@@ -0,0 +1,67 @@
import { categoryCount, flattenAppItems, type Category } from '../../data/types'
import { AppGrid } from './AppGrid'
import { ArticleGrid } from './ArticleGrid'
import { PosterGrid } from './PosterGrid'
import { SectionCard } from './SectionCard'
/**
* 首页:把每个分类渲染成一张分区卡片。
*
* 每个分区只展示前若干条(由分类的 previewLimit 决定),
* 右上角 ••• 进入该分类明细,点击卡片 / 海报则进入该条目的详情。
*/
type HomeViewProps = {
categories: Category[]
onOpenDetail: (categoryId: string) => void
onOpenItem: (categoryId: string, itemId: string) => void
}
export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps) {
return (
<div className="home-view">
{categories.map((category) => (
<SectionCard
key={category.id}
title={category.title}
count={categoryCount(category)}
barColor={category.barColor}
onMore={() => onOpenDetail(category.id)}
>
{renderPreview(category, onOpenItem)}
</SectionCard>
))}
</div>
)
}
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
function renderPreview(
category: Category,
onOpenItem: (categoryId: string, itemId: string) => void,
) {
if (category.kind === 'apps') {
const items = flattenAppItems(category).slice(0, category.previewLimit ?? 4)
if (items.length === 0) {
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
}
return <AppGrid items={items} onOpen={(itemId) => onOpenItem(category.id, itemId)} />
}
if (category.kind === 'posters') {
return (
<PosterGrid
items={category.items.slice(0, category.previewLimit ?? 6)}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
return (
<ArticleGrid
items={category.items.slice(0, category.previewLimit ?? 4)}
onOpen={(itemId) => onOpenItem(category.id, itemId)}
/>
)
}
@@ -0,0 +1,122 @@
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'
/**
* 条目详情页:应用与电影共用。
*
* 版式:标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
breadcrumb: string
model: ItemDetailModel
related: RelatedItem[]
onBack: () => void
onOpenItem: (itemId: string) => void
}
export function ItemDetailView({
breadcrumb,
model,
related,
onBack,
onOpenItem,
}: ItemDetailViewProps) {
return (
<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>
</div>
<article className="item-card card-box">
<h2 className="item-title">
{model.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
[编辑]
</button>
</h2>
<div className={`item-cover item-cover--${model.shape}`}>
{model.cover !== undefined ? (
<img src={model.cover} alt={model.title} loading="lazy" />
) : (
<span className="item-cover-letter">{model.fallbackIcon ?? '•'}</span>
)}
</div>
{model.tags.length > 0 && (
<div className="item-tags">
{model.tags.map((tag) => (
<span className="item-tag" key={tag}>
{tag}
</span>
))}
</div>
)}
{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>
)}
{model.paragraphs.map((paragraph, index) => (
<p className="item-desc" key={`${index}-${paragraph.slice(0, 8)}`}>
{paragraph}
</p>
))}
<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>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
{model.href === '#' ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址">
跳转到
<ExternalIcon />
</button>
) : (
<a className="item-jump" href={model.href} target="_blank" rel="noreferrer">
跳转到
<ExternalIcon />
</a>
)}
</div>
{model.banner !== undefined && (
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
)}
</article>
<RelatedList items={related} onOpen={onOpenItem} />
</div>
)
}
@@ -0,0 +1,36 @@
import type { PosterItem } from '../../data/types'
/**
* 竖版海报卡:2:3 封面 + 底部渐变遮罩上的标题。
* 传入 onOpen 时整张卡变成按钮,点击进入该电影的详情页。
*/
type PosterCardProps = {
item: PosterItem
onOpen?: (itemId: string) => void
}
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>
</>
)
if (onOpen === undefined) {
return <figure className="poster-card">{content}</figure>
}
return (
<button
type="button"
className="poster-card"
title={item.title}
onClick={() => onOpen(item.id)}
>
{content}
</button>
)
}
@@ -0,0 +1,20 @@
import type { PosterItem } from '../../data/types'
import { PosterCard } from './PosterCard'
/** 海报网格:首页 6 列,明细页同样 6 列但间距更大 */
type PosterGridProps = {
items: PosterItem[]
variant?: 'home' | 'detail'
/** 点击海报打开条目详情 */
onOpen?: (itemId: string) => void
}
export function PosterGrid({ items, variant = 'home', onOpen }: PosterGridProps) {
return (
<div className={`poster-grid${variant === 'detail' ? ' poster-grid--detail' : ''}`}>
{items.map((item) => (
<PosterCard key={item.id} item={item} onOpen={onOpen} />
))}
</div>
)
}
@@ -0,0 +1,45 @@
import type { RelatedItem } from '../../data/types'
/**
* 详情页的「推荐阅读」:两列小卡片,头像 + 名称 + 截断描述。
* 点击任意一条会在同一视图内切换到对应条目的详情。
*/
type RelatedListProps = {
items: RelatedItem[]
onOpen: (itemId: string) => void
}
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>
推荐阅读
</h3>
<ul className="related-grid">
{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>
))}
</ul>
</section>
)
}
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { CountBadge } from '../common/CountBadge'
import { MoreIcon } from '../common/Icon'
/**
* 首页分区卡片:白底圆角容器,组成「色条 + 标题 + 角标 + 更多按钮 + 内容」。
* 内容由调用方传入,因此同一容器可以承载应用网格、海报网格或图文网格。
*/
type SectionCardProps = {
title: string
count: number
/** 标题左侧色条颜色,不同分类用不同颜色区分 */
barColor?: string
/** 点击右上角 ••• 进入该分类的明细视图 */
onMore?: () => void
children: ReactNode
}
export function SectionCard({ title, count, barColor, onMore, children }: SectionCardProps) {
return (
<section className="section-card">
<header className="section-header">
<div className="section-title-wrapper">
<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>
{onMore !== undefined && (
<button
type="button"
className="section-more"
title={`查看${title}全部`}
aria-label={`查看${title}全部`}
onClick={onMore}
>
<MoreIcon />
</button>
)}
</header>
{children}
</section>
)
}