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:
@@ -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)。
|
||||
*
|
||||
* 维护者:ai13(teacher-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 ≤ 掌握度 < 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">掌握度 < 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>
|
||||
);
|
||||
}
|
||||
90
apps/teacher-portal/src/app/(app)/knowledge-graph/page.tsx
Normal file
90
apps/teacher-portal/src/app/(app)/knowledge-graph/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* KnowledgeGraph 页面 - 知识图谱可视化
|
||||
*
|
||||
* 数据来源(P4 扩展,MSW mock):GraphQL KnowledgeGraphQuery
|
||||
* - 支持按科目筛选(下拉选择)
|
||||
* - 纯 SVG 绘制节点关系图(不安装 d3/recharts)
|
||||
* - CSR + @next/dynamic 懒加载(ssr: false)
|
||||
*
|
||||
* 维护者:ai13(teacher-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user