docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果

v1 声称完成的下游工作经核查实际未完成:
- api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败
- teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平)
- iam: proto 缺 BatchGetUsers rpc 声明

v2 记录详细核查证据和修复要求
This commit is contained in:
SpecialX
2026-07-14 08:26:27 +08:00
parent 99580fa13a
commit 0b42302a64
42 changed files with 4804 additions and 637 deletions

View File

@@ -30,6 +30,7 @@ import {
useCrossTabSync,
broadcastCrossTabEvent,
} from "@/hooks/use-cross-tab-sync";
import { LocaleSwitcher } from "@/app/locale-switcher";
/**
* 权限上下文:从 localStorage 读取(登录时由 iam 返回并存储)。
@@ -142,7 +143,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
)}
</nav>
{/* 底部:用户信息 + 登出 */}
{/* 底部:用户信息 + 语言切换 + 登出 */}
<div className="mt-auto px-6 py-4 border-t border-rule">
{user && (
<div className="mb-2">
@@ -152,6 +153,9 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
</p>
</div>
)}
<div className="mb-3">
<LocaleSwitcher />
</div>
<button
onClick={handleLogout}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"

View File

@@ -0,0 +1,255 @@
"use client";
/**
* 性能监控 Dashboard 组件dev 环境)
*
* - 仅在 dev 环境渲染process.env.NODE_ENV === "development"
* - 采集 Web VitalsLCP/FCP/CLS/INP/TTFB并存储到 sessionStorage
* - 浮动按钮 + 弹出面板,展示实时指标与阈值对比
* - 使用语义设计令牌bg-subtle / text-ink / border-rule 等)
*
* 关联lib/observability/performance.ts PERFORMANCE_BUDGETS
* 02-architecture-design.md §12 可观测性
*/
import { useEffect, useState, useCallback } from "react";
/** Web Vital 指标存储结构 */
interface VitalMetric {
name: string;
value: number;
rating: "good" | "needs-improvement" | "poor";
timestamp: number;
}
/** sessionStorage 存储键 */
const STORAGE_KEY = "teacher-portal:web-vitals";
/** 性能阈值(与 PERFORMANCE_BUDGETS 对齐) */
const THRESHOLDS: Record<string, { good: number; poor: number; unit: string }> =
{
LCP: { good: 2500, poor: 4000, unit: "ms" },
FCP: { good: 1800, poor: 3000, unit: "ms" },
CLS: { good: 0.1, poor: 0.25, unit: "" },
INP: { good: 200, poor: 500, unit: "ms" },
TTFB: { good: 800, poor: 1800, unit: "ms" },
};
/** 指标显示顺序 */
const VITAL_ORDER = ["LCP", "FCP", "CLS", "INP", "TTFB"] as const;
/**
* 从 sessionStorage 读取已采集的指标。
*/
function readStoredMetrics(): Record<string, VitalMetric> {
if (typeof window === "undefined") return {};
try {
const raw = window.sessionStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, VitalMetric>;
} catch {
return {};
}
}
/**
* 将指标写入 sessionStorage。
*/
function writeStoredMetrics(metrics: Record<string, VitalMetric>): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(metrics));
} catch {
// sessionStorage 写入失败(如隐私模式),静默忽略
}
}
/**
* 根据 rating 返回对应的语义颜色类。
*/
function ratingColorClass(rating: string): string {
switch (rating) {
case "good":
return "text-success";
case "needs-improvement":
return "text-warning";
case "poor":
return "text-danger";
default:
return "text-ink-muted";
}
}
/**
* 格式化指标值显示。
*/
function formatValue(value: number, unit: string): string {
if (unit === "") {
return value.toFixed(3);
}
return `${Math.round(value)}${unit}`;
}
/**
* 性能监控 Dashboard 组件。
*
* 仅在 dev 环境渲染。采集 Web Vitals 并展示在浮动面板中。
*/
export function PerformanceDashboard(): React.ReactNode {
const [metrics, setMetrics] = useState<Record<string, VitalMetric>>({});
const [isOpen, setIsOpen] = useState(false);
// 注册 web-vitals 回调,采集指标
useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
// 动态 import web-vitals 库
let cancelled = false;
void import("web-vitals")
.then(({ onLCP, onCLS, onFCP, onINP, onTTFB }) => {
if (cancelled) return;
const handleMetric = (metric: {
name: string;
value: number;
rating: string;
}): void => {
const vital: VitalMetric = {
name: metric.name,
value: metric.value,
rating: metric.rating as VitalMetric["rating"],
timestamp: Date.now(),
};
setMetrics((prev) => {
const updated = { ...prev, [metric.name]: vital };
writeStoredMetrics(updated);
return updated;
});
};
onLCP(handleMetric);
onCLS(handleMetric);
onFCP(handleMetric);
onINP(handleMetric);
onTTFB(handleMetric);
})
.catch(() => {
// web-vitals 库未安装时静默降级
});
// 加载已存储的指标
setMetrics(readStoredMetrics());
return () => {
cancelled = true;
};
}, []);
// dev 环境外不渲染
if (process.env.NODE_ENV !== "development") return null;
// Escape 关闭面板
const handleKeyDown = useCallback((e: React.KeyboardEvent): void => {
if (e.key === "Escape") {
setIsOpen(false);
}
}, []);
const metricCount = Object.keys(metrics).length;
return (
<div className="fixed bottom-4 right-4 z-50" onKeyDown={handleKeyDown}>
{/* 浮动按钮 */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "关闭性能监控面板" : "打开性能监控面板"}
aria-expanded={isOpen}
className="flex items-center justify-center w-10 h-10 rounded-full bg-accent text-ink-on-accent shadow-lg hover:bg-accent-hover transition-colors"
>
<span className="text-sm font-mono" aria-hidden="true">
{metricCount > 0 ? metricCount : "⚡"}
</span>
</button>
{/* 弹出面板 */}
{isOpen && (
<div
role="dialog"
aria-label="Web Vitals 性能指标"
className="absolute bottom-12 right-0 w-72 p-4 bg-paper border border-rule rounded-card shadow-lg"
>
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-sm font-serif text-ink">Web Vitals</h2>
<button
type="button"
onClick={() => setIsOpen(false)}
aria-label="关闭"
className="text-tiny text-ink-muted hover:text-ink"
>
</button>
</div>
<div className="rule-thin mb-3" />
{metricCount === 0 ? (
<p className="text-tiny text-ink-muted italic"></p>
) : (
<ul className="space-y-2" aria-label="性能指标列表">
{VITAL_ORDER.map((name) => {
const metric = metrics[name];
if (!metric) return null;
const threshold = THRESHOLDS[name];
if (!threshold) return null;
return (
<li
key={name}
className="flex items-baseline justify-between"
>
<span className="text-tiny uppercase tracking-wide text-ink-muted">
{name}
</span>
<span
className={`text-sm font-mono ${ratingColorClass(metric.rating)}`}
>
{formatValue(metric.value, threshold.unit)}
</span>
</li>
);
})}
</ul>
)}
<div className="rule-thin mt-3 mb-2" />
{/* 阈值图例 */}
<div className="flex gap-3 text-tiny">
<span className="text-success"> good</span>
<span className="text-warning"> needs improvement</span>
<span className="text-danger"> poor</span>
</div>
{/* 清除按钮 */}
{metricCount > 0 && (
<button
type="button"
onClick={() => {
if (typeof window !== "undefined") {
window.sessionStorage.removeItem(STORAGE_KEY);
}
setMetrics({});
}}
className="mt-2 text-tiny uppercase tracking-wide text-ink-muted hover:text-ink"
>
</button>
)}
</div>
)}
</div>
);
}
export default PerformanceDashboard;