feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现

- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步
- P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote
- P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告
- P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移
- P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块
  - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页)
  - classes/[id] 详情 + classes/schedule 课表
  - course-plans(2 页)/diagnostic(2 页)/error-book/practice
  - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析
  - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考
  - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡
  - homework/submissions 列表 + assignments/[id]/submissions 批量批改
  - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改
  - lesson-plans 编辑器 + library + calendar + heatmap 5 页
  - elective 选修课 3 页 /leave 请假 /schedule-changes 调课
- P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports
- 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序)
- viewports.ts 扩展 11 个新导航项
- 验证:tsc --noEmit 零错误 + eslint 零错误
- 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
This commit is contained in:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View File

@@ -0,0 +1,399 @@
"use client";
/**
* KnowledgeGraphView - 纯 SVG 知识图谱可视化组件
*
* 职责:
* - 圆形布局放置知识点节点
* - 节点大小 = 掌握度,颜色 = 掌握度梯度(红→黄→绿,使用语义令牌)
* - 边为箭头线PREREQUISITE 实线 / RELATED 虚线)
* - 点击节点展示详情面板
*
* 不依赖 d3/recharts全部 SVG 手绘project_rules不安装新 npm 包)。
* 颜色禁止 hex 字面量,使用 var(--color-success/warning/danger)project_rules §3.10)。
*
* 维护者ai13teacher-portal
*/
import { useMemo, useState } from "react";
import type {
KnowledgeNode,
KnowledgeEdge,
} from "@/lib/graphql-p4";
interface KnowledgeGraphViewProps {
nodes: KnowledgeNode[];
edges: KnowledgeEdge[];
}
/** 画布尺寸 */
const WIDTH = 880;
const HEIGHT = 620;
const CENTER_X = WIDTH / 2;
const CENTER_Y = HEIGHT / 2;
/** 节点分布圆半径 */
const LAYOUT_RADIUS = 230;
/** 节点最小/最大半径 */
const NODE_R_MIN = 18;
const NODE_R_MAX = 34;
/** 按掌握度映射填充色(语义令牌变量) */
function masteryColor(level: number): string {
if (level < 40) return "var(--color-danger)";
if (level < 70) return "var(--color-warning)";
return "var(--color-success)";
}
/** 按掌握度映射节点半径 */
function nodeRadius(level: number): number {
const ratio = Math.max(0, Math.min(100, level)) / 100;
return NODE_R_MIN + (NODE_R_MAX - NODE_R_MIN) * ratio;
}
interface NodePosition {
node: KnowledgeNode;
x: number;
y: number;
r: number;
}
export default function KnowledgeGraphView({
nodes,
edges,
}: KnowledgeGraphViewProps): React.ReactNode {
const [selectedId, setSelectedId] = useState<string | null>(
nodes[0]?.id ?? null,
);
// 圆形布局:按 index 均匀分布
const positions = useMemo<NodePosition[]>(() => {
const total = nodes.length;
return nodes.map((node, i) => {
const angle = (2 * Math.PI * i) / total - Math.PI / 2;
return {
node,
x: CENTER_X + LAYOUT_RADIUS * Math.cos(angle),
y: CENTER_Y + LAYOUT_RADIUS * Math.sin(angle),
r: nodeRadius(node.masteryLevel),
};
});
}, [nodes]);
const posMap = useMemo(() => {
const m = new Map<string, NodePosition>();
positions.forEach((p) => m.set(p.node.id, p));
return m;
}, [positions]);
const selected = selectedId
? positions.find((p) => p.node.id === selectedId) ?? null
: null;
return (
<div className="flex gap-8 items-start">
{/* SVG 图谱 */}
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="flex-1 max-w-3xl border border-rule rounded-card bg-surface"
role="img"
aria-label="知识图谱节点关系图"
>
<defs>
{/* 箭头标记 - PREREQUISITE */}
<marker
id="arrow-prereq"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 z"
fill="var(--color-ink-muted)"
/>
</marker>
{/* 箭头标记 - RELATED */}
<marker
id="arrow-related"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path
d="M 0 0 L 10 5 L 0 10 z"
fill="var(--color-ink-subtle)"
/>
</marker>
</defs>
{/* 边 */}
<g>
{edges.map((edge, i) => {
const from = posMap.get(edge.from);
const to = posMap.get(edge.to);
if (!from || !to) return null;
// 计算边起止点(缩进到节点边缘,避免穿过节点)
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / dist;
const uy = dy / dist;
const x1 = from.x + ux * from.r;
const y1 = from.y + uy * from.r;
const x2 = to.x - ux * (to.r + 4);
const y2 = to.y - uy * (to.r + 4);
const isPrereq = edge.type === "PREREQUISITE";
return (
<line
key={`edge-${i}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={isPrereq ? "var(--color-ink-muted)" : "var(--color-ink-subtle)"}
strokeWidth={isPrereq ? 1.5 : 1}
strokeDasharray={isPrereq ? undefined : "5 4"}
markerEnd={isPrereq ? "url(#arrow-prereq)" : "url(#arrow-related)"}
/>
);
})}
</g>
{/* 节点 */}
<g>
{positions.map((p) => {
const isSelected = p.node.id === selectedId;
return (
<g
key={p.node.id}
onClick={() => setSelectedId(p.node.id)}
className="cursor-pointer"
role="button"
aria-label={`${p.node.name} 掌握度 ${p.node.masteryLevel}`}
>
<circle
cx={p.x}
cy={p.y}
r={p.r}
fill={masteryColor(p.node.masteryLevel)}
fillOpacity={0.85}
stroke={
isSelected
? "var(--color-ink)"
: "var(--color-rule)"
}
strokeWidth={isSelected ? 2.5 : 1}
/>
<text
x={p.x}
y={p.y + p.r + 16}
textAnchor="middle"
fontSize="13"
fontFamily="var(--font-family-sans)"
fill="var(--color-ink)"
>
{p.node.name}
</text>
<text
x={p.x}
y={p.y + 5}
textAnchor="middle"
fontSize="12"
fontFamily="var(--font-family-mono)"
fontWeight="600"
fill="var(--color-ink-on-accent)"
>
{p.node.masteryLevel}
</text>
</g>
);
})}
</g>
</svg>
{/* 详情面板 */}
<aside className="w-72 flex-shrink-0">
{selected ? (
<div className="p-6 border border-rule rounded-card bg-surface">
<p className="text-tiny uppercase tracking-wide text-ink-muted">
{selected.node.subject}
</p>
<h3 className="mt-2 text-xl font-serif text-ink">
{selected.node.name}
</h3>
{selected.node.description ? (
<p className="mt-3 text-sm text-ink-muted">
{selected.node.description}
</p>
) : null}
<div className="rule-thin my-5" />
<div>
<div className="flex items-baseline justify-between mb-2">
<span className="text-tiny uppercase tracking-wide text-ink-muted">
</span>
<span
className="text-lg font-serif"
style={{ color: masteryColor(selected.node.masteryLevel) }}
>
{selected.node.masteryLevel}
<span className="text-tiny text-ink-muted">/100</span>
</span>
</div>
<div className="h-2 rounded-button bg-subtle overflow-hidden">
<div
className="h-full rounded-button"
style={{
width: `${selected.node.masteryLevel}%`,
background: masteryColor(selected.node.masteryLevel),
}}
/>
</div>
</div>
{/* 前置 / 后继 */}
<Predecessors
edges={edges}
nodeId={selected.node.id}
posMap={posMap}
/>
<Successors
edges={edges}
nodeId={selected.node.id}
posMap={posMap}
/>
</div>
) : (
<p className="text-sm text-ink-muted p-6">
</p>
)}
{/* 图例 */}
<div className="mt-6 p-4 border border-rule rounded-card">
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-3">
<span
className="inline-block w-4 h-4 rounded-full"
style={{ background: "var(--color-success)" }}
/>
<span className="text-ink"> 70</span>
</li>
<li className="flex items-center gap-3">
<span
className="inline-block w-4 h-4 rounded-full"
style={{ background: "var(--color-warning)" }}
/>
<span className="text-ink">40 &lt; 70</span>
</li>
<li className="flex items-center gap-3">
<span
className="inline-block w-4 h-4 rounded-full"
style={{ background: "var(--color-danger)" }}
/>
<span className="text-ink"> &lt; 40</span>
</li>
<li className="flex items-center gap-3 pt-2 border-t border-rule">
<span className="inline-block w-6 h-0.5 bg-ink-muted" />
<span className="text-ink-muted">线</span>
</li>
<li className="flex items-center gap-3">
<span
className="inline-block w-6 h-0.5"
style={{
backgroundImage:
"repeating-linear-gradient(90deg, var(--color-ink-subtle) 0 5px, transparent 5px 9px)",
}}
/>
<span className="text-ink-muted">线</span>
</li>
</ul>
</div>
</aside>
</div>
);
}
/** 前置节点列表 */
function Predecessors({
edges,
nodeId,
posMap,
}: {
edges: KnowledgeEdge[];
nodeId: string;
posMap: Map<string, NodePosition>;
}): React.ReactNode {
const preds = edges
.filter((e) => e.to === nodeId)
.map((e) => posMap.get(e.from)?.node)
.filter((n): n is KnowledgeNode => n !== undefined);
if (preds.length === 0) return null;
return (
<div className="mt-5">
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-2">
</p>
<ul className="space-y-1">
{preds.map((n) => (
<li key={n.id} className="text-sm text-ink">
{n.name}
<span className="ml-2 text-tiny text-ink-muted">
{n.subject}
</span>
</li>
))}
</ul>
</div>
);
}
/** 后继节点列表 */
function Successors({
edges,
nodeId,
posMap,
}: {
edges: KnowledgeEdge[];
nodeId: string;
posMap: Map<string, NodePosition>;
}): React.ReactNode {
const succs = edges
.filter((e) => e.from === nodeId)
.map((e) => posMap.get(e.to)?.node)
.filter((n): n is KnowledgeNode => n !== undefined);
if (succs.length === 0) return null;
return (
<div className="mt-5">
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-2">
</p>
<ul className="space-y-1">
{succs.map((n) => (
<li key={n.id} className="text-sm text-ink">
{n.name}
<span className="ml-2 text-tiny text-ink-muted">
{n.subject}
</span>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
/**
* KnowledgeGraph 页面 - 知识图谱可视化
*
* 数据来源P4 扩展MSW mockGraphQL KnowledgeGraphQuery
* - 支持按科目筛选(下拉选择)
* - 纯 SVG 绘制节点关系图(不安装 d3/recharts
* - CSR + @next/dynamic 懒加载ssr: false
*
* 维护者ai13teacher-portal
*/
import { useState } from "react";
import dynamic from "next/dynamic";
import { useQuery } from "urql";
import { Loading, Empty } from "@edu/ui-components";
import { KnowledgeGraphQuery } from "@/lib/graphql-p4";
import type { KnowledgeGraphData } from "@/lib/graphql-p4";
import { KNOWLEDGE_SUBJECTS } from "@/mocks/fixtures/knowledge-graph";
// 懒加载 SVG 可视化组件CSR only避免 SSR 报 window 未定义)
const KnowledgeGraphView = dynamic(
() => import("./KnowledgeGraphView"),
{
ssr: false,
loading: () => <Loading lines={6} />,
},
);
export default function KnowledgeGraphPage() {
const [subject, setSubject] = useState<string>("全部");
const [result] = useQuery({
query: KnowledgeGraphQuery,
variables: { subject: subject === "全部" ? null : subject },
});
const graph: KnowledgeGraphData | undefined = result.data?.knowledgeGraph;
return (
<div className="px-10 py-10">
<header className="mb-8">
<h1 className="text-3xl font-serif text-ink"></h1>
<p className="mt-1 text-sm text-ink-muted">
GraphQL KnowledgeGraphQuery · P4 MSW mock
</p>
</header>
<div className="rule-thin mb-8" />
{/* 科目筛选 */}
<div className="mb-6 flex items-baseline gap-3">
<label className="text-tiny uppercase tracking-wide text-ink-muted">
</label>
<select
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
>
{KNOWLEDGE_SUBJECTS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
{graph ? (
<span className="ml-2 text-tiny text-ink-muted">
{graph.nodes.length} · {graph.edges.length}
</span>
) : null}
</div>
{result.fetching ? (
<Loading lines={6} />
) : result.error ? (
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
<p className="text-sm px-3 text-danger">
{result.error.message}
</p>
</div>
) : !graph || graph.nodes.length === 0 ? (
<Empty title="暂无知识图谱数据" description="该科目尚未录入知识点" />
) : (
<KnowledgeGraphView nodes={graph.nodes} edges={graph.edges} />
)}
</div>
);
}