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
@@ -1,12 +1,14 @@
import { useState } from 'react'
import { Card, Menu, type MenuItem } from '@stash/ui'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:直接由内容树派生,不再读独立的导航数据。
* 分类导航树:由内容树派生。
*
* - 有分组的分类(apps)渲染成可折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,直接是链接。
* - 有分组的分类(apps)是折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,就是一个普通菜单项。
*
* 折叠状态由组件库的 Menu 内部维护;这里只负责把内容树翻译成 items。
*/
type CategoryNavProps = {
/** 内容目录;顺序即侧栏顺序 */
@@ -17,82 +19,54 @@ type CategoryNavProps = {
onNavigate: (target: NavTarget) => void
}
const categoryKey = (id: string) => `cat:${id}`
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
onNavigate,
}: CategoryNavProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const items: MenuItem[] = categories.map((category) => {
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
const toggle = (id: string) => {
setOpenGroups((previous) =>
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
)
return {
key: categoryKey(category.id),
label: category.title,
children:
groups.length > 0
? groups.map((group) => ({
key: groupKey(category.id, group.id),
label: group.name,
}))
: undefined,
}
})
const activeKey =
activeCategoryId === undefined
? undefined
: activeGroupId !== undefined
? groupKey(activeCategoryId, activeGroupId)
: categoryKey(activeCategoryId)
const handleSelect = (key: string) => {
if (key.startsWith('group:')) {
const [, categoryId, groupId] = key.split(':')
onNavigate({ categoryId, groupId })
return
}
onNavigate({ categoryId: key.slice('cat:'.length) })
}
const isOpen = (id: string) => openGroups.includes(id)
return (
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{categories.map((category) => {
// 只显示允许出现在侧栏的分组
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
// 没有分组的分类:整行就是一个链接
if (groups.length === 0) {
return (
<li className="nav-item" key={category.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === category.id && activeGroupId === undefined ? ' active' : ''
}`}
onClick={() => onNavigate({ categoryId: category.id })}
>
<span>{category.title}</span>
</button>
</li>
)
}
// 有分组:分区可折叠,分组是真正的链接
return (
<li className={`nav-item${isOpen(category.id) ? ' open' : ''}`} key={category.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(category.id)}
onClick={() => toggle(category.id)}
>
<span>{category.title}</span>
</button>
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(category.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{groups.map((group) => (
<li key={group.id}>
<button
type="button"
className={`nav-link${activeGroupId === group.id ? ' active' : ''}`}
onClick={() => onNavigate({ categoryId: category.id, groupId: group.id })}
>
<span>{group.name}</span>
</button>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
</nav>
<Card padding="sm" className="nav-card">
<Menu items={items} activeKey={activeKey} onSelect={handleSelect} />
</Card>
)
}
@@ -1,5 +1,5 @@
import { Avatar, Card, Paragraph, Tag, Text, Title } from '@stash/ui'
import { PLACEHOLDER_PROFILE } from '../../profile'
import { UserIcon } from '../common/Icon'
/**
* 用户资料卡:头像、认证徽标、昵称、角色、简介、IP 属地。
@@ -9,16 +9,14 @@ 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">
<UserIcon />
<span className="profile-badge">✓</span>
</div>
<div className="profile-name">{name}</div>
<span className="profile-role">{role}</span>
<p className="profile-bio">{bio}</p>
<div className="profile-location">IP属地:{location}</div>
</section>
<Card padding="lg" className="profile-card">
<Avatar size="lg" fallback="S" verified />
<Title level={4}>{name}</Title>
{!name.includes(role) && <Tag shape="rounded">{role}</Tag>}
<Paragraph>{bio}</Paragraph>
<Text size="xs" tone="muted">
IP 属地:{location}
</Text>
</Card>
)
}
@@ -1,5 +1,5 @@
import { Button, Card, Divider, Icon, Space, Tag, Text, Title } from '@stash/ui'
import type { Project } from '../../api'
import { LinkIcon, RefreshIcon } from '../common/Icon'
/**
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
@@ -16,50 +16,52 @@ export function ServicePanel({ projects, live, refreshing, onRefresh }: ServiceP
const host = typeof window === 'undefined' ? '-' : window.location.host
return (
<section className="service-panel card-box" aria-label="本地服务状态">
<header className="service-head">
<h2 className="service-title">本地服务</h2>
<span className={`service-pill${live ? ' is-on' : ''}`}>
<span className={`status-dot${live ? ' is-on' : ''}`} aria-hidden="true" />
<Card padding="md" className="service-panel">
<Space justify="between">
<Title level={4} as="h2">
本地服务
</Title>
<Tag tone={live ? 'success' : 'neutral'} dot>
{live ? '已连接' : '未连接'}
</span>
</header>
</Tag>
</Space>
<dl className="service-stats">
<div>
<dt>项目数</dt>
<dd>{projects.length}</dd>
</div>
<div>
<dt>访问地址</dt>
<dd title={host}>{host}</dd>
</div>
</dl>
<Divider spacing="sm" />
<div className="service-actions">
<button
type="button"
className="service-action"
title="重新扫描项目"
data-spinning={refreshing}
disabled={refreshing}
onClick={onRefresh}
>
<RefreshIcon />
<span>重新扫描</span>
</button>
<a
className="service-action"
href="/api/projects"
target="_blank"
rel="noreferrer"
title="查看项目接口"
>
<LinkIcon />
<span>查看接口</span>
</a>
<div className="service-stats">
<Space justify="between">
<Text size="sm" tone="sub">
项目数
</Text>
<Text strong>{projects.length}</Text>
</Space>
<Space justify="between">
<Text size="sm" tone="sub">
访问地址
</Text>
<Text size="sm" truncate title={host} className="service-host">
{host}
</Text>
</Space>
</div>
</section>
<Space gap="sm" wrap>
<Button
size="sm"
variant="ghost"
loading={refreshing}
icon={<Icon name="refresh_3_line" />}
onClick={onRefresh}
title="重新扫描项目"
>
重新扫描
</Button>
<a className="link-button" href="/api/projects" target="_blank" rel="noreferrer">
<Icon name="link_3_line" />
查看接口
</a>
</Space>
</Card>
)
}
+2 -2
View File
@@ -32,7 +32,7 @@ export function Sidebar({
onNavigate,
}: SidebarProps) {
return (
<aside className="sidebar">
<div className="sidebar">
<ProfileCard />
<ServicePanel
@@ -48,6 +48,6 @@ export function Sidebar({
activeGroupId={activeGroupId}
onNavigate={onNavigate}
/>
</aside>
</div>
)
}