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:
@@ -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"
|
||||
|
||||
255
apps/teacher-portal/src/components/performance-dashboard.tsx
Normal file
255
apps/teacher-portal/src/components/performance-dashboard.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 性能监控 Dashboard 组件(dev 环境)
|
||||
*
|
||||
* - 仅在 dev 环境渲染(process.env.NODE_ENV === "development")
|
||||
* - 采集 Web Vitals(LCP/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;
|
||||
Reference in New Issue
Block a user