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
+1099 -386
View File
File diff suppressed because it is too large Load Diff
+205 -97
View File
@@ -1,119 +1,178 @@
import { useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/BackToTop'
import { SearchBar } from './components/SearchBar'
import { Sidebar } from './components/Sidebar'
import { SiteCard } from './components/SiteCard'
import { TopBar } from './components/TopBar'
import { MOCK_SECTIONS, type CatalogCard, type CatalogSection } from './catalog'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { DetailView } from './components/content/DetailView'
import { HomeView } from './components/content/HomeView'
import { ItemDetailView } from './components/content/ItemDetailView'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { Sidebar } from './components/sidebar/Sidebar'
import { buildCategories } from './data/catalog'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
} from './data/detail'
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
findAppItem,
findArticleItem,
findPosterItem,
relatedAppItems,
relatedPosterItems,
} from './data/types'
import { useProjects } from './useProjects'
import './App.css'
function initialOf(name: string): string {
const [first] = Array.from(name.trim())
return first ? first.toUpperCase() : '#'
}
/**
* 当前展示的视图:
* - home:首页分区卡片流
* - detail:某个分类(可指定分组)的明细
* - item:单个条目(应用或电影)的详情,returnTo 记录返回目标
*/
type View =
| { type: 'home' }
| { type: 'detail'; categoryId: string; groupId?: string }
| { type: 'item'; categoryId: string; itemId: string; returnTo: View }
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const [keyword, setKeyword] = useState('')
const [view, setView] = useState<View>({ type: 'home' })
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// 真实项目放最前面,占位分类跟在后面
const sections = useMemo<CatalogSection[]>(() => {
const localCards: CatalogCard[] = projects.map((project) => ({
id: project.route_path,
title: project.name,
desc: project.route_path,
href: project.route_path,
initial: initialOf(project.name),
mono: true,
}))
const categories = useMemo(() => buildCategories(projects), [projects])
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
}, [projects])
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
const query = keyword.trim().toLowerCase()
const searching = query !== ''
const detailCategory =
detailView !== undefined
? categories.find((category) => category.id === detailView.categoryId)
: undefined
// 搜索时过滤卡片,并丢掉没有命中的分区
const sectionsToRender = useMemo(() => {
if (!searching) return sections
const itemCategory =
itemView !== undefined
? categories.find((category) => category.id === itemView.categoryId)
: undefined
return sections
.map((section) => ({
...section,
cards: section.cards.filter(
(card) =>
card.title.toLowerCase().includes(query) ||
card.desc.toLowerCase().includes(query),
),
}))
.filter((section) => section.cards.length > 0)
}, [sections, searching, query])
// 应用与电影共用一个详情模型,这里按分类类型做映射
const detailModel = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return undefined
const noMatch = searching && sectionsToRender.length === 0
if (itemCategory.kind === 'apps') {
const item = findAppItem(itemCategory, itemView.itemId)
return item !== undefined ? appDetailModel(item) : undefined
}
if (itemCategory.kind === 'posters') {
const poster = findPosterItem(itemCategory, itemView.itemId)
return poster !== undefined ? posterDetailModel(poster) : undefined
}
return undefined
}, [itemView, itemCategory])
const relatedItems = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return []
if (itemCategory.kind === 'apps') {
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
}
if (itemCategory.kind === 'posters') {
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
}
return []
}, [itemView, itemCategory])
// 切换视图时把内容区滚回顶部
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 })
}, [view])
// 桌面端滚动作在内容容器里,窄屏滚的是窗口:
// 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
useEffect(() => {
const sync = () => {
const inner = scrollRef.current?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
window.addEventListener('scroll', sync, { passive: true })
return () => window.removeEventListener('scroll', sync)
}, [])
const openDetail = (categoryId: string) => setView({ type: 'detail', categoryId })
const openItem = (categoryId: string, itemId: string, returnTo: View) =>
setView({ type: 'item', categoryId, itemId, returnTo })
const navigate = (target: NavTarget) =>
setView({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
const goHome = () => setView({ type: 'home' })
const searching = keyword.trim() !== ''
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
*/
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <p className="section-empty">分类不存在</p>
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <p className="section-empty">条目不存在</p>
}
function renderLocalPlaceholder() {
if (status === 'error') {
return (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<p>
请确认后端已启动(默认 <code>127.0.0.1:8080</code>),开发时 Vite 会把{' '}
<code>/api</code> 代理到后端。
</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
<ArticleDetailView
article={article}
categoryTitle={itemCategory.title}
onBack={() => setView(itemView.returnTo)}
/>
)
}
if (status === 'loading') {
return (
<div className="notice">
<strong>正在读取项目列表…</strong>
<p>首次加载会扫描一次托管目录。</p>
</div>
)
if (detailModel === undefined) {
return <p className="section-empty">条目不存在</p>
}
return (
<div className="notice">
<strong>这里还没有项目</strong>
<p>
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
</p>
</div>
)
}
function renderSection(section: CatalogSection) {
return (
<section className="section-group" id={section.id} key={section.id}>
<div className="section-heading">
<h2 className="section-title">{section.title}</h2>
<span className="section-badge">{section.cards.length}</span>
</div>
{section.cards.length > 0 ? (
<div className="grid">
{section.cards.map((card, index) => (
<SiteCard key={card.id} card={card} index={index} />
))}
</div>
) : (
renderLocalPlaceholder()
)}
</section>
<ItemDetailView
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
model={detailModel}
related={relatedItems}
onBack={() => setView(itemView.returnTo)}
onOpenItem={(itemId) => setView({ ...itemView, itemId })}
/>
)
}
return (
<div className="app">
<TopBar
projects={projects}
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
@@ -128,25 +187,74 @@ export default function App() {
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={detailView?.categoryId ?? itemView?.categoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
<main className="content">
<div className="content-scroll" id="content-scroll">
{/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<div
className="content-scroll"
id="content-scroll"
ref={scrollRef}
onScroll={(event) => {
const next = event.currentTarget.scrollTop > 4
setScrolled((current) => (current === next ? current : next))
}}
>
<h1 className="sr-only">Stash 本地应用工作区</h1>
{sectionsToRender.map(renderSection)}
{noMatch && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
{status === 'error' && (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
)}
{detailView === undefined && itemView === undefined && (
<>
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={(categoryId, itemId) =>
openItem(categoryId, itemId, { type: 'home' })
}
/>
{searching && visibleCategories.length === 0 && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
</div>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onBack={goHome}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId, detailView)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<p className="section-empty">分类不存在</p>
)}
{itemView !== undefined && renderItemView()}
</div>
</main>
</div>
<BackToTop />
<BackToTop visible={scrolled} />
</div>
)
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

-130
View File
@@ -1,130 +0,0 @@
/**
* 占位数据:沿用 index9 设计稿里的分类与卡片。
*
* 后端目前只提供扁平的项目列表,没有分类概念,
* 所以这里的内容仅用于撑起界面结构,等接口支持分类后再替换。
*/
export type CatalogCard = {
id: string
title: string
desc: string
href: string
/** 卡片左侧的字符标记 */
initial: string
/** desc 是路径时用等宽字体 */
mono?: boolean
}
export type CatalogSection = {
id: string
title: string
cards: CatalogCard[]
}
export type NavLink = {
id: string
label: string
}
export type NavGroup = {
id: string
label: string
links: NavLink[]
}
/** 侧栏顶层链接 */
export const MOCK_NAV_PRIMARY: NavLink[] = [{ id: 'common', label: '常用推荐' }]
/** 侧栏可折叠分组 */
export const MOCK_NAV_GROUPS: NavGroup[] = [
{
id: 'assets',
label: '素材资源',
links: [
{ id: 'design', label: 'UI 设计资源' },
{ id: 'icon', label: '矢量图标' },
{ id: 'photo', label: '免费壁纸' },
],
},
{
id: 'tools',
label: '开发工具',
links: [
{ id: 'dev', label: '前端调试工具' },
{ id: 'code', label: '在线编辑器' },
],
},
]
/** 内容区占位分区,顺序与设计稿一致 */
export const MOCK_SECTIONS: CatalogSection[] = [
{
id: 'common',
title: '常用推荐',
cards: [
{
id: 'github',
title: 'GitHub',
desc: '代码托管与开源社区',
href: '#',
initial: 'G',
},
{ id: 'zhihu', title: '知乎', desc: '深度内容问答社区', href: '#', initial: 'Z' },
{
id: 'bilibili',
title: '哔哩哔哩',
desc: '弹幕视频分享网站',
href: '#',
initial: 'B',
},
{
id: 'v2ex',
title: 'V2EX',
desc: '创意工作者讨论社区',
href: '#',
initial: 'V',
},
],
},
{
id: 'dev',
title: '前端调试工具',
cards: [
{
id: 'stackoverflow',
title: 'Stack Overflow',
desc: '程序员技术问答',
href: '#',
initial: 'S',
},
{ id: 'juejin', title: '掘金', desc: '开发者技术社区', href: '#', initial: 'J' },
],
},
{
id: 'code',
title: '在线编辑器',
cards: [
{
id: 'codepen',
title: 'CodePen',
desc: '前端在线代码演练场',
href: '#',
initial: 'C',
},
],
},
{
id: 'design',
title: 'UI 设计资源',
cards: [
{
id: 'dribbble',
title: 'Dribbble',
desc: '全球设计作品分享社区',
href: '#',
initial: 'D',
},
],
},
]
-22
View File
@@ -1,22 +0,0 @@
export function BackToTop() {
const handleClick = () => {
// 桌面端滚动的是内容容器,窄屏滚动的是窗口
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<button
type="button"
className="back-to-top"
title="回到顶部"
aria-label="回到顶部"
onClick={handleClick}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<line x1="5" y1="4" x2="19" y2="4" />
<path d="M12 20V8M6 14l6-6 6 6" />
</svg>
</button>
)
}
-84
View File
@@ -1,84 +0,0 @@
import { useState } from 'react'
import type { Project } from '../api'
import { MOCK_NAV_GROUPS, MOCK_NAV_PRIMARY } from '../catalog'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
type SidebarProps = {
projects: Project[]
live: boolean
refreshing: boolean
onRefresh: () => void
}
export function Sidebar({ projects, live, refreshing, onRefresh }: SidebarProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const [activeId, setActiveId] = useState('common')
const toggle = (id: string) => {
setOpenGroups((previous) =>
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
)
}
const isOpen = (id: string) => openGroups.includes(id)
return (
<aside className="sidebar">
<ProfileCard />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{MOCK_NAV_PRIMARY.map((link) => (
<li className="nav-item" key={link.id}>
<a
className={`nav-link${activeId === link.id ? ' active' : ''}`}
href={`#${link.id}`}
onClick={() => setActiveId(link.id)}
>
<span>{link.label}</span>
</a>
</li>
))}
{MOCK_NAV_GROUPS.map((group) => (
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
<button
type="button"
className="nav-link"
aria-expanded={isOpen(group.id)}
onClick={() => toggle(group.id)}
>
<span>{group.label}</span>
<span className="nav-arrow" aria-hidden="true">
›
</span>
</button>
<ul className="submenu">
{group.links.map((link) => (
<li key={link.id}>
<a
className={`nav-link${activeId === link.id ? ' active' : ''}`}
href={`#${link.id}`}
onClick={() => setActiveId(link.id)}
>
<span>{link.label}</span>
</a>
</li>
))}
</ul>
</li>
))}
</ul>
</nav>
</aside>
)
}
-26
View File
@@ -1,26 +0,0 @@
import type { CatalogCard } from '../catalog'
type SiteCardProps = {
card: CatalogCard
index: number
}
export function SiteCard({ card, index }: SiteCardProps) {
return (
<a
className="card"
href={card.href}
title={card.title}
// 轻微错峰入场,最多延迟 12 个卡片
style={{ animationDelay: `${Math.min(index, 12) * 24}ms` }}
>
<span className="card-icon" aria-hidden="true">
{card.initial}
</span>
<span className="card-body">
<span className="card-title">{card.title}</span>
<span className={`card-desc${card.mono ? ' is-mono' : ''}`}>{card.desc}</span>
</span>
</a>
)
}
-89
View File
@@ -1,89 +0,0 @@
import { useMemo } from 'react'
import type { Project } from '../api'
import { LoginIcon, SearchIcon } from './icons'
type TopBarProps = {
projects: Project[]
live: boolean
onFocusSearch: () => void
}
type TickerItem = {
tag: string
text: string
}
export function TopBar({ projects, live, onFocusSearch }: TopBarProps) {
return (
<header className="topbar">
<div className="topbar-inner">
<a className="brand" href="/" aria-label="Stash 首页">
<span className="brand-mark" aria-hidden="true">
S
</span>
<span className="brand-name">STASH</span>
</a>
<Ticker projects={projects} live={live} />
<div className="top-actions">
<button
type="button"
className="icon-button"
title="搜索(Ctrl+K)"
aria-label="聚焦搜索框"
onClick={onFocusSearch}
>
<SearchIcon />
</button>
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
<button type="button" className="icon-button" title="登录" aria-label="登录">
<LoginIcon />
</button>
</div>
</div>
</header>
)
}
/** 顶栏跑马灯:展示连接状态、项目数量与前几个项目名。 */
function Ticker({ projects, live }: { projects: Project[]; live: boolean }) {
const items = useMemo(() => buildTickerItems(projects, live), [projects, live])
// 复制一份实现无缝循环,动画位移为 -50%
const loop = [...items, ...items]
return (
<div className="ticker" aria-hidden="true">
<div className="ticker-track">
{loop.map((item, index) => (
<span className="ticker-item" key={`${item.tag}-${item.text}-${index}`}>
<span className="ticker-tag">{item.tag}</span>
{item.text}
</span>
))}
</div>
</div>
)
}
function buildTickerItems(projects: Project[], live: boolean): TickerItem[] {
const items: TickerItem[] = [
{ tag: '状态', text: live ? '实时同步已连接' : '等待服务连接' },
{ tag: '项目', text: `共 ${projects.length} 个本地应用` },
]
if (projects.length === 0) {
items.push({
tag: '提示',
text: '把静态站点放进托管目录,并保留 index.html,就会出现在这里',
})
return items
}
for (const project of projects.slice(0, 6)) {
items.push({ tag: '本地', text: project.name })
}
return items
}
@@ -0,0 +1,33 @@
import { ArrowUpIcon } from './Icon'
/**
* 回到顶部:复刻 Alright 主题的 .scroll-toolbar .totop-button。
*
* - 42px 圆形、浅灰底,固定在右侧(bottom: 50%)
* - 默认隐藏,只有内容区滚动后才淡入,回到顶部后再淡出
*/
type BackToTopProps = {
/** 内容区是否已滚动(由 App 传入) */
visible: boolean
}
export function BackToTop({ visible }: BackToTopProps) {
const handleClick = () => {
// 桌面端滚动的是内容容器,窄屏滚动的是窗口,两处都滚一遍
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<button
type="button"
className={`back-to-top${visible ? ' is-visible' : ''}`}
title="回到顶部"
aria-label="回到顶部"
tabIndex={visible ? 0 : -1}
onClick={handleClick}
>
<ArrowUpIcon />
</button>
)
}
@@ -0,0 +1,11 @@
/**
* 数量角标:贴在分区标题右上角的小红标。
* 纯展示组件,只接收数字,宽度由内容撑开(两位数不会变形)。
*/
type CountBadgeProps = {
count: number
}
export function CountBadge({ count }: CountBadgeProps) {
return <span className="count-badge">{count}</span>
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 通用图标组件。
*
* 全部使用 Alright 主题的 iconfont 线性图标(见 src/iconfont.css),
* 尺寸与颜色由使用处的 CSS 控制(font-size / color),
* 因此同一套图标可以复用在按钮、统计条、导航等不同位置。
*/
type IconProps = {
/** 附加类名,便于局部覆盖字号或颜色 */
className?: string
}
const base = (name: string, className?: string) =>
`iconfont icon-${name}${className !== undefined ? ` ${className}` : ''}`
export function SearchIcon({ className }: IconProps) {
return <i className={base('search', className)} aria-hidden="true" />
}
export function UserIcon({ className }: IconProps) {
return <i className={base('user', className)} aria-hidden="true" />
}
export function RefreshIcon({ className }: IconProps) {
return <i className={base('refresh', className)} aria-hidden="true" />
}
export function LinkIcon({ className }: IconProps) {
return <i className={base('link', className)} aria-hidden="true" />
}
export function ExternalIcon({ className }: IconProps) {
return <i className={base('external', className)} aria-hidden="true" />
}
export function EyeIcon({ className }: IconProps) {
return <i className={base('eye', className)} aria-hidden="true" />
}
export function ThumbUpIcon({ className }: IconProps) {
return <i className={base('thumb-up', className)} aria-hidden="true" />
}
export function ShareIcon({ className }: IconProps) {
return <i className={base('share', className)} aria-hidden="true" />
}
export function CloseIcon({ className }: IconProps) {
return <i className={base('close', className)} aria-hidden="true" />
}
export function MoreIcon({ className }: IconProps) {
return <i className={base('more', className)} aria-hidden="true" />
}
export function ArrowUpIcon({ className }: IconProps) {
return <i className={base('arrow-up', className)} aria-hidden="true" />
}
export function ArrowLeftIcon({ className }: IconProps) {
return <i className={base('arrow-left', className)} aria-hidden="true" />
}
export function ArrowDownIcon({ className }: IconProps) {
return <i className={base('arrow-down', className)} aria-hidden="true" />
}
@@ -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>
)
}
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
/**
* 顶栏中间的一条信息,形如「[标签] 文本」。
* 标签带浅色底,用于区分「本地 / 状态」这类类型前缀。
*/
type HeaderInfoItemProps = {
/** 可选的前缀标签 */
tag?: string
/** 标签配色:默认蓝色,状态类用绿色 */
tone?: 'blue' | 'green'
children: ReactNode
}
export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
return (
<div className="header-info-item">
{tag !== undefined && (
<span className={`header-info-tag${tone === 'green' ? ' is-green' : ''}`}>{tag}</span>
)}
<span className="header-info-text">{children}</span>
</div>
)
}
@@ -0,0 +1,58 @@
import { SearchIcon, UserIcon } from '../common/Icon'
import { HeaderInfoItem } from './HeaderInfoItem'
/**
* 顶部栏:左品牌、中间运行信息、右侧操作图标。
* 信息区展示「共 N 个本地应用 / 第一个项目 / 连接状态」,
* 对应模板里的 header-center-info。
*/
type SiteHeaderProps = {
projectCount: number
/** 用于展示的第一个项目名,没有项目时留空 */
sampleProject?: string
live: boolean
onFocusSearch: () => void
}
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
return (
<header className="site-header">
<div className="layout-container site-header-inner">
<a className="site-brand" href="/" aria-label="Stash 首页">
<span className="site-brand-mark" aria-hidden="true">
S
</span>
<span className="site-brand-name">STASH</span>
</a>
<div className="header-info">
<HeaderInfoItem>共 {projectCount} 个本地应用</HeaderInfoItem>
{sampleProject !== undefined && (
<HeaderInfoItem tag="本地">{sampleProject}</HeaderInfoItem>
)}
<HeaderInfoItem tag="状态" tone={live ? 'green' : 'blue'}>
{live ? '实时同步已连接' : '等待服务连接'}
</HeaderInfoItem>
</div>
<div className="header-actions">
<button
type="button"
className="header-icon-button"
title="搜索(Ctrl+K)"
aria-label="聚焦搜索框"
onClick={onFocusSearch}
>
<SearchIcon />
</button>
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
<button type="button" className="header-icon-button" title="登录" aria-label="登录">
<UserIcon />
</button>
</div>
</div>
</header>
)
}
-37
View File
@@ -1,37 +0,0 @@
/** 统一的小图标集合:只负责路径,尺寸与颜色由使用处的 CSS 决定。 */
export function RefreshIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
)
}
export function SearchIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
)
}
export function LinkIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5" />
<path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5" />
</svg>
)
}
export function LoginIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="8" r="3.6" />
<path d="M4.8 20a7.2 7.2 0 0 1 14.4 0" />
</svg>
)
}
@@ -1,5 +1,10 @@
import { useEffect, type RefObject } from 'react'
import { CloseIcon, SearchIcon } from '../common/Icon'
/**
* 全局搜索框:固定悬浮在顶栏下方居中。
* 支持 Ctrl/Cmd+K 聚焦、Esc 清空,输入内容由父组件接管过滤逻辑。
*/
type SearchBarProps = {
value: string
onChange: (value: string) => void
@@ -22,10 +27,7 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
return (
<div className="search-bar card-box">
<svg className="search-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<SearchIcon className="search-icon" />
<input
ref={inputRef}
@@ -48,9 +50,7 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
aria-label="清空搜索"
onClick={() => onChange('')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
<CloseIcon />
</button>
)}
</div>
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { NAV_GROUPS, NAV_TOP_LEVEL, type NavTarget } from '../../data/nav'
/**
* 分类导航树:顶层直达链接 + 可折叠分组。
* 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给父组件的 onNavigate,
* 由父组件切换右侧的明细视图。
*/
type CategoryNavProps = {
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
onNavigate: (target: NavTarget) => void
}
export function CategoryNav({ activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const toggle = (id: string) => {
setOpenGroups((previous) =>
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
)
}
const isOpen = (id: string) => openGroups.includes(id)
return (
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{NAV_TOP_LEVEL.map((leaf) => (
<li className="nav-item" key={leaf.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === leaf.target.categoryId && activeGroupId === undefined
? ' active'
: ''
}`}
onClick={() => onNavigate(leaf.target)}
>
<span>{leaf.label}</span>
</button>
</li>
))}
{NAV_GROUPS.map((group) => (
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(group.id)}
onClick={() => toggle(group.id)}
>
<span>{group.label}</span>
</button>
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(group.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{group.leaves.map((leaf) => (
<li key={leaf.id}>
<button
type="button"
className={`nav-link${activeGroupId === leaf.target.groupId ? ' active' : ''}`}
onClick={() => onNavigate(leaf.target)}
>
<span>{leaf.label}</span>
</button>
</li>
))}
</ul>
</div>
</li>
))}
</ul>
</nav>
)
}
@@ -1,16 +1,17 @@
import { PLACEHOLDER_PROFILE } from '../profile'
import { PLACEHOLDER_PROFILE } from '../../profile'
import { UserIcon } from '../common/Icon'
/** 登录后展示的用户资料卡(数据为占位,见 profile.ts)。 */
/**
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
* 数据来自 profile.ts 的占位对象,后端接入鉴权后替换。
*/
export function ProfileCard() {
const { name, role, bio, location } = PLACEHOLDER_PROFILE
return (
<section className="profile-card card-box" aria-label="用户信息">
<div className="profile-avatar" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="9" r="3.4" />
<path d="M5.6 19.4a6.6 6.6 0 0 1 12.8 0" />
</svg>
<UserIcon />
<span className="profile-badge">✓</span>
</div>
@@ -1,6 +1,10 @@
import type { Project } from '../api'
import { LinkIcon, RefreshIcon } from './icons'
import type { Project } from '../../api'
import { LinkIcon, RefreshIcon } from '../common/Icon'
/**
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
* 只读展示 + 触发刷新,不负责导航。
*/
type ServicePanelProps = {
projects: Project[]
live: boolean
@@ -8,10 +12,6 @@ type ServicePanelProps = {
onRefresh: () => void
}
/**
* 本地服务面板:状态概览 + 操作入口。
* 项目列表本身在右侧内容区以卡片呈现,这里不再展开。
*/
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
const host = typeof window === 'undefined' ? '-' : window.location.host
@@ -0,0 +1,48 @@
import type { Project } from '../../api'
import type { NavTarget } from '../../data/nav'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
/**
* 左侧栏容器:用户资料 + 本地服务面板 + 分类导航。
* 只负责布局与数据透传,具体内容由三个子组件分别渲染。
*/
type SidebarProps = {
projects: Project[]
live: boolean
refreshing: boolean
onRefresh: () => void
activeCategoryId?: string
activeGroupId?: string
onNavigate: (target: NavTarget) => void
}
export function Sidebar({
projects,
live,
refreshing,
onRefresh,
activeCategoryId,
activeGroupId,
onNavigate,
}: SidebarProps) {
return (
<aside className="sidebar">
<ProfileCard />
<ServicePanel
projects={projects}
live={live}
refreshing={refreshing}
onRefresh={onRefresh}
/>
<CategoryNav
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
onNavigate={onNavigate}
/>
</aside>
)
}
+267
View File
@@ -0,0 +1,267 @@
import type {
AppCategory,
AppGroup,
AppItem,
ArticleBlock,
ArticleCategory,
Category,
PosterCategory,
} from './types'
/**
* 占位内容:沿用 index4.html 模板里的示例数据。
*
* 除「本地服务」由后端项目列表动态生成外,其余分类都是静态占位,
* 等后端提供分类 / 收藏接口后再替换。
*/
/**
* 详情页占位统计:按 id 生成稳定的数值,
* 保证同一站点每次打开看到的是同一组数字,而不是随机跳变。
*/
function stat(seed: string, min: number, max: number): number {
let hash = 0
for (const char of seed) {
hash = (hash * 31 + char.charCodeAt(0)) % 100_000
}
return min + (hash % (max - min + 1))
}
/** 只有少数条目带 HOT 徽标 —— 主题里也不是每个作者都带 */
const HOT_IDS = new Set(['github', 'bilibili', 'wallhaven'])
/** 生成一条占位站点,详情页需要的头像 / 横幅 / 评价也一并补齐 */
const app = (id: string, name: string, desc: string, icon: string, href = '#'): AppItem => ({
id,
name,
desc,
icon,
href,
cover: `https://picsum.photos/seed/${id}/200/200`,
banner: `https://picsum.photos/seed/${id}-banner/800/300`,
comment: '收录在本地导航里的常用站点,点开就能直达。',
badge: '推荐好站',
hot: HOT_IDS.has(id),
views: stat(id, 60, 980),
likes: stat(`${id}-like`, 3, 88),
})
/** 常用推荐:第一个分组是首页预览展示的四项 */
const RECOMMEND_GROUPS: AppGroup[] = [
{
id: 'common',
name: '常用站点',
items: [
app('github', 'GitHub', '代码托管与开源社区', 'G'),
app('zhihu', '知乎', '深度内容问答社区', 'Z'),
app('bilibili', '哔哩哔哩', '弹幕视频分享网站', 'B'),
app('v2ex', 'V2EX', '创意工作者讨论社区', 'V'),
],
},
{
id: 'ui',
name: 'UI 设计资源',
items: [
app('wallhaven', 'Wallhaven', 'WallBase 创办人失踪后推出的精选壁纸站', 'W'),
app('500px', '500px', '有着世界上最好的照片分享社区', '5'),
app('chuangyiren', '创意人', '去掉了形式主义描述的原创社区', 'C'),
app('topys', '顶尖文案', '全球顶尖创意分享平台 OPEN YOUR EYES', 'T'),
app('uichina', 'UI中国', '前身为 iconfans,国内专业的 UI 设计平台', 'U'),
],
},
{
id: 'vector',
name: '矢量图标',
items: [
app('iconfont', 'Iconfont', '阿里妈妈矢量图标库', 'I'),
app('feather', 'Feather Icons', '简单漂亮的开源图标库', 'F'),
app('fontawesome', 'FontAwesome', '图标字体与 CSS 框架', 'A'),
],
},
{
id: 'wallpaper',
name: '免费壁纸',
items: [
app('unsplash', 'Unsplash', '免费高清无版权摄影图片', 'U'),
app('pexels', 'Pexels', '才华横溢的摄影师分享的素材', 'P'),
app('pixabay', 'Pixabay', '超过 2.7 百万张优质图片和视频', 'X'),
],
},
{
id: 'dev',
name: '开发工具',
items: [
app('codepen', 'CodePen', '前端在线代码演示和练习场', 'C'),
app('stackoverflow', 'Stack Overflow', '全球程序员技术问答社区', 'S'),
app('github-dev', 'GitHub', '代码托管与开源协作社区', 'G'),
],
},
]
const RECOMMEND: AppCategory = {
id: 'recommend',
kind: 'apps',
title: '常用推荐',
barColor: '#3858f6',
icon: '🔖',
groups: RECOMMEND_GROUPS,
}
/** 电影占位数据:标题 + 一句简介,简介同时用作详情页正文 */
const MOVIE_SEEDS: [string, string][] = [
[
'千与千寻 千と千尋の神隠し (2001)',
'少女千寻误入神灵世界,为救回变成猪的父母,在汤屋打工并逐渐学会担当。',
],
[
'美丽人生 La vita è bella (1997)',
'二战集中营里,父亲用一场「游戏」保护儿子,让他相信世界仍然美好。',
],
[
'这个杀手不太冷 Léon (1994)',
'孤独的职业杀手莱昂收留了邻居少女玛蒂尔达,两个边缘人相互依靠。',
],
[
'阿甘正传 Forrest Gump (1994)',
'智商不高的阿甘凭着一股单纯的执着,意外见证并参与了几十年美国历史。',
],
['霸王别姬 (1993)', '两位京剧伶人半个世纪的悲欢离合,与时代洪流交织在一起。'],
[
'肖申克的救赎 The Shawshank Redemption',
'蒙冤入狱的银行家安迪,在漫长牢狱中守住希望并完成自救。',
],
[
'泰坦尼克号 Titanic (1997)',
'沉船之夜,来自不同阶层的杰克与露丝相爱,也被人性的选择所考验。',
],
['盗梦空间 Inception (2010)', '盗梦团队潜入层层梦境,试图在目标的潜意识里植入一个念头。'],
[
'星际穿越 Interstellar (2014)',
'地球濒临枯竭,父亲穿越虫洞寻找新家园,与女儿跨越时间重逢。',
],
[
'楚门的世界 The Truman Show (1998)',
'楚门渐渐发现,自己的整个人生其实是一档被全世界围观的真人秀。',
],
['疯狂动物城 Zootopia (2016)', '兔子警官与狐狸搭档,在动物城里侦破一桩失踪案,也打破偏见。'],
[
'大话西游之大圣娶亲 (1995)',
'至尊宝在爱情与宿命之间反复抉择,最终戴上金箍踏上取经路。',
],
]
const MOVIES: PosterCategory = {
id: 'movie',
kind: 'posters',
title: '推荐电影',
barColor: '#ff6034',
icon: '🎬',
items: MOVIE_SEEDS.map(([title, summary], index) => {
const id = `movie-${index + 1}`
return {
id,
title,
cover: `https://picsum.photos/seed/m${index + 1}/300/450`,
tags: ['推荐电影', '豆瓣电影 Top250'],
comment: '收藏在本地片单里,闲下来就重看一遍。',
summary,
href: '#',
views: stat(id, 300, 1500),
likes: stat(`${id}-like`, 3, 60),
}
}),
}
const ARTICLE_SEEDS = [
['可塑性极强的玻璃,在设计师手里有了更多可能', '前沿科技'],
['废旧共享单车如何变成微型共享书屋?', '环保设计'],
['荧光灯管与混凝土块的组合,可以玩出多少花样?', '材料探索'],
['混凝土做的模块化收纳框,还能改成搁架用', '家居模块'],
['这个用拉伸材料做的头盔,还能帮你呼叫救护车', '智能穿戴'],
['你如何选择字体,简单易读还是好看有趣?', '视觉体验'],
['家里空间小?你需要几把可以悬挂在墙上的椅子', '空间收纳'],
['宜家厨房家具又被改造了,色彩是亮点', '色彩灵感'],
['这款诞生 50 年的扶手椅,推出了特别版配色', '经典产品'],
['让家和办公室界限变得模糊的一组家具', '办公家具'],
['这个酒店设计奖,挖掘过去一年体验最好的酒店', '空间建筑'],
['一款可移动办公桌,挂墙上当作白板就能写写画画', '移动办公'],
]
/** 生成演示用正文;接入真实数据后由后端提供 blocks */
function buildArticleBlocks(title: string, desc: string, cover: string): ArticleBlock[] {
return [
{
type: 'paragraph',
text: `开放与明亮的氛围,在室内空间中尤为注重。这条以「${desc}」为主题的报道,围绕材料、结构与使用场景展开,我们挑了几个值得一看的案例。`,
},
{ type: 'heading', text: '设计细节' },
{
type: 'paragraph',
text: `《${title}》之所以特别,不在于用了多昂贵的材料,而是把材料本身的特性放在了第一位:既没有沿用传统做法,也不是单纯的形式实验。`,
},
{
type: 'paragraph',
text: '在有限的空间里,它既保持了功能的完整性,也留出了呼吸感,适合日常使用。',
},
{ type: 'image', src: cover },
{
type: 'paragraph',
text: '文字与图片均为本地演示用的占位内容,实际报道接入后由后端提供。',
},
]
}
const CES: ArticleCategory = {
id: 'ces',
kind: 'articles',
title: 'CES 现场报道',
barColor: '#ffba3a',
icon: '🏷️',
items: ARTICLE_SEEDS.map(([title, desc], index) => {
const cover = `https://picsum.photos/seed/a${index + 1}/400/250`
return {
id: `ces-${index + 1}`,
title: `${title} | 这个设计了不起`,
desc,
cover,
meta: ['2 年前', 'CES 现场报道'],
blocks: buildArticleBlocks(title, desc, cover),
}
}),
}
/**
* 把后端项目列表转成「本地服务」分类。
* href 用真实路由,详情页的「跳转」按钮直接打开对应静态站点。
*/
function buildLocalCategory(items: AppItem[]): AppCategory {
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 buildCategories(projects: { name: string; route_path: string }[]): Category[] {
const localItems: AppItem[] = projects.map((project) => ({
id: project.route_path,
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,
}))
return [buildLocalCategory(localItems), RECOMMEND, MOVIES, CES]
}
+75
View File
@@ -0,0 +1,75 @@
import type { AppItem, PosterItem, RelatedItem } from './types'
/**
* 条目详情页的通用视图模型。
*
* 应用(方形头像 + 横幅)和电影(竖版海报 + 多标签)共用同一套版式,
* 差异通过 shape / tags / banner 这几个字段表达,避免维护两份几乎相同的页面。
*/
export type ItemDetailModel = {
title: string
/** 主图;缺失时用 fallbackIcon 显示字符块 */
cover?: string
/** 主图形态:应用用方形,电影用竖版海报 */
shape: 'square' | 'portrait'
/** 主图缺省时的字符 */
fallbackIcon?: string
/** 标签行(应用 1 个,电影 2 个) */
tags: string[]
/** 一句话评价 */
comment?: string
/** 正文段落 */
paragraphs: string[]
views: number
likes: number
href: string
/** 应用详情页的横幅图 */
banner?: string
}
/** 把应用条目转成详情模型 */
export function appDetailModel(item: AppItem): ItemDetailModel {
return {
title: item.name,
cover: item.cover,
shape: 'square',
fallbackIcon: item.icon,
tags: item.badge !== undefined ? [item.badge] : [],
comment: item.comment,
paragraphs: [item.desc],
views: item.views ?? 0,
likes: item.likes ?? 0,
href: item.href,
banner: item.banner,
}
}
/** 把电影海报转成详情模型 */
export function posterDetailModel(item: PosterItem): ItemDetailModel {
return {
title: item.title,
cover: item.cover,
shape: 'portrait',
tags: item.tags ?? [],
comment: item.comment,
paragraphs: item.summary !== undefined ? [item.summary] : [],
views: item.views ?? 0,
likes: item.likes ?? 0,
href: item.href ?? '#',
}
}
/** 应用条目 → 推荐阅读条目 */
export function toRelatedItem(item: AppItem): RelatedItem {
return { id: item.id, name: item.name, desc: item.desc, cover: item.cover, icon: item.icon }
}
/** 电影海报 → 推荐阅读条目 */
export function posterToRelatedItem(item: PosterItem): RelatedItem {
return {
id: item.id,
name: item.title,
desc: item.tags?.join(' · ') ?? '推荐电影',
cover: item.cover,
}
}
+49
View File
@@ -0,0 +1,49 @@
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))
}
+54
View File
@@ -0,0 +1,54 @@
/**
* 侧栏分类导航的数据定义。
*
* 叶子节点通过 target 指向「分类 + 可选分组」,点击后右侧切换到对应明细。
* 标签保持与现有侧栏一致,避免改动已定稿的侧栏。
*/
export type NavTarget = {
categoryId: string
/** 指定后明细页只展示该分组 */
groupId?: string
}
export type NavLeaf = {
id: string
label: string
target: NavTarget
}
export type NavGroupNode = {
id: string
label: string
leaves: NavLeaf[]
}
/** 顶层直达链接 */
export const NAV_TOP_LEVEL: NavLeaf[] = [
{ id: 'recommend', label: '常用推荐', target: { categoryId: 'recommend' } },
]
/** 可折叠分组 */
export const NAV_GROUPS: NavGroupNode[] = [
{
id: 'assets',
label: '素材资源',
leaves: [
{ id: 'ui', label: 'UI 设计资源', target: { categoryId: 'recommend', groupId: 'ui' } },
{ id: 'vector', label: '矢量图标', target: { categoryId: 'recommend', groupId: 'vector' } },
{
id: 'wallpaper',
label: '免费壁纸',
target: { categoryId: 'recommend', groupId: 'wallpaper' },
},
],
},
{
id: 'tools',
label: '开发工具',
leaves: [
{ id: 'dev', label: '前端调试工具', target: { categoryId: 'recommend', groupId: 'dev' } },
{ id: 'code', label: '在线编辑器', target: { categoryId: 'recommend', groupId: 'dev' } },
],
},
]
+190
View File
@@ -0,0 +1,190 @@
/**
* 内容数据模型。
*
* 首页把每个「分类」渲染成一张卡片,明细页按分类的 kind 渲染不同网格,
* 因此这里用可辨识联合把三种展示形态分开。
*/
/** 一个可点击的应用 / 站点条目 */
export type AppItem = {
id: string
name: string
desc: string
/** 卡片左侧的字符图标 */
icon: string
/** 跳转地址;本地项目为 /<目录名>/ */
href: string
/** 详情页头像 / 封面,缺省时退化为字符图标 */
cover?: string
/** 是否为热门条目:卡片名字后显示主题同款 HOT 徽标 */
hot?: boolean
/** 详情页的一句话评价 */
comment?: string
/** 详情页横幅图 */
banner?: string
/** 详情页头像下方的标签 */
badge?: string
/** 详情页浏览量(占位数据) */
views?: number
/** 详情页点赞数(占位数据) */
likes?: number
}
/** 应用分组:明细页按组展示,首页取所有分组的前几项做预览 */
export type AppGroup = {
id: string
name: string
items: AppItem[]
}
/** 竖版海报条目(电影) */
export type PosterItem = {
id: string
title: string
cover: string
/** 详情页标签行,如「推荐电影」「豆瓣电影 Top250」 */
tags?: string[]
/** 详情页一句话评价 */
comment?: string
/** 详情页正文段落 */
summary?: string
/** 跳转地址(占位数据暂无真实链接) */
href?: string
views?: number
likes?: number
}
/** 「推荐阅读」用的轻量条目,应用与电影都能映射过来 */
export type RelatedItem = {
id: string
name: string
desc: string
cover?: string
icon?: string
}
/** 文章正文块:段落 / 小标题 / 配图 */
export type ArticleBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; text: string }
| { type: 'image'; src: string }
/** 图文条目(报道) */
export type ArticleItem = {
id: string
title: string
desc: string
cover: string
/** 详情页标题下的元信息,如 ['2 年前', 'CES 现场报道'] */
meta?: string[]
/** 详情页正文 */
blocks?: ArticleBlock[]
}
/** 应用类分类:内部按分组组织 */
export type AppCategory = {
id: string
kind: 'apps'
title: string
/** 标题左侧色条颜色,各分类用不同颜色区分 */
barColor: string
/** 明细页 hero 区的字符图标 */
icon: string
groups: AppGroup[]
/** 首页预览条数,默认 4 */
previewLimit?: number
/** 首页预览为空时的提示文案 */
emptyText?: string
}
/** 海报类分类:竖版海报网格 */
export type PosterCategory = {
id: string
kind: 'posters'
title: string
barColor: string
icon: string
items: PosterItem[]
/** 首页预览条数,默认 6 */
previewLimit?: number
}
/** 图文类分类:上图下文卡片网格 */
export type ArticleCategory = {
id: string
kind: 'articles'
title: string
barColor: string
icon: string
items: ArticleItem[]
/** 首页预览条数,默认 4 */
previewLimit?: number
}
export type Category = AppCategory | PosterCategory | ArticleCategory
/** 分类内的条目总数,用于角标与明细页计数 */
export function categoryCount(category: Category): number {
if (category.kind === 'apps') {
return category.groups.reduce((total, group) => total + group.items.length, 0)
}
return category.items.length
}
/** 把应用类分类的分组拍平,首页预览用 */
export function flattenAppItems(category: AppCategory): AppItem[] {
return category.groups.flatMap((group) => group.items)
}
/** 按 id 在分类里查找条目;详情页用 */
export function findAppItem(category: Category, itemId: string): AppItem | undefined {
if (category.kind !== 'apps') return undefined
for (const group of category.groups) {
const found = group.items.find((item) => item.id === itemId)
if (found !== undefined) return found
}
return undefined
}
/** 取同分类下的其他条目,用于详情页的「推荐阅读」 */
export function relatedAppItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): AppItem[] {
if (category.kind !== 'apps') return []
return flattenAppItems(category)
.filter((item) => item.id !== excludeId)
.slice(0, limit)
}
/** 按 id 查找海报条目 */
export function findPosterItem(
category: Category,
itemId: string,
): PosterItem | undefined {
if (category.kind !== 'posters') return undefined
return category.items.find((item) => item.id === itemId)
}
/** 取同分类下的其他海报,用于详情页的「推荐阅读」 */
export function relatedPosterItems(
category: Category,
excludeId: string | undefined,
limit = 4,
): PosterItem[] {
if (category.kind !== 'posters') return []
return category.items.filter((item) => item.id !== excludeId).slice(0, limit)
}
/** 按 id 查找图文条目 */
export function findArticleItem(
category: Category,
itemId: string,
): ArticleItem | undefined {
if (category.kind !== 'articles') return undefined
return category.items.find((item) => item.id === itemId)
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 图标字体:取自 Alright 主题自带的 iconfont(Project id 4809463)。
* 字体文件放在 public/fonts/iconfont.woff2,这里只保留项目用到的字形,
* 避免把整份 500 多个图标的样式表打进包里。
*/
@font-face {
font-family: 'iconfont';
src: url('./assets/iconfont.woff2') format('woff2');
font-display: swap;
}
.iconfont {
display: inline-block;
font-family: 'iconfont' !important;
font-style: normal;
font-weight: normal;
line-height: 1;
text-rendering: auto;
speak: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-search::before {
content: '\e942';
}
.icon-user::before {
content: '\e966';
}
.icon-refresh::before {
content: '\e93d';
}
.icon-link::before {
content: '\ea37';
}
.icon-external::before {
content: '\e8be';
}
.icon-eye::before {
content: '\e8fd';
}
.icon-thumb-up::before {
content: '\e957';
}
.icon-share::before {
content: '\e946';
}
.icon-close::before {
content: '\e8e4';
}
.icon-more::before {
content: '\e91f';
}
.icon-arrow-up::before {
content: '\e8c1';
}
.icon-arrow-left::before {
content: '\e8b5';
}
.icon-arrow-down::before {
content: '\e8b7';
}
+25 -12
View File
@@ -1,24 +1,37 @@
:root {
--bg-color: #f7f9fb;
/* ---------- 颜色:取自 Alright 主题的设计变量 ---------- */
--bg-color: #f7f8fa;
--card-bg: #ffffff;
--primary-color: #3b82f6;
--primary-soft: #eff6ff;
--text-main: #1e293b;
--text-sub: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--primary-color: #3858f6;
--primary-soft: rgba(56, 88, 246, 0.1);
--text-main: #1d2129;
--text-sub: #4e5969;
--text-muted: #86909c;
--border-color: #f6f6f6;
--border-strong: #eeeeee;
--danger: #ff3e3e;
--orange: #ffba3a;
--green: #4abd86;
--radius-sm: 6px;
--radius-main: 12px;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
--shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
--radius-lg: 24px;
/* 主题的阴影很轻:0 6px 16px rgba(27,26,49,2%) */
--shadow-card: 0 6px 16px rgba(27, 26, 49, 0.02);
--shadow-hover: 0 6px 16px rgba(27, 26, 49, 0.06);
--sidebar-width: 240px;
--top-nav-height: 60px;
/* 内容区最大宽度,宽屏时整体居中 */
--layout-max-width: 1280px;
--layout-max-width: 1380px;
/* 内容列顶部让位高度(给悬浮搜索框),侧栏首卡与内容首卡都按它对齐 */
--content-top-offset: 52px;
/* 主题正文用霞鹜文楷(与演示站同一份 webfont),缺失时回退系统字体 */
--font-sans:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
font-family: var(--font-sans);
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './iconfont.css'
import './index.css'
import App from './App.tsx'