feat(a0): 完成工程骨架与契约基线
ci / 质量门禁 (push) Canceled after 0s

- 建立 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:
Stev_Wang committed 2026-11-02 17:01:50 +08:00
1 parent 9c3ab9768a
commit 04f49690f4
155 files changed
+18402 -28

No files matched your search

+58
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}