feat(web): 对齐 Alright 主题视觉规范(配色/字体/图标/背景/动效),并补充设计规范文档
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PLACEHOLDER_PROFILE } from '../../profile'
|
||||
import { UserIcon } from '../common/Icon'
|
||||
|
||||
/**
|
||||
* 用户资料卡:头像、认证徽标、昵称、角色、简介、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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Project } from '../../api'
|
||||
import { LinkIcon, RefreshIcon } from '../common/Icon'
|
||||
|
||||
/**
|
||||
* 本地服务面板:连接状态、项目数、访问地址,以及两个操作入口。
|
||||
* 只读展示 + 触发刷新,不负责导航。
|
||||
*/
|
||||
type ServicePanelProps = {
|
||||
projects: Project[]
|
||||
live: boolean
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
|
||||
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" />
|
||||
{live ? '已连接' : '未连接'}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<dl className="service-stats">
|
||||
<div>
|
||||
<dt>项目数</dt>
|
||||
<dd>{projects.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>访问地址</dt>
|
||||
<dd title={host}>{host}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user