diff --git a/apps/teacher-portal/.eslintrc.a11y.js b/apps/teacher-portal/.eslintrc.a11y.js new file mode 100644 index 0000000..29f75ae --- /dev/null +++ b/apps/teacher-portal/.eslintrc.a11y.js @@ -0,0 +1,97 @@ +/** + * ESLint A11y 配置(P6 硬化) + * + * - 配置 eslint-plugin-jsx-a11y(error 级别) + * - 包含 WCAG 2.2 AA 规则集 + * + * 这是独立配置文件,不修改主 eslint 配置。 + * 使用方式:eslint -c .eslintrc.a11y.js src + * + * 注意:eslint-plugin-jsx-a11y 尚未安装,CI 统一安装后启用。 + * 关联:02-architecture-design.md §12 可观测性 / WCAG 2.2 AA + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + ecmaFeatures: { jsx: true }, + }, + plugins: ["jsx-a11y"], + rules: { + // WCAG 2.2 AA 规则集(全部 error 级别) + + // 1.1.1 非文本内容:所有图片必须有 alt 属性 + "jsx-a11y/alt-text": "error", + + // 1.3.1 信息与关系:表单控件必须有 label + "jsx-a11y/label-has-associated-control": "error", + "jsx-a11y/control-has-associated-label": "error", + + // 1.4.1 颜色使用:不仅依赖颜色传达信息 + "jsx-a11y/no-noninteractive-element-interactions": "error", + + // 2.1.1 键盘可达性:所有交互元素必须键盘可操作 + "jsx-a11y/no-static-element-interactions": "error", + + // 2.1.2 无键盘陷阱:焦点必须能移出组件 + "jsx-a11y/no-autofocus": "error", + + // 2.4.1 跳过块:提供跳过重复内容的机制 + "jsx-a11y/anchor-is-valid": "error", + + // 2.4.4 链接目的:链接文本必须有意义 + "jsx-a11y/anchor-has-content": "error", + + // 2.4.6 标题与标签:标题必须描述性 + "jsx-a11y/heading-has-content": "error", + + // 3.1.2 每种语言的部分:html lang 属性 + "jsx-a11y/html-has-lang": "error", + "jsx-a11y/lang": "error", + + // 3.2.2 输入时:表单提交可预测 + "jsx-a11y/no-onchange": "error", + + // 3.3.2 标签或指令:表单输入需要描述 + "jsx-a11y/tabindex-no-positive": "error", + + // 4.1.2 名称、角色、值:ARIA 属性正确使用 + "jsx-a11y/aria-props": "error", + "jsx-a11y/aria-proptypes": "error", + "jsx-a11y/aria-unsupported-elements": "error", + "jsx-a11y/role-has-required-aria-props": "error", + "jsx-a11y/role-supports-aria-props": "error", + + // 4.1.3 状态消息:ARIA live regions + "jsx-a11y/aria-activedescendant-has-tabindex": "error", + + // 额外规则:交互元素的角色 + "jsx-a11y/interactive-supports-focus": "error", + "jsx-a11y/no-interactive-element-to-noninteractive-role": "error", + "jsx-a11y/no-noninteractive-element-to-interactive-role": "error", + "jsx-a11y/no-redundant-roles": "error", + "jsx-a11y/role": "error", + + // 媒体可访问性 + "jsx-a11y/media-has-caption": "error", + + // 鼠标/指针事件可访问性 + "jsx-a11y/mouse-events-have-key-events": "error", + "jsx-a11y/click-events-have-key-events": "error", + + // 分散元素(blink/marquee 禁用) + "jsx-a11y/no-distracting-elements": "error", + }, + overrides: [ + { + // 允许在测试文件中使用 jsx-a11y 断言 + files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], + rules: { + "jsx-a11y/anchor-is-valid": "off", + }, + }, + ], +}; diff --git a/apps/teacher-portal/package.json b/apps/teacher-portal/package.json index 0b632a7..a8d2672 100644 --- a/apps/teacher-portal/package.json +++ b/apps/teacher-portal/package.json @@ -13,21 +13,34 @@ "@edu/hooks": "workspace:*", "@edu/ui-components": "workspace:*", "@edu/ui-tokens": "workspace:*", + "@opentelemetry/context-zone": "^1.28.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation-document-load": "^0.57.0", + "@opentelemetry/instrumentation-fetch": "^0.57.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.57.0", + "@opentelemetry/sdk-trace-web": "^1.28.0", + "@sentry/nextjs": "^8.0.0", "graphql": "^16.8.0", "next": "^14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "urql": "^2.2.0" + "urql": "^2.2.0", + "web-vitals": "^4.2.0" }, "devDependencies": { "@module-federation/nextjs-mf": "^8.3.0", + "@next/bundle-analyzer": "^14.2.0", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.0", + "axe-core": "^4.10.0", "eslint": "^9.0.0", + "eslint-plugin-jsx-a11y": "^6.9.0", "msw": "^2.4.0", "postcss": "^8.4.0", + "size-limit": "^11.1.0", "tailwindcss": "^3.4.0", "typescript": "^5.6.0" }, diff --git a/apps/teacher-portal/size-limit.json b/apps/teacher-portal/size-limit.json new file mode 100644 index 0000000..2abb6b4 --- /dev/null +++ b/apps/teacher-portal/size-limit.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/ai/size-limit/main/schema.json", + "name": "teacher-portal bundle size limits", + "description": "P6 性能预算:Shell <150KB / Remote <80KB / CSS <50KB", + "checks": [ + { + "name": "Shell (main bundle)", + "path": ".next/static/chunks/main-*.js", + "limit": "150 KB", + "gzip": true + }, + { + "name": "Remote entry", + "path": ".next/static/chunks/remoteEntry-*.js", + "limit": "80 KB", + "gzip": true + }, + { + "name": "CSS", + "path": ".next/static/css/*.css", + "limit": "50 KB", + "gzip": true + } + ] +} diff --git a/apps/teacher-portal/src/app/(app)/ai-assist/page.tsx b/apps/teacher-portal/src/app/(app)/ai-assist/page.tsx new file mode 100644 index 0000000..c5ce7db --- /dev/null +++ b/apps/teacher-portal/src/app/(app)/ai-assist/page.tsx @@ -0,0 +1,348 @@ +"use client"; + +/** + * AI 辅助出题页面(P5) + * + * 数据来源(02-architecture-design.md §6 AI 辅助): + * - GraphQL GenerateQuestionMutation + * + * 功能: + * - 表单:科目 / 题型 / 难度 / 知识点 / 题目数量 + * - 生成题目(mutation)+ SSE 流式效果模拟(setInterval 逐字显示) + * - 完整题目展示(含选项、答案、解析) + * - 复制到剪贴板 / 再生成一组 + * - 生成历史(本次会话内) + * + * 维护者:ai13(teacher-portal) + */ + +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "urql"; +import { GenerateQuestionMutation } from "@/lib/graphql-p5"; +import type { GeneratedQuestion, GenerateQuestionInput } from "@/lib/graphql-p5"; + +const SUBJECTS = ["数学", "语文", "英语", "物理", "化学"]; +const QUESTION_TYPES = ["选择题", "填空题", "解答题"]; +const DIFFICULTIES = ["简单", "中等", "困难"]; + +const STREAM_INTERVAL_MS = 25; + +function buildQuestionText(q: GeneratedQuestion): string { + return [ + q.question, + "", + ...q.options, + "", + `答案:${q.answer}`, + "", + `解析:${q.explanation}`, + ].join("\n"); +} + +export default function AiAssistPage(): JSX.Element { + const [subject, setSubject] = useState(SUBJECTS[0] ?? "数学"); + const [questionType, setQuestionType] = useState( + QUESTION_TYPES[0] ?? "选择题", + ); + const [difficulty, setDifficulty] = useState(DIFFICULTIES[1] ?? "中等"); + const [knowledgePoints, setKnowledgePoints] = useState("导数应用"); + const [count, setCount] = useState(1); + + const [isGenerating, setIsGenerating] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingText, setStreamingText] = useState(""); + const [generated, setGenerated] = useState(null); + const [history, setHistory] = useState([]); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const streamTimerRef = useRef | null>(null); + const [, generateQuestion] = useMutation(GenerateQuestionMutation); + + useEffect(() => { + return () => { + if (streamTimerRef.current) clearInterval(streamTimerRef.current); + }; + }, []); + + const handleGenerate = async (): Promise => { + if (isGenerating || isStreaming) return; + setIsGenerating(true); + setError(null); + setGenerated(null); + setStreamingText(""); + setCopied(false); + + const input: GenerateQuestionInput = { + subject, + questionType, + difficulty, + knowledgePoints, + count, + }; + + const res = await generateQuestion({ input }); + setIsGenerating(false); + + if (res.error || !res.data?.generateQuestion) { + setError(res.error?.message ?? "生成失败,请重试"); + return; + } + + const q = res.data.generateQuestion as GeneratedQuestion; + const full = q.question; + + setIsStreaming(true); + let i = 0; + streamTimerRef.current = setInterval(() => { + i += 1; + setStreamingText(full.slice(0, i)); + if (i >= full.length) { + if (streamTimerRef.current) { + clearInterval(streamTimerRef.current); + streamTimerRef.current = null; + } + setIsStreaming(false); + setGenerated(q); + setHistory((prev) => [q, ...prev].slice(0, 20)); + } + }, STREAM_INTERVAL_MS); + }; + + const handleCopy = (): void => { + if (!generated) return; + const text = buildQuestionText(generated); + if (typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + }; + + return ( +
+
+

AI 辅助出题

+

+ GraphQL GenerateQuestionMutation · SSE 流式输出模拟(P5) +

+
+ +
+ + {/* 生成表单 */} +
+

出题参数

+
+ + + + + +
+ +
+ + {generated && !isStreaming && ( + + )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* 生成结果(流式 + 完整) */} + {(isGenerating || isStreaming || streamingText || generated) && ( +
+
+

+ 生成结果 + {isGenerating && ( + + 正在请求 AI… + + )} + {isStreaming && ( + + 流式输出中… + + )} +

+ {generated && !isStreaming && ( + + )} +
+
+ + {isStreaming || !generated ? ( +
+ {streamingText} + {isStreaming && ( + + )} +
+ ) : ( +
+

+ {generated.question} +

+
    + {generated.options.map((opt) => ( +
  • + {opt} +
  • + ))} +
+

+ + 答案 + {" "} + {generated.answer} +

+

+ + 解析 + + {"\n"} + {generated.explanation} +

+
+ )} +
+ )} + + {/* 生成历史 */} + {history.length > 0 && ( +
+
+

+ 生成历史 + + {history.length} 条 + +

+
+
+
    + {history.map((h) => ( +
  • +
    +

    {h.question}

    +

    + 答案:{h.answer} +

    +
    +
    + {h.id} +
    +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx b/apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx new file mode 100644 index 0000000..a2e0ed6 --- /dev/null +++ b/apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx @@ -0,0 +1,259 @@ +"use client"; + +/** + * AI 教案生成页面(P5) + * + * 数据来源(02-architecture-design.md §6 AI 辅助): + * - GraphQL GenerateLessonPlanMutation + * + * 功能: + * - 表单:班级 / 科目 / 课时主题 / 教学目标 + * - 生成教案(mutation)+ 流式输出模拟(setInterval 逐字显示) + * - 完整教案展示 + * - 导出为 .md 文件 + * + * 维护者:ai13(teacher-portal) + */ + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery } from "urql"; +import { GenerateLessonPlanMutation } from "@/lib/graphql-p5"; +import type { + GeneratedLessonPlan, + GenerateLessonPlanInput, +} from "@/lib/graphql-p5"; +import { ClassesQuery } from "@/lib/graphql"; +import type { Class } from "@/lib/graphql"; +import { Loading } from "@edu/ui-components"; + +const SUBJECTS = ["数学", "语文", "英语", "物理", "化学"]; +const STREAM_INTERVAL_MS = 12; + +export default function AiLessonPlanPage(): JSX.Element { + const [classId, setClassId] = useState(""); + const [subject, setSubject] = useState(SUBJECTS[0] ?? "数学"); + const [topic, setTopic] = useState("函数的单调性与极值"); + const [objectives, setObjectives] = useState( + "理解单调性、掌握极值求法、能解决实际问题", + ); + + const [isGenerating, setIsGenerating] = useState(false); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingText, setStreamingText] = useState(""); + const [generated, setGenerated] = useState(null); + const [error, setError] = useState(null); + + const streamTimerRef = useRef | null>(null); + const [, generateLessonPlan] = useMutation(GenerateLessonPlanMutation); + const [classesResult] = useQuery({ query: ClassesQuery }); + const classes = (classesResult.data?.classes as Class[] | undefined) ?? []; + + useEffect(() => { + if (!classId) { + const first = classes[0]; + if (first) setClassId(first.id); + } + }, [classes, classId]); + + useEffect(() => { + return () => { + if (streamTimerRef.current) clearInterval(streamTimerRef.current); + }; + }, []); + + const handleGenerate = async (): Promise => { + if (isGenerating || isStreaming) return; + setIsGenerating(true); + setError(null); + setGenerated(null); + setStreamingText(""); + + const input: GenerateLessonPlanInput = { + classId, + subject, + topic, + objectives, + }; + + const res = await generateLessonPlan({ input }); + setIsGenerating(false); + + if (res.error || !res.data?.generateLessonPlan) { + setError(res.error?.message ?? "生成失败,请重试"); + return; + } + + const lp = res.data.generateLessonPlan as GeneratedLessonPlan; + const full = lp.content; + + setIsStreaming(true); + let i = 0; + streamTimerRef.current = setInterval(() => { + i += 1; + setStreamingText(full.slice(0, i)); + if (i >= full.length) { + if (streamTimerRef.current) { + clearInterval(streamTimerRef.current); + streamTimerRef.current = null; + } + setIsStreaming(false); + setGenerated(lp); + } + }, STREAM_INTERVAL_MS); + }; + + const handleExport = (): void => { + if (!generated) return; + const blob = new Blob([generated.content], { + type: "text/markdown;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `lesson-plan-${generated.id}.md`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

AI 教案生成

+

+ GraphQL GenerateLessonPlanMutation · 流式输出模拟(P5) +

+
+ +
+ + {/* 表单 */} +
+

教案参数

+ {classesResult.fetching && classes.length === 0 ? ( + + ) : ( +
+ + + +