- 建立 pnpm workspace + Turborepo 单仓、ESLint 9/Prettier/commitlint/husky 门禁 - NestJS + Fastify API:统一错误体、trace_id、Zod 管道、/health 真实依赖探活、OpenAPI 3.1 契约 - Prisma 基线:outbox_events/idempotency_keys/system_configs 与首版迁移、幂等种子 - shared 契约包:错误码、权限点、ULID、游标分页、命名/时间工具 - ui 包与 Web 壳层:Semi + Remix 图标统一出口、Cloud Blue 视觉、登录/工作台/错误页 - worker 骨架、Playwright 冒烟、Gitea CI、OpenAPI 快照与 oasdiff 回退脚本 - 补充 A0 实施报告与本地开发说明
This commit is contained in:
1 parent
9c3ab9768a
commit
04f49690f4
155 files changed
+18402
-28
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { PageSkeleton } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router';
|
||||
|
||||
import { filterNavByPermissions, findActiveNav, NAV_MODULES } from '@/navigation/nav-config';
|
||||
import { ROUTES } from '@/routes';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import { PageBreadcrumbs } from './PageBreadcrumbs';
|
||||
import { SideNav } from './SideNav';
|
||||
import { TopBar } from './TopBar';
|
||||
|
||||
export function AppLayout(): ReactNode {
|
||||
const location = useLocation();
|
||||
const permissions = useAuthStore((state) => state.permissions);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const modules = useMemo(
|
||||
() => filterNavByPermissions(NAV_MODULES, new Set(permissions)),
|
||||
[permissions],
|
||||
);
|
||||
const active = useMemo(
|
||||
() => findActiveNav(modules, location.pathname),
|
||||
[modules, location.pathname],
|
||||
);
|
||||
const activeModule = active?.module ?? modules[0];
|
||||
|
||||
if (!activeModule) {
|
||||
return <Navigate to={ROUTES.forbidden} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={collapsed ? 'lx-shell lx-shell--collapsed' : 'lx-shell'}
|
||||
data-testid="app-shell"
|
||||
>
|
||||
<TopBar modules={modules} activeModuleKey={activeModule.key} />
|
||||
<div className="lx-shell__body">
|
||||
<SideNav
|
||||
module={activeModule}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => {
|
||||
setCollapsed((value) => !value);
|
||||
}}
|
||||
/>
|
||||
<main className="lx-workspace">
|
||||
<PageBreadcrumbs pathname={location.pathname} />
|
||||
<div className="lx-page">
|
||||
<Suspense fallback={<PageSkeleton rows={8} />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
||||
|
||||
export interface PageBreadcrumbsProps {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
||||
const entries = getBreadcrumbs(NAV_MODULES, pathname);
|
||||
return (
|
||||
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||
{entries.map((entry, index) => {
|
||||
const isLast = index === entries.length - 1;
|
||||
return (
|
||||
<span key={entry.key} className="lx-breadcrumbs__entry">
|
||||
{index > 0 ? <span className="lx-breadcrumbs__sep">/</span> : null}
|
||||
{entry.path !== undefined && !isLast ? (
|
||||
<Link to={entry.path} className="lx-breadcrumbs__link">
|
||||
{entry.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? 'lx-breadcrumbs__current' : 'lx-breadcrumbs__label'}>
|
||||
{entry.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IconBack, IconMenu } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
import type { NavModule } from '@/navigation/nav-config';
|
||||
|
||||
export interface SideNavProps {
|
||||
module: NavModule;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SideNav({ module, collapsed, onToggle }: SideNavProps): ReactNode {
|
||||
return (
|
||||
<aside className="lx-sidebar" aria-label="功能导航">
|
||||
<div className="lx-sidebar__heading">
|
||||
<span className="lx-sidebar__title">{module.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-icon-button lx-sidebar__toggle"
|
||||
onClick={onToggle}
|
||||
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
title={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
{collapsed ? <IconMenu size={18} /> : <IconBack size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="lx-sidebar__nav">
|
||||
{module.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
end={item.path === '/'}
|
||||
className={({ isActive }) =>
|
||||
isActive ? 'lx-sidebar__item is-active' : 'lx-sidebar__item'
|
||||
}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="lx-sidebar__item-icon">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<span className="lx-sidebar__item-label">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="lx-sidebar__footer">
|
||||
<span className="lx-sidebar__env" title="当前环境:本地开发">
|
||||
<span className="lx-sidebar__env-dot" aria-hidden="true" />
|
||||
<span className="lx-sidebar__env-text">本地开发</span>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@lingxi/ui/icons';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import type { NavModule } from '@/navigation/nav-config';
|
||||
import { ROUTES } from '@/routes';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export interface TopBarProps {
|
||||
modules: readonly NavModule[];
|
||||
activeModuleKey: string;
|
||||
}
|
||||
|
||||
function ModuleTabs({
|
||||
modules,
|
||||
activeModuleKey,
|
||||
}: {
|
||||
modules: readonly NavModule[];
|
||||
activeModuleKey: string;
|
||||
}): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<nav className="lx-topbar__modules" aria-label="模块导航">
|
||||
{modules.map((module) => {
|
||||
const active = module.key === activeModuleKey;
|
||||
return (
|
||||
<button
|
||||
key={module.key}
|
||||
type="button"
|
||||
className={active ? 'lx-topbar__module is-active' : 'lx-topbar__module'}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={() => {
|
||||
void navigate(module.path);
|
||||
}}
|
||||
>
|
||||
{module.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const clearSession = useAuthStore((state) => state.clearSession);
|
||||
const [openPath, setOpenPath] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const open = openPath === location.pathname;
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: MouseEvent): void => {
|
||||
if (menuRef.current !== null && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpenPath(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="lx-user" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-user__trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setOpenPath(open ? null : location.pathname);
|
||||
}}
|
||||
>
|
||||
<span className="lx-user__avatar">{user?.name.slice(0, 1) ?? '灵'}</span>
|
||||
<span className="lx-user__meta">
|
||||
<span className="lx-user__name">{user?.name ?? '未登录'}</span>
|
||||
<span className="lx-user__role">{user?.roleName ?? '访客'}</span>
|
||||
</span>
|
||||
<IconChevronDown size={14} className="lx-user__chevron" />
|
||||
</button>
|
||||
<UserMenuDropdown
|
||||
open={open}
|
||||
email={user?.email ?? '—'}
|
||||
onLogout={() => {
|
||||
clearSession();
|
||||
void navigate(ROUTES.login, { replace: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenuDropdown({
|
||||
open,
|
||||
email,
|
||||
onLogout,
|
||||
}: {
|
||||
open: boolean;
|
||||
email: string;
|
||||
onLogout: () => void;
|
||||
}): ReactNode {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="lx-user__menu" role="menu">
|
||||
<div className="lx-user__menu-header">{email}</div>
|
||||
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
|
||||
<IconLogout size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopBar({ modules, activeModuleKey }: TopBarProps): ReactNode {
|
||||
return (
|
||||
<header className="lx-topbar">
|
||||
<div className="lx-topbar__brand">
|
||||
<span className="lx-topbar__mark">灵</span>
|
||||
<span className="lx-topbar__brand-text">
|
||||
<span className="lx-topbar__name">灵犀</span>
|
||||
<span className="lx-topbar__tag">素材生产平台</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ModuleTabs modules={modules} activeModuleKey={activeModuleKey} />
|
||||
|
||||
<div className="lx-topbar__actions">
|
||||
<button type="button" className="lx-icon-button" aria-label="全局搜索" title="全局搜索">
|
||||
<IconSearch size={18} />
|
||||
</button>
|
||||
<button type="button" className="lx-icon-button" aria-label="任务通知" title="任务通知">
|
||||
<IconNotification size={18} />
|
||||
<span className="lx-icon-button__dot" aria-hidden="true" />
|
||||
</button>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user