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:
97
apps/teacher-portal/.eslintrc.a11y.js
Normal file
97
apps/teacher-portal/.eslintrc.a11y.js
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
25
apps/teacher-portal/size-limit.json
Normal file
25
apps/teacher-portal/size-limit.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
348
apps/teacher-portal/src/app/(app)/ai-assist/page.tsx
Normal file
348
apps/teacher-portal/src/app/(app)/ai-assist/page.tsx
Normal file
@@ -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<string>(SUBJECTS[0] ?? "数学");
|
||||
const [questionType, setQuestionType] = useState<string>(
|
||||
QUESTION_TYPES[0] ?? "选择题",
|
||||
);
|
||||
const [difficulty, setDifficulty] = useState<string>(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<GeneratedQuestion | null>(null);
|
||||
const [history, setHistory] = useState<GeneratedQuestion[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const streamTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [, generateQuestion] = useMutation(GenerateQuestionMutation);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamTimerRef.current) clearInterval(streamTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
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 (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">AI 辅助出题</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL GenerateQuestionMutation · SSE 流式输出模拟(P5)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 生成表单 */}
|
||||
<section className="mb-8 max-w-3xl">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">出题参数</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
科目
|
||||
</span>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
题目类型
|
||||
</span>
|
||||
<select
|
||||
value={questionType}
|
||||
onChange={(e) => setQuestionType(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
难度等级
|
||||
</span>
|
||||
<select
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{DIFFICULTIES.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
题目数量
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Number(e.target.value) || 1)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
知识点
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={knowledgePoints}
|
||||
onChange={(e) => setKnowledgePoints(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
placeholder="例如:导数应用、极值判定"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || isStreaming}
|
||||
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? "生成中…" : "生成题目"}
|
||||
</button>
|
||||
{generated && !isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || isStreaming}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
再生成一组
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果(流式 + 完整) */}
|
||||
{(isGenerating || isStreaming || streamingText || generated) && (
|
||||
<section className="mb-10">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
生成结果
|
||||
{isGenerating && (
|
||||
<span className="ml-3 text-sm font-sans text-ink-muted">
|
||||
正在请求 AI…
|
||||
</span>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<span className="ml-3 text-sm font-sans text-accent">
|
||||
流式输出中…
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{generated && !isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
{copied ? "已复制 ✓" : "复制到剪贴板"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{isStreaming || !generated ? (
|
||||
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24">
|
||||
{streamingText}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 border border-rule rounded-card bg-subtle">
|
||||
<h3 className="text-lg font-serif text-ink mb-3">
|
||||
{generated.question}
|
||||
</h3>
|
||||
<ul className="space-y-1 mb-4">
|
||||
{generated.options.map((opt) => (
|
||||
<li
|
||||
key={opt}
|
||||
className={`text-sm ${
|
||||
opt.startsWith(generated.answer + ".")
|
||||
? "text-accent font-serif"
|
||||
: "text-ink-muted"
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-ink mb-2">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
答案
|
||||
</span>{" "}
|
||||
<span className="font-serif text-accent">{generated.answer}</span>
|
||||
</p>
|
||||
<p className="text-sm text-ink-muted whitespace-pre-wrap">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
解析
|
||||
</span>
|
||||
{"\n"}
|
||||
{generated.explanation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 生成历史 */}
|
||||
{history.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
生成历史
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{history.length} 条
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
<ul className="space-y-0">
|
||||
{history.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="py-3 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-9">
|
||||
<h3 className="text-sm font-serif text-ink">{h.question}</h3>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
答案:{h.answer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-3 text-right text-tiny font-mono text-ink-subtle">
|
||||
{h.id}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx
Normal file
259
apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx
Normal file
@@ -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<string>(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<GeneratedLessonPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const streamTimerRef = useRef<ReturnType<typeof setInterval> | 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<void> => {
|
||||
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 (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">AI 教案生成</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL GenerateLessonPlanMutation · 流式输出模拟(P5)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 表单 */}
|
||||
<section className="mb-8 max-w-3xl">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">教案参数</h2>
|
||||
{classesResult.fetching && classes.length === 0 ? (
|
||||
<Loading lines={2} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</span>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
科目
|
||||
</span>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
课时主题
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
教学目标
|
||||
</span>
|
||||
<textarea
|
||||
value={objectives}
|
||||
onChange={(e) => setObjectives(e.target.value)}
|
||||
rows={3}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink resize-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || isStreaming || !classId}
|
||||
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? "生成中…" : "生成教案"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果 */}
|
||||
{(isGenerating || isStreaming || streamingText || generated) && (
|
||||
<section className="mb-10">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
教案内容
|
||||
{isGenerating && (
|
||||
<span className="ml-3 text-sm font-sans text-ink-muted">
|
||||
正在请求 AI…
|
||||
</span>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<span className="ml-3 text-sm font-sans text-accent">
|
||||
流式输出中…
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{generated && !isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
导出 .md
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{isStreaming || !generated ? (
|
||||
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24 font-mono">
|
||||
{streamingText}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 border border-rule rounded-card bg-subtle">
|
||||
<p className="text-sm text-ink-muted mb-4">{generated.summary}</p>
|
||||
<pre className="whitespace-pre-wrap text-sm text-ink font-mono leading-relaxed">
|
||||
{generated.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
apps/teacher-portal/src/app/(app)/ai-report/page.tsx
Normal file
289
apps/teacher-portal/src/app/(app)/ai-report/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AI 学情报告页面(P5)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §6 AI 辅助):
|
||||
* - GraphQL GenerateReportMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 表单:班级 / 报告类型(班级周报/月报/单生报告)/ 学生选择(单生报告时)
|
||||
* - 生成报告(mutation)+ 流式输出模拟(setInterval 逐字显示)
|
||||
* - 完整报告展示(含建议)
|
||||
* - 导出为 .md 文件
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "urql";
|
||||
import { GenerateReportMutation } from "@/lib/graphql-p5";
|
||||
import type { GeneratedReport, GenerateReportInput } from "@/lib/graphql-p5";
|
||||
import { ClassesQuery, ClassStudentsQuery } from "@/lib/graphql";
|
||||
import type { Class, Student } from "@/lib/graphql";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
|
||||
const REPORT_TYPES = ["班级周报", "月报", "单生报告"];
|
||||
const STREAM_INTERVAL_MS = 12;
|
||||
|
||||
export default function AiReportPage(): JSX.Element {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [reportType, setReportType] = useState<string>(
|
||||
REPORT_TYPES[0] ?? "班级周报",
|
||||
);
|
||||
const [studentId, setStudentId] = useState("");
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [generated, setGenerated] = useState<GeneratedReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const streamTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [, generateReport] = useMutation(GenerateReportMutation);
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes = (classesResult.data?.classes as Class[] | undefined) ?? [];
|
||||
|
||||
const [studentsResult] = useQuery({
|
||||
query: ClassStudentsQuery,
|
||||
variables: { classId },
|
||||
pause: !classId,
|
||||
});
|
||||
const students =
|
||||
(studentsResult.data?.classStudents as Student[] | undefined) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!classId) {
|
||||
const first = classes[0];
|
||||
if (first) setClassId(first.id);
|
||||
}
|
||||
}, [classes, classId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportType !== "单生报告") {
|
||||
setStudentId("");
|
||||
}
|
||||
}, [reportType]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamTimerRef.current) clearInterval(streamTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (isGenerating || isStreaming) return;
|
||||
if (reportType === "单生报告" && !studentId) {
|
||||
setError("单生报告需要选择一名学生");
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setGenerated(null);
|
||||
setStreamingText("");
|
||||
|
||||
const input: GenerateReportInput = {
|
||||
classId,
|
||||
reportType,
|
||||
studentId: reportType === "单生报告" ? studentId : undefined,
|
||||
};
|
||||
|
||||
const res = await generateReport({ input });
|
||||
setIsGenerating(false);
|
||||
|
||||
if (res.error || !res.data?.generateReport) {
|
||||
setError(res.error?.message ?? "生成失败,请重试");
|
||||
return;
|
||||
}
|
||||
|
||||
const rpt = res.data.generateReport as GeneratedReport;
|
||||
const full = rpt.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(rpt);
|
||||
}
|
||||
}, 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 = `report-${generated.id}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">AI 学情报告</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL GenerateReportMutation · 流式输出模拟(P5)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 表单 */}
|
||||
<section className="mb-8 max-w-3xl">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">报告参数</h2>
|
||||
{classesResult.fetching && classes.length === 0 ? (
|
||||
<Loading lines={2} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</span>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
报告类型
|
||||
</span>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{REPORT_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{reportType === "单生报告" && (
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生
|
||||
</span>
|
||||
<select
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
<option value="">请选择学生</option>
|
||||
{students.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{studentsResult.fetching && (
|
||||
<span className="text-tiny text-ink-subtle">
|
||||
加载学生名单…
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || isStreaming || !classId}
|
||||
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? "生成中…" : "生成报告"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成结果 */}
|
||||
{(isGenerating || isStreaming || streamingText || generated) && (
|
||||
<section className="mb-10">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
报告内容
|
||||
{isGenerating && (
|
||||
<span className="ml-3 text-sm font-sans text-ink-muted">
|
||||
正在请求 AI…
|
||||
</span>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<span className="ml-3 text-sm font-sans text-accent">
|
||||
流式输出中…
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{generated && !isStreaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
导出 .md
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{isStreaming || !generated ? (
|
||||
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24 font-mono">
|
||||
{streamingText}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 border border-rule rounded-card bg-subtle">
|
||||
<p className="text-sm text-ink-muted mb-4">{generated.summary}</p>
|
||||
<pre className="whitespace-pre-wrap text-sm text-ink font-mono leading-relaxed mb-6">
|
||||
{generated.content}
|
||||
</pre>
|
||||
<div className="pt-4 border-t border-rule">
|
||||
<h3 className="text-sm font-serif text-ink mb-3">教学建议</h3>
|
||||
<ul className="space-y-2">
|
||||
{generated.recommendations.map((rec, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="text-sm text-ink-muted flex gap-2"
|
||||
>
|
||||
<span className="text-accent font-mono">{idx + 1}.</span>
|
||||
<span>{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
apps/teacher-portal/src/app/(app)/analytics/TrendChart.tsx
Normal file
162
apps/teacher-portal/src/app/(app)/analytics/TrendChart.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TrendChart - 纯 SVG 折线图组件
|
||||
*
|
||||
* 用于展示成绩趋势(最近 N 次考试/测验)。
|
||||
* 不依赖 recharts/d3,纯 SVG 手绘(project_rules:不安装新 npm 包)。
|
||||
* 颜色使用 var(--color-accent) 语义令牌(project_rules §3.10)。
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
interface TrendChartProps {
|
||||
/** 数据序列(按时间顺序,最近一次在右) */
|
||||
data: number[];
|
||||
/** Y 轴标签前缀,默认 "第 N 次" */
|
||||
labelPrefix?: string;
|
||||
/** 自定义高度(可选) */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HEIGHT = 200;
|
||||
const PADDING = { top: 20, right: 24, bottom: 32, left: 36 };
|
||||
|
||||
export default function TrendChart({
|
||||
data,
|
||||
labelPrefix = "第",
|
||||
height = DEFAULT_HEIGHT,
|
||||
}: TrendChartProps): React.ReactNode {
|
||||
const width = 480;
|
||||
const innerW = width - PADDING.left - PADDING.right;
|
||||
const innerH = height - PADDING.top - PADDING.bottom;
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-ink-muted">暂无趋势数据</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Y 轴范围:0-100(成绩),或自适应
|
||||
const yMax = Math.max(100, ...data);
|
||||
const yMin = Math.min(0, ...data);
|
||||
const yRange = yMax - yMin || 1;
|
||||
|
||||
// 坐标映射
|
||||
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
const points = data.map((v, i) => {
|
||||
const x = PADDING.left + i * xStep;
|
||||
const y = PADDING.top + innerH - ((v - yMin) / yRange) * innerH;
|
||||
return { x, y, value: v };
|
||||
});
|
||||
|
||||
// 折线路径
|
||||
const linePath = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
|
||||
.join(" ");
|
||||
|
||||
// 填充区域路径(data.length 已在前面校验 > 0,points 至少 1 个元素)
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (!firstPoint || !lastPoint) {
|
||||
return <p className="text-sm text-ink-muted">暂无趋势数据</p>;
|
||||
}
|
||||
const areaPath = `${linePath} L ${lastPoint.x} ${PADDING.top + innerH} L ${firstPoint.x} ${PADDING.top + innerH} Z`;
|
||||
|
||||
// Y 轴刻度(3 条网格线)
|
||||
const gridLines = [0, 0.5, 1].map((ratio) => {
|
||||
const value = yMin + yRange * (1 - ratio);
|
||||
const y = PADDING.top + innerH * ratio;
|
||||
return { y, value: Math.round(value) };
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full max-w-xl border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="成绩趋势折线图"
|
||||
>
|
||||
{/* 网格线 + Y 轴刻度 */}
|
||||
<g>
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
y1={g.y}
|
||||
x2={width - PADDING.right}
|
||||
y2={g.y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 8}
|
||||
y={g.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{g.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="var(--color-accent)"
|
||||
fillOpacity={0.08}
|
||||
/>
|
||||
|
||||
{/* 折线 */}
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
<g>
|
||||
{points.map((p, i) => (
|
||||
<g key={`point-${i}`}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4}
|
||||
fill="var(--color-accent)"
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{p.value}
|
||||
</text>
|
||||
<text
|
||||
x={p.x}
|
||||
y={height - PADDING.bottom + 18}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{labelPrefix}{i + 1}次
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
170
apps/teacher-portal/src/app/(app)/analytics/[studentId]/page.tsx
Normal file
170
apps/teacher-portal/src/app/(app)/analytics/[studentId]/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* StudentAnalytics 详情页 - 单生学情分析
|
||||
*
|
||||
* 数据来源(P4 扩展,MSW mock):GraphQL StudentAnalyticsQuery
|
||||
* - 成绩趋势图(纯 SVG 折线图)
|
||||
* - 弱项 / 强项知识点列表
|
||||
* - 掌握度进度条
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { StudentAnalyticsQuery } from "@/lib/graphql-p4";
|
||||
import type { StudentAnalytics } from "@/lib/graphql-p4";
|
||||
import TrendChart from "../TrendChart";
|
||||
|
||||
/** 按掌握度映射语义色(与知识图谱保持一致) */
|
||||
function masteryColor(rate: number): string {
|
||||
if (rate >= 70) return "var(--color-success)";
|
||||
if (rate >= 40) return "var(--color-warning)";
|
||||
return "var(--color-danger)";
|
||||
}
|
||||
|
||||
export default function StudentAnalyticsPage() {
|
||||
const params = useParams<{ studentId: string }>();
|
||||
const studentId = params?.studentId ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: StudentAnalyticsQuery,
|
||||
variables: { studentId },
|
||||
pause: !studentId,
|
||||
});
|
||||
|
||||
const student: StudentAnalytics | undefined =
|
||||
result.data?.studentAnalytics;
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回学情分析
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-serif text-ink">
|
||||
{student ? student.studentName : "学生详情"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL StudentAnalyticsQuery · 单生学情(P4 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{!studentId ? (
|
||||
<Empty title="未指定学生" description="请从学情分析进入查看学生详情" />
|
||||
) : 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>
|
||||
) : !student ? (
|
||||
<Empty title="暂无数据" description="该学生尚未生成学情分析" />
|
||||
) : (
|
||||
<>
|
||||
{/* 概览卡片 */}
|
||||
<section className="grid grid-cols-2 gap-6 mb-10">
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均分
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-ink">
|
||||
{student.avgScore}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
掌握度
|
||||
</p>
|
||||
<div className="mt-3 flex items-baseline gap-3">
|
||||
<span
|
||||
className="text-4xl font-serif"
|
||||
style={{ color: masteryColor(student.masteryRate) }}
|
||||
>
|
||||
{student.masteryRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-button bg-subtle overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-button"
|
||||
style={{
|
||||
width: `${student.masteryRate}%`,
|
||||
background: masteryColor(student.masteryRate),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 成绩趋势图 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">成绩趋势</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<TrendChart data={student.trend} labelPrefix="第" />
|
||||
</section>
|
||||
|
||||
{/* 强项 + 弱项 */}
|
||||
<section className="grid grid-cols-2 gap-8">
|
||||
{/* 强项 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">强项知识点</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
{student.strongPoints.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">暂无明显强项</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{student.strongPoints.map((sp, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center gap-3 py-2 px-3 border-b border-rule"
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ background: "var(--color-success)" }}
|
||||
/>
|
||||
<span className="text-sm text-ink">{sp}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 弱项 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">弱项知识点</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
{student.weakPoints.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">暂无明显弱项</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{student.weakPoints.map((wp, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center gap-3 py-2 px-3 border-b border-rule"
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ background: "var(--color-danger)" }}
|
||||
/>
|
||||
<span className="text-sm text-ink">{wp}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
apps/teacher-portal/src/app/(app)/analytics/page.tsx
Normal file
287
apps/teacher-portal/src/app/(app)/analytics/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Analytics 页面 - 班级学情分析仪表盘
|
||||
*
|
||||
* 数据来源(P4 扩展,MSW mock):
|
||||
* - GraphQL ClassAnalyticsQuery:班级均分/及格率/趋势/Top 学生/弱项
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 班级选择:URL ?classId=xxx 或下拉选择(同步 URL)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery, ClassQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import { ClassAnalyticsQuery } from "@/lib/graphql-p4";
|
||||
import type { ClassAnalytics } from "@/lib/graphql-p4";
|
||||
import TrendChart from "./TrendChart";
|
||||
|
||||
function AnalyticsContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const classId = searchParams.get("classId") ?? "";
|
||||
|
||||
// 班级列表(下拉选项)
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
// 当前班级详情(显示名称)
|
||||
const [classResult] = useQuery({
|
||||
query: ClassQuery,
|
||||
variables: { id: classId },
|
||||
pause: !classId,
|
||||
});
|
||||
// 班级学情分析
|
||||
const [analyticsResult] = useQuery({
|
||||
query: ClassAnalyticsQuery,
|
||||
variables: { classId },
|
||||
pause: !classId,
|
||||
});
|
||||
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const cls = classResult.data?.class ?? null;
|
||||
const analytics: ClassAnalytics | undefined =
|
||||
analyticsResult.data?.classAnalytics;
|
||||
|
||||
// 掌握度 = Top 学生 masteryRate 均值(classAnalytics 无直接字段,从 topStudents 推算)
|
||||
const masteryRate = useMemo(() => {
|
||||
if (!analytics?.topStudents || analytics.topStudents.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sum = analytics.topStudents.reduce(
|
||||
(acc, s) => acc + s.masteryRate,
|
||||
0,
|
||||
);
|
||||
return Math.round(sum / analytics.topStudents.length);
|
||||
}, [analytics]);
|
||||
|
||||
function selectClass(id: string) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (id) {
|
||||
params.set("classId", id);
|
||||
} else {
|
||||
params.delete("classId");
|
||||
}
|
||||
router.push(`/analytics${params.toString() ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
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 ClassAnalyticsQuery · 班级学情聚合(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={classId}
|
||||
onChange={(e) => selectClass(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"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{cls ? (
|
||||
<span className="ml-2 text-tiny text-ink-muted">
|
||||
{cls.studentCount} 名学生
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!classId ? (
|
||||
<Empty
|
||||
title="请选择班级"
|
||||
description="选择班级后查看学情分析数据"
|
||||
/>
|
||||
) : analyticsResult.fetching ? (
|
||||
<Loading lines={6} />
|
||||
) : analyticsResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{analyticsResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : !analytics ? (
|
||||
<Empty title="暂无学情数据" description="该班级尚未生成分析报告" />
|
||||
) : (
|
||||
<>
|
||||
{/* 概览卡片 */}
|
||||
<section className="grid grid-cols-3 gap-6 mb-10">
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均分
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-ink">
|
||||
{analytics.avgScore.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
及格率
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-success">
|
||||
{analytics.passRate}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
掌握度
|
||||
</p>
|
||||
<p className="mt-3 text-4xl font-serif text-accent">
|
||||
{masteryRate}%
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 成绩趋势图 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
班级成绩趋势
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<TrendChart data={analytics.avgTrend} labelPrefix="第" />
|
||||
</section>
|
||||
|
||||
{/* Top 学生 + 弱项 */}
|
||||
<section className="grid grid-cols-3 gap-8">
|
||||
{/* Top 5 学生 */}
|
||||
<div className="col-span-2">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
Top 5 学生
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<ul className="space-y-0">
|
||||
{analytics.topStudents.map((s, i) => (
|
||||
<li
|
||||
key={s.studentId}
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-1 text-2xl font-serif text-ink-muted">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Link
|
||||
href={`/analytics/${s.studentId}`}
|
||||
className="text-lg font-serif text-ink hover:text-accent"
|
||||
>
|
||||
{s.studentName}
|
||||
</Link>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
{s.strongPoints.length} 项强 · {s.weakPoints.length} 项弱
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-ink">
|
||||
均分
|
||||
<span className="ml-2 text-lg font-serif text-accent">
|
||||
{s.avgScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-3 text-right">
|
||||
<div className="flex items-baseline justify-end gap-2">
|
||||
<span className="text-tiny text-ink-muted">
|
||||
掌握度
|
||||
</span>
|
||||
<span
|
||||
className="text-lg font-serif"
|
||||
style={{
|
||||
color:
|
||||
s.masteryRate >= 70
|
||||
? "var(--color-success)"
|
||||
: s.masteryRate >= 40
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-danger)",
|
||||
}}
|
||||
>
|
||||
{s.masteryRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-button bg-subtle overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-button"
|
||||
style={{
|
||||
width: `${s.masteryRate}%`,
|
||||
background:
|
||||
s.masteryRate >= 70
|
||||
? "var(--color-success)"
|
||||
: s.masteryRate >= 40
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-danger)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
<Link
|
||||
href={`/analytics/${s.studentId}`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
详情
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 班级弱项 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
班级弱项
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
{analytics.weakPoints.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">暂无明显弱项</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{analytics.weakPoints.map((wp, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center gap-3 py-2 px-3 border-b border-rule"
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ background: "var(--color-danger)" }}
|
||||
/>
|
||||
<span className="text-sm text-ink">{wp}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={3} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalyticsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
341
apps/teacher-portal/src/app/(app)/attendance/page.tsx
Normal file
341
apps/teacher-portal/src/app/(app)/attendance/page.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 列表页 - 考勤记录
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceListQuery:按 classId/日期范围/状态筛选 + 分页
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import {
|
||||
AttendanceListQuery,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
AttendanceRecord,
|
||||
AttendanceListFilter,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
|
||||
const ALL_STATUSES: { value: AttendanceStatus; label: string }[] = [
|
||||
{ value: "PRESENT", label: "出勤" },
|
||||
{ value: "LATE", label: "迟到" },
|
||||
{ value: "EARLY_LEAVE", label: "早退" },
|
||||
{ value: "LEAVE", label: "请假" },
|
||||
{ value: "ABSENT", label: "缺勤" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<AttendanceStatus, string> = {
|
||||
PRESENT: "出勤",
|
||||
LATE: "迟到",
|
||||
EARLY_LEAVE: "早退",
|
||||
LEAVE: "请假",
|
||||
ABSENT: "缺勤",
|
||||
};
|
||||
|
||||
function statusColor(status: AttendanceStatus): string {
|
||||
switch (status) {
|
||||
case "PRESENT":
|
||||
return "var(--color-success)";
|
||||
case "LATE":
|
||||
return "var(--color-warning)";
|
||||
case "EARLY_LEAVE":
|
||||
return "var(--color-warning)";
|
||||
case "LEAVE":
|
||||
return "var(--color-accent)";
|
||||
case "ABSENT":
|
||||
return "var(--color-danger)";
|
||||
default:
|
||||
return "var(--color-ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dateMinusDays(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function AttendanceListPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [startDate, setStartDate] = useState(dateMinusDays(6));
|
||||
const [endDate, setEndDate] = useState(todayStr());
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<AttendanceStatus[]>(
|
||||
[],
|
||||
);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
|
||||
const filter: AttendanceListFilter = useMemo(
|
||||
() => ({
|
||||
classId: classId || firstClassId || null,
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
statuses: selectedStatuses.length > 0 ? selectedStatuses : null,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
[classId, firstClassId, startDate, endDate, selectedStatuses, page],
|
||||
);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: AttendanceListQuery,
|
||||
variables: { filter },
|
||||
});
|
||||
|
||||
const data = result.data?.attendanceList;
|
||||
const items: AttendanceRecord[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = data?.totalPages ?? 1;
|
||||
|
||||
const toggleStatus = (s: AttendanceStatus): void => {
|
||||
setSelectedStatuses((prev) =>
|
||||
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
|
||||
);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">考勤记录</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL AttendanceListQuery · 班级/日期/状态筛选 + 分页(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/attendance/sheet"
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
录入考勤
|
||||
</Link>
|
||||
<Link
|
||||
href="/attendance/stats"
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
统计
|
||||
</Link>
|
||||
<Link
|
||||
href="/attendance/report"
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
报告
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-3 gap-6 mb-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={classId || firstClassId}
|
||||
onChange={(e) => {
|
||||
setClassId(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
开始日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
setStartDate(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
结束日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => {
|
||||
setEndDate(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
|
||||
状态(多选)
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ALL_STATUSES.map((s) => {
|
||||
const active = selectedStatuses.includes(s.value);
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(s.value)}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
active
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
) : items.length === 0 ? (
|
||||
<Empty title="暂无考勤记录" description="当前筛选条件下没有记录" />
|
||||
) : (
|
||||
<>
|
||||
{/* 数据表 */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
考勤明细
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
共 {total} 条
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
第 {page} / {totalPages} 页
|
||||
</p>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
日期
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
备注
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-t border-rule">
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{r.className}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{r.date}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{r.studentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{r.studentNo}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-button text-tiny font-mono"
|
||||
style={{
|
||||
color: statusColor(r.status),
|
||||
border: `1px solid ${statusColor(r.status)}`,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-tiny text-ink-muted">
|
||||
{r.note ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
第 {page} 页 / 共 {totalPages} 页(每页 {PAGE_SIZE} 条)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(totalPages, p + 1))
|
||||
}
|
||||
disabled={page >= totalPages}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
apps/teacher-portal/src/app/(app)/attendance/report/page.tsx
Normal file
337
apps/teacher-portal/src/app/(app)/attendance/report/page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 报告页 - 周报/月报 + 打印
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceReportQuery:按 classId + reportType + 日期范围生成报告数据
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* A4 纸质风格 + window.print()
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import { AttendanceReportQuery } from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
AttendanceReport as AttendanceReportData,
|
||||
AttendanceReportType,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
|
||||
function dateMinusDays(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
return dateMinusDays(0);
|
||||
}
|
||||
|
||||
const REPORT_TYPES: { value: AttendanceReportType; label: string; days: number }[] = [
|
||||
{ value: "WEEKLY", label: "周报", days: 7 },
|
||||
{ value: "MONTHLY", label: "月报", days: 30 },
|
||||
];
|
||||
|
||||
export default function AttendanceReportPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [reportType, setReportType] = useState<AttendanceReportType>("WEEKLY");
|
||||
const [endDate, setEndDate] = useState(todayStr());
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
const targetClassId = classId || firstClassId;
|
||||
|
||||
const startDate = useMemo(() => {
|
||||
const preset = REPORT_TYPES.find((r) => r.value === reportType);
|
||||
const days = preset?.days ?? 7;
|
||||
return dateMinusDays(days - 1);
|
||||
}, [reportType]);
|
||||
|
||||
const [reportResult] = useQuery({
|
||||
query: AttendanceReportQuery,
|
||||
variables: {
|
||||
classId: targetClassId,
|
||||
reportType,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
pause: !targetClassId,
|
||||
});
|
||||
|
||||
const report: AttendanceReportData | undefined =
|
||||
reportResult.data?.attendanceReport;
|
||||
|
||||
const handlePrint = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.print();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 print:hidden">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/attendance"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考勤记录
|
||||
</Link>
|
||||
</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">考勤报告</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL AttendanceReportQuery · 周报/月报 · A4 打印(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
{report && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
打印 / 导出 PDF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8 print:hidden" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-8 print:hidden">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={targetClassId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
报告类型
|
||||
</label>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) =>
|
||||
setReportType(e.target.value as AttendanceReportType)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{REPORT_TYPES.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
结束日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-tiny text-ink-muted">
|
||||
报告区间:{startDate} ~ {endDate}({reportType === "WEEKLY" ? "周报" : "月报"})
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{reportResult.fetching ? (
|
||||
<Loading lines={6} />
|
||||
) : reportResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{reportResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : !report ? (
|
||||
<Empty title="暂无报告" description="请选择班级和报告类型" />
|
||||
) : (
|
||||
<article className="bg-surface border border-rule rounded-card p-10 max-w-[820px] mx-auto">
|
||||
{/* 报告标题 */}
|
||||
<header className="text-center mb-8 pb-6 border-b border-rule">
|
||||
<h1 className="text-2xl font-serif text-ink">
|
||||
考勤{report.reportType === "WEEKLY" ? "周" : "月"}报告
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
{report.className} · {report.startDate} ~ {report.endDate}
|
||||
</p>
|
||||
<p className="mt-1 text-tiny font-mono text-ink-muted">
|
||||
生成时间:{new Date(report.generatedAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 班级信息 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-serif text-ink mb-3">班级信息</h2>
|
||||
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<dt className="text-ink-muted">班级名称</dt>
|
||||
<dd className="text-ink">{report.className}</dd>
|
||||
<dt className="text-ink-muted">报告类型</dt>
|
||||
<dd className="text-ink">
|
||||
{report.reportType === "WEEKLY" ? "周报" : "月报"}
|
||||
</dd>
|
||||
<dt className="text-ink-muted">统计区间</dt>
|
||||
<dd className="font-mono text-ink">
|
||||
{report.startDate} ~ {report.endDate}
|
||||
</dd>
|
||||
<dt className="text-ink-muted">记录总数</dt>
|
||||
<dd className="font-mono text-ink">
|
||||
{report.summary.totalRecords} 条
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 统计摘要 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-serif text-ink mb-3">统计摘要</h2>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="p-3 border border-rule rounded-card text-center">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
出勤率
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-serif text-accent">
|
||||
{report.summary.presentRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-rule rounded-card text-center">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
迟到率
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-serif text-warning">
|
||||
{report.summary.lateRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-rule rounded-card text-center">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
早退率
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-serif text-warning">
|
||||
{report.summary.earlyLeaveRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 border border-rule rounded-card text-center">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
请假率
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-serif text-ink">
|
||||
{report.summary.leaveRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-tiny text-ink-muted">
|
||||
预警学生数:{report.summary.warningCount} 名
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 明细表 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-serif text-ink mb-3">学生明细</h2>
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
出勤
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
迟到
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
早退
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
请假
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
缺勤
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
出勤率
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.details.map((d) => (
|
||||
<tr key={d.studentId} className="border-t border-rule">
|
||||
<td className="px-3 py-2 font-mono text-tiny text-ink-muted">
|
||||
{d.studentNo}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-serif text-ink">
|
||||
{d.studentName}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-tiny text-ink">
|
||||
{d.presentCount}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
|
||||
{d.lateCount}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
|
||||
{d.earlyLeaveCount}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-tiny text-ink-muted">
|
||||
{d.leaveCount}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-tiny text-danger">
|
||||
{d.absentCount}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-serif text-ink">
|
||||
{d.presentRate.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 教师签字 */}
|
||||
<section className="mt-12 pt-6 border-t border-rule">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-sm text-ink-muted">
|
||||
<p>班主任签字:________________________</p>
|
||||
<p className="mt-2">日期:________________________</p>
|
||||
</div>
|
||||
<div className="text-sm text-ink-muted text-right">
|
||||
<p>教务主任签字:__________________</p>
|
||||
<p className="mt-2">日期:__________________</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
Normal file
344
apps/teacher-portal/src/app/(app)/attendance/sheet/page.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 录入页 - 批量录入考勤
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceSheetQuery:按 classId + date 拉取学生列表 + 当天状态
|
||||
* - GraphQL SaveAttendanceSheetMutation:批量保存考勤
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import {
|
||||
AttendanceSheetQuery,
|
||||
SaveAttendanceSheetMutation,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
AttendanceSheetItem,
|
||||
SaveAttendanceRecordInput,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
|
||||
const STATUS_OPTIONS: { value: AttendanceStatus; label: string }[] = [
|
||||
{ value: "PRESENT", label: "出勤" },
|
||||
{ value: "LATE", label: "迟到" },
|
||||
{ value: "LEAVE", label: "请假" },
|
||||
{ value: "ABSENT", label: "缺勤" },
|
||||
{ value: "EARLY_LEAVE", label: "早退" },
|
||||
];
|
||||
|
||||
function statusColor(status: AttendanceStatus): string {
|
||||
switch (status) {
|
||||
case "PRESENT":
|
||||
return "var(--color-success)";
|
||||
case "LATE":
|
||||
return "var(--color-warning)";
|
||||
case "EARLY_LEAVE":
|
||||
return "var(--color-warning)";
|
||||
case "LEAVE":
|
||||
return "var(--color-accent)";
|
||||
case "ABSENT":
|
||||
return "var(--color-danger)";
|
||||
default:
|
||||
return "var(--color-ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface RowState {
|
||||
status: AttendanceStatus;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export default function AttendanceSheetPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [date, setDate] = useState(todayStr());
|
||||
const [rows, setRows] = useState<Record<string, RowState>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
|
||||
const targetClassId = classId || firstClassId;
|
||||
|
||||
const [sheetResult, reexecuteSheet] = useQuery({
|
||||
query: AttendanceSheetQuery,
|
||||
variables: { classId: targetClassId, date },
|
||||
pause: !targetClassId || !date,
|
||||
});
|
||||
|
||||
const sheet: AttendanceSheetItem[] = sheetResult.data?.attendanceSheet ?? [];
|
||||
|
||||
const [, saveSheet] = useMutation(SaveAttendanceSheetMutation);
|
||||
|
||||
// 当 sheet 数据变化时同步本地 rows
|
||||
useEffect(() => {
|
||||
const next: Record<string, RowState> = {};
|
||||
for (const item of sheet) {
|
||||
next[item.studentId] = {
|
||||
status: item.status ?? "PRESENT",
|
||||
note: item.note ?? "",
|
||||
};
|
||||
}
|
||||
setRows(next);
|
||||
setSavedMsg(null);
|
||||
setSaveError(null);
|
||||
}, [sheet]);
|
||||
|
||||
const dirtyCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const item of sheet) {
|
||||
const row = rows[item.studentId];
|
||||
if (!row) continue;
|
||||
const originalStatus = item.status ?? "PRESENT";
|
||||
const originalNote = item.note ?? "";
|
||||
if (row.status !== originalStatus || row.note !== originalNote) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [rows, sheet]);
|
||||
|
||||
const handleSaveAll = async (): Promise<void> => {
|
||||
if (!targetClassId || !date) {
|
||||
setSaveError("请选择班级和日期");
|
||||
return;
|
||||
}
|
||||
const records: SaveAttendanceRecordInput[] = [];
|
||||
for (const item of sheet) {
|
||||
const row = rows[item.studentId];
|
||||
if (!row) continue;
|
||||
records.push({
|
||||
studentId: item.studentId,
|
||||
status: row.status,
|
||||
note: row.note.trim() || null,
|
||||
});
|
||||
}
|
||||
if (records.length === 0) {
|
||||
setSaveError("当前无可保存的记录");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
setSavedMsg(null);
|
||||
const res = await saveSheet({
|
||||
input: { classId: targetClassId, date, records },
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setSaveError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setSavedMsg(
|
||||
`已保存 ${res.data?.saveAttendanceSheet?.savedCount ?? records.length} 条记录`,
|
||||
);
|
||||
reexecuteSheet({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/attendance"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考勤记录
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">考勤录入</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL AttendanceSheetQuery + SaveAttendanceSheetMutation · 乐观更新(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级 + 日期选择 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={targetClassId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!targetClassId || !date ? (
|
||||
<Empty
|
||||
title="请选择班级和日期"
|
||||
description="选择后即可拉取学生名单进行批量录入"
|
||||
/>
|
||||
) : sheetResult.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : sheetResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{sheetResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : sheet.length === 0 ? (
|
||||
<Empty title="暂无学生" description="该班级尚未导入学生名单" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
学生考勤录入
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{sheet.length} 名学生 · 已修改 {dirtyCount} 项
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{saveError && (
|
||||
<span className="text-tiny text-danger">{saveError}</span>
|
||||
)}
|
||||
{savedMsg && !submitting && (
|
||||
<span className="text-tiny text-success">{savedMsg}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAll}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "保存中..." : "保存全部"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{/* 录入表格 */}
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
备注
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.map((item) => {
|
||||
const row = rows[item.studentId];
|
||||
const currentStatus = row?.status ?? "PRESENT";
|
||||
return (
|
||||
<tr
|
||||
key={item.studentId}
|
||||
className="border-t border-rule"
|
||||
>
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{item.studentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{item.studentNo}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{STATUS_OPTIONS.map((opt) => {
|
||||
const active = currentStatus === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[item.studentId]: {
|
||||
status: opt.value,
|
||||
note: row?.note ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="px-2 py-0.5 text-tiny rounded-button border transition-colors"
|
||||
style={{
|
||||
color: active
|
||||
? "var(--bg-paper)"
|
||||
: statusColor(opt.value),
|
||||
borderColor: statusColor(opt.value),
|
||||
background: active
|
||||
? statusColor(opt.value)
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={row?.note ?? ""}
|
||||
onChange={(ev) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[item.studentId]: {
|
||||
status: currentStatus,
|
||||
note: ev.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="可选:备注"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
551
apps/teacher-portal/src/app/(app)/attendance/stats/page.tsx
Normal file
551
apps/teacher-portal/src/app/(app)/attendance/stats/page.tsx
Normal file
@@ -0,0 +1,551 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attendance 统计页 - 班级考勤统计 + 趋势图 + 班级对比 + 学生预警
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL AttendanceStatsQuery:班级考勤统计(含 30 天趋势)
|
||||
* - GraphQL AttendanceClassComparisonQuery:5 班级出勤率对比
|
||||
* - GraphQL AttendanceWarningsQuery:学生考勤预警(出勤率 < 90%)
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* SVG 图表参考 grades/analytics/charts.tsx 模式(var(--color-*) 语义令牌)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import {
|
||||
AttendanceStatsQuery,
|
||||
AttendanceClassComparisonQuery,
|
||||
AttendanceWarningsQuery,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
AttendanceStats,
|
||||
AttendanceTrendPoint,
|
||||
AttendanceClassComparison,
|
||||
AttendanceWarning,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
|
||||
function dateMinusDays(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
return dateMinusDays(0);
|
||||
}
|
||||
|
||||
const RANGE_PRESETS: { label: string; days: number }[] = [
|
||||
{ label: "近 7 天", days: 7 },
|
||||
{ label: "近 14 天", days: 14 },
|
||||
{ label: "近 30 天", days: 30 },
|
||||
];
|
||||
|
||||
// ============ 趋势折线图(30 天每日出勤率) ============
|
||||
|
||||
const TREND_PADDING = { top: 24, right: 24, bottom: 36, left: 36 };
|
||||
const TREND_W = 720;
|
||||
const TREND_H = 220;
|
||||
|
||||
function TrendLineChart({ data }: { data: AttendanceTrendPoint[] }): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无趋势数据</p>;
|
||||
}
|
||||
const innerW = TREND_W - TREND_PADDING.left - TREND_PADDING.right;
|
||||
const innerH = TREND_H - TREND_PADDING.top - TREND_PADDING.bottom;
|
||||
const yMax = 100;
|
||||
const yMin = 0;
|
||||
const yRange = yMax - yMin;
|
||||
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
|
||||
const toPoint = (value: number, i: number) => ({
|
||||
x: TREND_PADDING.left + i * xStep,
|
||||
y: TREND_PADDING.top + innerH - ((value - yMin) / yRange) * innerH,
|
||||
});
|
||||
|
||||
const buildPath = (key: "presentRate" | "lateRate" | "absentRate") =>
|
||||
data
|
||||
.map((d, i) => {
|
||||
const p = toPoint(d[key], i);
|
||||
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
const gridLines = [0, 50, 100].map((v) => {
|
||||
const ratio = 1 - (v - yMin) / yRange;
|
||||
return { y: TREND_PADDING.top + innerH * ratio, value: v };
|
||||
});
|
||||
|
||||
const lineColor = (key: "presentRate" | "lateRate" | "absentRate") =>
|
||||
key === "presentRate"
|
||||
? "var(--color-accent)"
|
||||
: key === "lateRate"
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-danger)";
|
||||
|
||||
// X 轴标签最多显示 6 个
|
||||
const labelStep = Math.max(1, Math.ceil(data.length / 6));
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${TREND_W} ${TREND_H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="考勤趋势折线图"
|
||||
>
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={TREND_PADDING.left}
|
||||
y1={g.y}
|
||||
x2={TREND_W - TREND_PADDING.right}
|
||||
y2={g.y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={TREND_PADDING.left - 8}
|
||||
y={g.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{g.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{(["absentRate", "lateRate", "presentRate"] as const).map((key) => (
|
||||
<g key={key}>
|
||||
<path
|
||||
d={buildPath(key)}
|
||||
fill="none"
|
||||
stroke={lineColor(key)}
|
||||
strokeWidth={key === "presentRate" ? 2.5 : 1.5}
|
||||
strokeDasharray={key === "presentRate" ? undefined : "4 3"}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{data.map((d, i) => {
|
||||
const p = toPoint(d[key], i);
|
||||
return (
|
||||
<circle
|
||||
key={`${key}-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={key === "presentRate" ? 3 : 2}
|
||||
fill={lineColor(key)}
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{data.map((d, i) =>
|
||||
i % labelStep === 0 ? (
|
||||
<text
|
||||
key={`x-${i}`}
|
||||
x={TREND_PADDING.left + i * xStep}
|
||||
y={TREND_H - TREND_PADDING.bottom + 18}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.date.slice(5)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 班级对比柱图 ============
|
||||
|
||||
const CMP_W = 480;
|
||||
const CMP_H = 200;
|
||||
const CMP_PAD = { top: 24, right: 16, bottom: 48, left: 36 };
|
||||
|
||||
function ClassComparisonChart({
|
||||
data,
|
||||
}: {
|
||||
data: AttendanceClassComparison[];
|
||||
}): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无班级对比数据</p>;
|
||||
}
|
||||
const maxRate = 100;
|
||||
const innerW = CMP_W - CMP_PAD.left - CMP_PAD.right;
|
||||
const innerH = CMP_H - CMP_PAD.top - CMP_PAD.bottom;
|
||||
const barW = innerW / data.length;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${CMP_W} ${CMP_H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="班级考勤对比柱图"
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio, i) => {
|
||||
const y = CMP_PAD.top + innerH * ratio;
|
||||
const value = Math.round(maxRate * (1 - ratio));
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={CMP_PAD.left}
|
||||
y1={y}
|
||||
x2={CMP_W - CMP_PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={CMP_PAD.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{value}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{data.map((item, i) => {
|
||||
const barH = (item.presentRate / maxRate) * innerH;
|
||||
const x = CMP_PAD.left + i * barW + barW * 0.2;
|
||||
const y = CMP_PAD.top + innerH - barH;
|
||||
const w = barW * 0.6;
|
||||
return (
|
||||
<g key={item.classId}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={Math.max(0, barH)}
|
||||
fill="var(--color-accent)"
|
||||
rx={3}
|
||||
/>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={y - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{item.presentRate}%
|
||||
</text>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={CMP_H - CMP_PAD.bottom + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{item.className}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AttendanceStatsPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [rangeDays, setRangeDays] = useState(7);
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
const targetClassId = classId || firstClassId;
|
||||
|
||||
const startDate = useMemo(() => dateMinusDays(rangeDays - 1), [rangeDays]);
|
||||
const endDate = useMemo(() => todayStr(), []);
|
||||
|
||||
const [statsResult] = useQuery({
|
||||
query: AttendanceStatsQuery,
|
||||
variables: { classId: targetClassId, startDate, endDate },
|
||||
pause: !targetClassId,
|
||||
});
|
||||
|
||||
const [cmpResult] = useQuery({
|
||||
query: AttendanceClassComparisonQuery,
|
||||
variables: { startDate, endDate },
|
||||
});
|
||||
|
||||
const [warningsResult] = useQuery({
|
||||
query: AttendanceWarningsQuery,
|
||||
variables: { classId: targetClassId, startDate, endDate },
|
||||
pause: !targetClassId,
|
||||
});
|
||||
|
||||
const stats: AttendanceStats | undefined = statsResult.data?.attendanceStats;
|
||||
const comparisons: AttendanceClassComparison[] =
|
||||
cmpResult.data?.attendanceClassComparison ?? [];
|
||||
const warnings: AttendanceWarning[] =
|
||||
warningsResult.data?.attendanceWarnings ?? [];
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/attendance"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考勤记录
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">考勤统计</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL AttendanceStatsQuery · 班级统计 + 趋势 + 对比 + 预警(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级 + 时间范围筛选 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={targetClassId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
时间范围
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{RANGE_PRESETS.map((p) => {
|
||||
const active = rangeDays === p.days;
|
||||
return (
|
||||
<button
|
||||
key={p.days}
|
||||
type="button"
|
||||
onClick={() => setRangeDays(p.days)}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
active
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
统计区间:{startDate} ~ {endDate}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{classesResult.fetching || statsResult.fetching ? (
|
||||
<Loading lines={6} />
|
||||
) : statsResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{statsResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : !stats ? (
|
||||
<Empty title="暂无统计数据" description="请选择班级和时间范围" />
|
||||
) : (
|
||||
<>
|
||||
{/* 5 个统计卡片 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
{stats.className} · 统计摘要
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<dl className="grid grid-cols-5 gap-4">
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
出勤率
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-accent">
|
||||
{stats.presentRate.toFixed(1)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
迟到率
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-warning">
|
||||
{stats.lateRate.toFixed(1)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
早退率
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-warning">
|
||||
{stats.earlyLeaveRate.toFixed(1)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
请假率
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-ink">
|
||||
{stats.leaveRate.toFixed(1)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
预警学生
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-danger">
|
||||
{stats.warningCount}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-4 text-tiny text-ink-muted">
|
||||
共 {stats.totalRecords} 条记录 · 出勤 {stats.presentCount} ·
|
||||
迟到 {stats.lateCount} · 早退 {stats.earlyLeaveCount} ·
|
||||
请假 {stats.leaveCount} · 缺勤 {stats.absentCount}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 趋势折线图 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
出勤率趋势({stats.trend.length} 天)
|
||||
</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<TrendLineChart data={stats.trend} />
|
||||
<div className="mt-3 flex items-center gap-4 text-tiny text-ink-muted">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
/>
|
||||
出勤率
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5"
|
||||
style={{
|
||||
background: "var(--color-warning)",
|
||||
borderTop: "1px dashed var(--color-warning)",
|
||||
}}
|
||||
/>
|
||||
迟到率
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block w-3 h-0.5"
|
||||
style={{ background: "var(--color-danger)" }}
|
||||
/>
|
||||
缺勤率
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 班级对比柱图 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">班级出勤率对比</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<ClassComparisonChart data={comparisons} />
|
||||
</section>
|
||||
|
||||
{/* 学生预警表 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
学生考勤预警
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
出勤率 < 90% · 共 {warnings.length} 名
|
||||
</span>
|
||||
</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
{warnings.length === 0 ? (
|
||||
<Empty title="暂无预警" description="所有学生出勤率均达标" />
|
||||
) : (
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
|
||||
出勤率
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
|
||||
缺勤次数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
|
||||
迟到次数
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{warnings.map((w) => (
|
||||
<tr key={w.studentId} className="border-t border-rule">
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{w.studentNo}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{w.studentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-muted">
|
||||
{w.className}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-danger">
|
||||
{w.presentRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{w.absentCount}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{w.lateCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
492
apps/teacher-portal/src/app/(app)/classes/[id]/page.tsx
Normal file
492
apps/teacher-portal/src/app/(app)/classes/[id]/page.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级详情聚合页 - 单班级完整信息
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ClassDetailQuery:聚合班级信息 + 学生 + 课表 + 近期作业 + 概览统计 + 成绩趋势
|
||||
*
|
||||
* 布局:多 widget 用 ErrorBoundary 隔离,单个 widget 异常不影响整页
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ErrorBoundary, Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassDetailQuery } from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
ClassDetailAggregate,
|
||||
ClassDetailTrendPoint,
|
||||
ClassScheduleItem,
|
||||
ClassDetailHomework,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
|
||||
const DAY_LABELS: Record<number, string> = {
|
||||
1: "周一",
|
||||
2: "周二",
|
||||
3: "周三",
|
||||
4: "周四",
|
||||
5: "周五",
|
||||
6: "周六",
|
||||
7: "周日",
|
||||
};
|
||||
|
||||
function todayDayOfWeek(): number {
|
||||
const d = new Date().getDay(); // 0 = Sunday
|
||||
return d === 0 ? 7 : d;
|
||||
}
|
||||
|
||||
/** 班级成绩趋势 mini chart(纯 SVG) */
|
||||
function TrendMiniChart({ data }: { data: ClassDetailTrendPoint[] }): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-tiny text-ink-muted">暂无趋势数据</p>;
|
||||
}
|
||||
const W = 280;
|
||||
const H = 96;
|
||||
const PAD = { top: 12, right: 12, bottom: 18, left: 24 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const values = data.map((d) => d.avgScore);
|
||||
const yMax = Math.max(100, ...values);
|
||||
const yMin = Math.min(0, ...values);
|
||||
const yRange = yMax - yMin || 1;
|
||||
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
const toPoint = (v: number, i: number) => ({
|
||||
x: PAD.left + i * xStep,
|
||||
y: PAD.top + innerH - ((v - yMin) / yRange) * innerH,
|
||||
});
|
||||
const path = data
|
||||
.map((d, i) => {
|
||||
const p = toPoint(d.avgScore, i);
|
||||
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="班级成绩趋势图"
|
||||
>
|
||||
{[0, 50, 100].map((v) => {
|
||||
const ratio = 1 - (v - yMin) / yRange;
|
||||
const y = PAD.top + innerH * ratio;
|
||||
return (
|
||||
<g key={`grid-${v}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={y}
|
||||
x2={W - PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 6}
|
||||
y={y + 3}
|
||||
textAnchor="end"
|
||||
fontSize="9"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{data.map((d, i) => {
|
||||
const p = toPoint(d.avgScore, i);
|
||||
return (
|
||||
<g key={`pt-${i}`}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={3}
|
||||
fill="var(--color-accent)"
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={H - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.month}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 概览统计卡片 */
|
||||
function OverviewCard({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
suffix?: string;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="p-4 border border-rule rounded-card bg-surface">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">{label}</p>
|
||||
<p className="mt-2 text-2xl font-serif text-ink">
|
||||
{value}
|
||||
{suffix && (
|
||||
<span className="ml-1 text-sm font-sans text-ink-muted">{suffix}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 近期作业 widget */
|
||||
function HomeworkWidget({
|
||||
items,
|
||||
}: {
|
||||
items: ClassDetailHomework[];
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 text-lg font-serif text-ink">近期作业</h3>
|
||||
<div className="rule-thin mb-3" />
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">暂无作业</p>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{items.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="py-2 grid grid-cols-12 gap-2 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-5">
|
||||
<p className="text-sm font-serif text-ink">{h.title}</p>
|
||||
</div>
|
||||
<div className="col-span-3 text-tiny font-mono text-ink-muted">
|
||||
{h.dueDate}
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny font-mono text-ink-muted">
|
||||
提交率 {h.submissionRate}%
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
|
||||
{h.avgScore === null ? "未评" : `均分 ${h.avgScore}`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 本周课表 widget */
|
||||
function ScheduleWidget({
|
||||
schedule,
|
||||
}: {
|
||||
schedule: ClassScheduleItem[];
|
||||
}): React.ReactNode {
|
||||
const today = todayDayOfWeek();
|
||||
const todayItems = schedule
|
||||
.filter((s) => s.dayOfWeek === today && today <= 5)
|
||||
.sort((a, b) => a.period - b.period);
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 text-lg font-serif text-ink">
|
||||
今日课表
|
||||
<span className="ml-2 text-tiny font-sans text-ink-muted">
|
||||
{DAY_LABELS[today] ?? "—"}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="rule-thin mb-3" />
|
||||
{todayItems.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">今日无课</p>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{todayItems.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="py-2 grid grid-cols-12 gap-2 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-1 text-tiny font-mono text-ink-muted">
|
||||
{s.period}
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny font-mono text-ink-muted">
|
||||
{s.startTime}-{s.endTime}
|
||||
</div>
|
||||
<div className="col-span-5 text-sm font-serif text-ink">
|
||||
{s.subject}
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny text-ink-muted">
|
||||
{s.teacherName}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
|
||||
{s.room}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生列表 widget */
|
||||
function StudentsWidget({
|
||||
classId,
|
||||
students,
|
||||
}: {
|
||||
classId: string;
|
||||
students: { id: string; name: string; studentNo: string; email: string }[];
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h3 className="text-lg font-serif text-ink">
|
||||
学生列表
|
||||
<span className="ml-2 text-tiny font-sans text-ink-muted">
|
||||
{students.length} 名
|
||||
</span>
|
||||
</h3>
|
||||
<Link
|
||||
href={`/students?classId=${classId}`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
查看全部 →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rule-thin mb-3" />
|
||||
<ul className="grid grid-cols-2 gap-x-6 gap-y-1">
|
||||
{students.slice(0, 12).map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="py-2 grid grid-cols-12 gap-2 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-5 text-sm font-serif text-ink">{s.name}</div>
|
||||
<div className="col-span-5 text-tiny font-mono text-ink-muted">
|
||||
{s.studentNo}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny">
|
||||
<Link
|
||||
href={`/students/${s.id}`}
|
||||
className="font-mono text-ink-muted hover:text-accent"
|
||||
>
|
||||
详情
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClassDetailPage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const classId = params?.id ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ClassDetailQuery,
|
||||
variables: { id: classId },
|
||||
pause: !classId,
|
||||
});
|
||||
|
||||
if (!classId) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="未指定班级" description="请从班级列表进入详情页" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.fetching) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/classes"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回班级列表
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-serif text-ink">班级详情</h1>
|
||||
</header>
|
||||
<div className="rule-thin mb-8" />
|
||||
<div className="mark-left py-2 mb-4 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{result.error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = result.data?.classDetail as ClassDetailAggregate | undefined;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="班级不存在" description="未找到该班级的详情数据" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cls = data.class;
|
||||
const overview = data.overview;
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
{/* 头部 */}
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/classes"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回班级列表
|
||||
</Link>
|
||||
</p>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">{cls.name}</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassDetailQuery · 班级详情聚合(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/classes/schedule?classId=${cls.id}`}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
查看完整课表
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级信息 */}
|
||||
<section className="mb-8 grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">学科</p>
|
||||
<p className="mt-1 text-sm font-serif text-ink">{cls.subject}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">学生数</p>
|
||||
<p className="mt-1 text-sm font-serif text-ink">
|
||||
{cls.studentCount} 名
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">班主任</p>
|
||||
<p className="mt-1 text-sm font-serif text-ink">
|
||||
{cls.homeroomTeacher}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">教室</p>
|
||||
<p className="mt-1 text-sm font-mono text-ink">{cls.room ?? "—"}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 概览统计卡片 */}
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<p className="text-tiny text-danger">概览统计加载失败</p>
|
||||
}
|
||||
>
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-4 text-xl font-serif text-ink">概览统计</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<OverviewCard
|
||||
label="出勤率"
|
||||
value={overview.attendanceRate.toFixed(1)}
|
||||
suffix="%"
|
||||
/>
|
||||
<OverviewCard
|
||||
label="平均分"
|
||||
value={overview.averageScore.toFixed(1)}
|
||||
/>
|
||||
<OverviewCard
|
||||
label="作业完成率"
|
||||
value={overview.homeworkCompletionRate.toFixed(1)}
|
||||
suffix="%"
|
||||
/>
|
||||
<OverviewCard
|
||||
label="最近考试"
|
||||
value={
|
||||
overview.recentExamTitle
|
||||
? `${overview.recentExamAvg ?? "—"}`
|
||||
: "无"
|
||||
}
|
||||
suffix={
|
||||
overview.recentExamTitle
|
||||
? `(均分 ${overview.recentExamAvg ?? "—"})`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{overview.recentExamTitle && (
|
||||
<p className="mt-2 text-tiny text-ink-muted">
|
||||
最近考试:{overview.recentExamTitle}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* 双列布局:左列作业 + 学生;右列课表 + 趋势 */}
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<ErrorBoundary
|
||||
fallback={<p className="text-tiny text-danger">作业模块加载失败</p>}
|
||||
>
|
||||
<HomeworkWidget items={data.recentHomework} />
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={<p className="text-tiny text-danger">课表模块加载失败</p>}
|
||||
>
|
||||
<ScheduleWidget schedule={data.schedule} />
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={<p className="text-tiny text-danger">学生列表加载失败</p>}
|
||||
>
|
||||
<StudentsWidget classId={cls.id} students={data.students} />
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={<p className="text-tiny text-danger">趋势图加载失败</p>}
|
||||
>
|
||||
<section>
|
||||
<h3 className="mb-3 text-lg font-serif text-ink">成绩趋势</h3>
|
||||
<div className="rule-thin mb-3" />
|
||||
<TrendMiniChart data={data.trend} />
|
||||
</section>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
apps/teacher-portal/src/app/(app)/classes/schedule/page.tsx
Normal file
250
apps/teacher-portal/src/app/(app)/classes/schedule/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级课表页 - 周课表网格
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ClassScheduleQuery:按 classId 拉取周课表
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 布局:CSS Grid(周一到周五 × 8 节),每个卡片显示 学科/教师/教室
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import { ClassScheduleQuery } from "@/lib/graphql-p7-admin";
|
||||
import type { ClassScheduleItem } from "@/lib/graphql-p7-admin";
|
||||
|
||||
const DAY_LABELS: Record<number, string> = {
|
||||
1: "周一",
|
||||
2: "周二",
|
||||
3: "周三",
|
||||
4: "周四",
|
||||
5: "周五",
|
||||
6: "周六",
|
||||
7: "周日",
|
||||
};
|
||||
|
||||
const DAYS = [1, 2, 3, 4, 5] as const;
|
||||
const PERIODS = [1, 2, 3, 4, 5, 6, 7, 8] as const;
|
||||
|
||||
const PERIOD_TIMES: Record<number, { start: string; end: string }> = {
|
||||
1: { start: "08:00", end: "08:45" },
|
||||
2: { start: "08:55", end: "09:40" },
|
||||
3: { start: "10:00", end: "10:45" },
|
||||
4: { start: "10:55", end: "11:40" },
|
||||
5: { start: "13:30", end: "14:15" },
|
||||
6: { start: "14:25", end: "15:10" },
|
||||
7: { start: "15:25", end: "16:10" },
|
||||
8: { start: "16:20", end: "17:05" },
|
||||
};
|
||||
|
||||
function ScheduleContent(): React.ReactNode {
|
||||
const searchParams = useSearchParams();
|
||||
const initialClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
const [classId, setClassId] = useState(initialClassId);
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
const targetClassId = classId || firstClassId;
|
||||
|
||||
const [scheduleResult] = useQuery({
|
||||
query: ClassScheduleQuery,
|
||||
variables: { classId: targetClassId },
|
||||
pause: !targetClassId,
|
||||
});
|
||||
|
||||
const schedule: ClassScheduleItem[] =
|
||||
(scheduleResult.data?.classSchedule as ClassScheduleItem[] | undefined) ?? [];
|
||||
|
||||
// 按 dayOfWeek + period 分组
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, ClassScheduleItem> = {};
|
||||
for (const item of schedule) {
|
||||
const key = `${item.dayOfWeek}-${item.period}`;
|
||||
map[key] = item;
|
||||
}
|
||||
return map;
|
||||
}, [schedule]);
|
||||
|
||||
const currentClassName =
|
||||
classes.find((c) => c.id === targetClassId)?.name ?? "—";
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/classes"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回班级列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">
|
||||
{currentClassName} - 课表
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassScheduleQuery · 周课表网格(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级筛选 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={targetClassId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!targetClassId ? (
|
||||
<Empty title="请选择班级" description="选择后即可查看该班级的周课表" />
|
||||
) : scheduleResult.fetching ? (
|
||||
<Loading lines={6} />
|
||||
) : scheduleResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{scheduleResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : schedule.length === 0 ? (
|
||||
<Empty title="暂无课表" description="该班级尚未排课" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
周课表
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{schedule.length} 节
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
周一至周五 · 共 {PERIODS.length} 节
|
||||
</p>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{/* 课表网格:第一列为节次,后续 5 列为周一到周五 */}
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `80px repeat(${DAYS.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{/* 表头 */}
|
||||
<div className="px-3 py-2 text-tiny uppercase tracking-wide text-ink-muted bg-subtle border-b border-rule text-center">
|
||||
节次
|
||||
</div>
|
||||
{DAYS.map((day) => (
|
||||
<div
|
||||
key={`head-${day}`}
|
||||
className="px-3 py-2 text-tiny uppercase tracking-wide text-ink-muted bg-subtle border-b border-l border-rule text-center"
|
||||
>
|
||||
{DAY_LABELS[day]}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 8 节 × 5 天 */}
|
||||
{PERIODS.map((period) => {
|
||||
const times = PERIOD_TIMES[period];
|
||||
return (
|
||||
<div key={`row-${period}`} className="contents">
|
||||
{/* 节次标签 */}
|
||||
<div className="px-3 py-3 text-center border-t border-rule bg-subtle">
|
||||
<p className="text-sm font-serif text-ink">{period}</p>
|
||||
{times && (
|
||||
<p className="mt-1 text-tiny font-mono text-ink-muted">
|
||||
{times.start}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{DAYS.map((day) => {
|
||||
const key = `${day}-${period}`;
|
||||
const item = grouped[key];
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`cell-${key}`}
|
||||
className="px-3 py-3 border-t border-l border-rule text-tiny text-ink-muted"
|
||||
>
|
||||
—
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isSelfStudy = item.subject === "自习";
|
||||
return (
|
||||
<div
|
||||
key={`cell-${key}`}
|
||||
className={`px-3 py-3 border-t border-l border-rule ${
|
||||
isSelfStudy ? "bg-subtle" : "bg-surface"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm font-serif ${
|
||||
isSelfStudy ? "text-ink-muted" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{item.subject}
|
||||
</p>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
{item.teacherName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-tiny font-mono text-ink-muted">
|
||||
{item.room}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClassSchedulePage(): React.ReactNode {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={4} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ScheduleContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
190
apps/teacher-portal/src/app/(app)/course-plans/[id]/page.tsx
Normal file
190
apps/teacher-portal/src/app/(app)/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课程计划详情页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL CoursePlanDetailQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 基本信息:标题/学科/年级/学年/学期/学时
|
||||
* - 章节列表(每章:标题/学时/教学目标/重难点/教学活动)
|
||||
* - 关联教材入口(→ /textbooks/[id])
|
||||
* - 关联作业入口(→ /homework?coursePlanId=xxx)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { CoursePlanDetailQuery } from "@/lib/graphql-p7-insights";
|
||||
import type { CoursePlanDetail as CoursePlanDetailType } from "@/lib/graphql-p7-insights";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
export default function CoursePlanDetailPage(): JSX.Element {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: CoursePlanDetailQuery,
|
||||
variables: { id },
|
||||
pause: !id,
|
||||
});
|
||||
|
||||
const plan: CoursePlanDetailType | undefined = result.data?.coursePlanDetail;
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/course-plans"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回课程计划列表
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-serif text-ink">
|
||||
{plan?.title ?? "课程计划详情"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL CoursePlanDetailQuery · 课程计划详情与章节(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{!id ? (
|
||||
<Empty title="未指定课程计划" description="请从列表进入" />
|
||||
) : 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>
|
||||
) : !plan ? (
|
||||
<Empty title="课程计划不存在" description="可能已被删除" />
|
||||
) : (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<section className="mb-8 grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学科 / 年级
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-serif text-ink">
|
||||
{plan.subject} · {plan.grade}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学年 / 学期
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-serif text-ink">
|
||||
{plan.academicYear} {plan.semester}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总学时
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-accent">
|
||||
{plan.totalHours}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
|
||||
{STATUS_LABEL[plan.status] ?? plan.status}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
关联教材
|
||||
</p>
|
||||
{plan.textbookId ? (
|
||||
<Link
|
||||
href={`/textbooks/${plan.textbookId}`}
|
||||
className="mt-2 inline-block text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
{plan.textbookTitle ?? plan.textbookId} →
|
||||
</Link>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-ink-muted">未关联</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
关联作业
|
||||
</p>
|
||||
<Link
|
||||
href={`/homework?coursePlanId=${plan.id}`}
|
||||
className="mt-2 inline-block text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
{plan.homeworkCount} 份作业 →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 章节列表 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">章节列表</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{plan.chapters.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">暂无章节</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{plan.chapters.map((ch) => (
|
||||
<li
|
||||
key={ch.id}
|
||||
className="p-5 border border-rule rounded-card bg-surface"
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<h3 className="text-lg font-serif text-ink">
|
||||
{ch.title}
|
||||
</h3>
|
||||
<span className="text-tiny font-mono text-ink-muted">
|
||||
{ch.hours} 学时
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
教学目标
|
||||
</p>
|
||||
<p className="text-ink">{ch.objectives}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
重难点
|
||||
</p>
|
||||
<p className="text-ink">{ch.keyPoints}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
教学活动
|
||||
</p>
|
||||
<p className="text-ink">{ch.activities}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
apps/teacher-portal/src/app/(app)/course-plans/page.tsx
Normal file
366
apps/teacher-portal/src/app/(app)/course-plans/page.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课程计划列表页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL CoursePlansQuery + CreateCoursePlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 状态筛选(draft/published/archived)
|
||||
* - 列表:标题/学科/年级/学年/状态/操作
|
||||
* - "新建课程计划" 弹窗(模态框)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
CoursePlansQuery,
|
||||
CreateCoursePlanMutation,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
CoursePlanItem,
|
||||
CoursePlanStatus,
|
||||
CreateCoursePlanInput,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
type StatusFilter = "ALL" | CoursePlanStatus;
|
||||
|
||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "全部" },
|
||||
{ value: "DRAFT", label: "草稿" },
|
||||
{ value: "PUBLISHED", label: "已发布" },
|
||||
{ value: "ARCHIVED", label: "已归档" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<CoursePlanStatus, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
const SUBJECTS = ["语文", "数学", "英语"] as const;
|
||||
const GRADES = ["小学三年级", "小学六年级", "初中一年级", "高中一年级", "高中三年级"] as const;
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return iso.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default function CoursePlansPage(): JSX.Element {
|
||||
const [status, setStatus] = useState<StatusFilter>("ALL");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: CoursePlansQuery,
|
||||
variables: status === "ALL" ? {} : { status },
|
||||
});
|
||||
|
||||
const [, createCoursePlan] = useMutation(CreateCoursePlanMutation);
|
||||
|
||||
// 新建表单 state
|
||||
const [form, setForm] = useState<{
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
totalHours: number;
|
||||
textbookId: string;
|
||||
}>({
|
||||
title: "",
|
||||
subject: SUBJECTS[0] ?? "数学",
|
||||
grade: GRADES[0] ?? "小学三年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "上学期",
|
||||
totalHours: 48,
|
||||
textbookId: "",
|
||||
});
|
||||
|
||||
const plans: CoursePlanItem[] = result.data?.coursePlans ?? [];
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!form.title) {
|
||||
setError("请填写标题");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
const input: CreateCoursePlanInput = {
|
||||
title: form.title,
|
||||
subject: form.subject,
|
||||
grade: form.grade,
|
||||
academicYear: form.academicYear,
|
||||
semester: form.semester,
|
||||
totalHours: form.totalHours,
|
||||
textbookId: form.textbookId || null,
|
||||
};
|
||||
const res = await createCoursePlan({ input });
|
||||
setCreating(false);
|
||||
if (res.error || !res.data?.createCoursePlan) {
|
||||
setError(res.error?.message ?? "创建失败");
|
||||
return;
|
||||
}
|
||||
setModalOpen(false);
|
||||
setForm((prev) => ({ ...prev, title: "" }));
|
||||
};
|
||||
|
||||
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 CoursePlansQuery · 学期/学年课程计划管理(P7-insights,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as StatusFilter)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建课程计划
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : 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>
|
||||
) : plans.length === 0 ? (
|
||||
<Empty
|
||||
title="暂无课程计划"
|
||||
description="点击右上角「新建课程计划」开始创建"
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">标题</th>
|
||||
<th className="py-3 pr-4 font-normal">学科</th>
|
||||
<th className="py-3 pr-4 font-normal">年级</th>
|
||||
<th className="py-3 pr-4 font-normal">学年</th>
|
||||
<th className="py-3 pr-4 font-normal">学期</th>
|
||||
<th className="py-3 pr-4 font-normal">学时</th>
|
||||
<th className="py-3 pr-4 font-normal">状态</th>
|
||||
<th className="py-3 pr-4 font-normal">更新时间</th>
|
||||
<th className="py-3 pr-4 font-normal text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((p) => (
|
||||
<tr key={p.id} className="border-b border-rule">
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={`/course-plans/${p.id}`}
|
||||
className="font-serif text-ink hover:text-accent"
|
||||
>
|
||||
{p.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink">{p.subject}</td>
|
||||
<td className="py-3 pr-4 text-ink">{p.grade}</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
|
||||
{p.academicYear}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink">{p.semester}</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
|
||||
{p.totalHours}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
|
||||
{STATUS_LABEL[p.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
|
||||
{formatDate(p.updatedAt)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<Link
|
||||
href={`/course-plans/${p.id}`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
查看
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* 新建弹窗 */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "hsl(var(--ink) / 0.4)" }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="新建课程计划"
|
||||
>
|
||||
<div className="w-full max-w-lg p-6 border border-rule rounded-card bg-paper">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">新建课程计划</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
标题
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学科
|
||||
</span>
|
||||
<select
|
||||
value={form.subject}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, subject: e.target.value }))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
年级
|
||||
</span>
|
||||
<select
|
||||
value={form.grade}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, grade: e.target.value }))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{GRADES.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学年
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.academicYear}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, academicYear: e.target.value }))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学期
|
||||
</span>
|
||||
<select
|
||||
value={form.semester}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, semester: e.target.value }))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
<option value="上学期">上学期</option>
|
||||
<option value="下学期">下学期</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总学时
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.totalHours}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
totalHours: Number(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
教材 ID(可选)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.textbookId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, textbookId: e.target.value }))
|
||||
}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-danger">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(false)}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{creating ? "创建中…" : "创建"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级诊断视图(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL ClassDiagnosticQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 顶部:班级基本信息 + 掌握度摘要(5 知识点 avg)
|
||||
* - 知识点掌握度列表(10 知识点,每点 avg/中位数/学生数/掌握分布)
|
||||
* - 学生掌握度排名表
|
||||
* - 监控埋点(mock console.log)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassDiagnosticQuery } from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
ClassDiagnostic,
|
||||
DiagnosticKpMastery,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
function masteryColor(rate: number): string {
|
||||
if (rate >= 70) return "var(--color-success)";
|
||||
if (rate >= 40) return "var(--color-warning)";
|
||||
return "var(--color-danger)";
|
||||
}
|
||||
|
||||
export default function ClassDiagnosticPage(): JSX.Element {
|
||||
const params = useParams<{ classId: string }>();
|
||||
const classId = params?.classId ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ClassDiagnosticQuery,
|
||||
variables: { classId },
|
||||
pause: !classId,
|
||||
});
|
||||
|
||||
const diag: ClassDiagnostic | undefined = result.data?.classDiagnostic;
|
||||
|
||||
// 监控埋点(mock console.log)
|
||||
useEffect(() => {
|
||||
if (diag) {
|
||||
console.log("[diagnostic-monitor] class-view-loaded", {
|
||||
classId,
|
||||
className: diag.className,
|
||||
studentCount: diag.studentCount,
|
||||
kpCount: diag.kpMastery.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}, [diag, classId]);
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/diagnostic"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回诊断报告
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-serif text-ink">
|
||||
{diag?.className ?? "班级诊断"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassDiagnosticQuery · 班级知识点掌握度与学生排名(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{!classId ? (
|
||||
<Empty title="未指定班级" description="请从诊断报告列表进入" />
|
||||
) : 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>
|
||||
) : !diag ? (
|
||||
<Empty title="暂无诊断数据" description="该班级尚未生成诊断报告" />
|
||||
) : (
|
||||
<>
|
||||
{/* 基本信息 + 摘要 */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-4 flex items-baseline gap-6">
|
||||
<p className="text-sm text-ink-muted">
|
||||
学生数 <span className="text-ink font-mono">{diag.studentCount}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{diag.summary.map((s) => (
|
||||
<div
|
||||
key={s.knowledgePoint}
|
||||
className="p-4 border border-rule rounded-card"
|
||||
>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
{s.knowledgePoint}
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 text-2xl font-serif"
|
||||
style={{ color: masteryColor(s.avg) }}
|
||||
>
|
||||
{s.avg}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 知识点掌握度列表 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">知识点掌握度</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">知识点</th>
|
||||
<th className="py-3 pr-4 font-normal">平均掌握度</th>
|
||||
<th className="py-3 pr-4 font-normal">中位数</th>
|
||||
<th className="py-3 pr-4 font-normal">学生数</th>
|
||||
<th className="py-3 pr-4 font-normal">掌握分布</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diag.kpMastery.map((kp: DiagnosticKpMastery) => (
|
||||
<tr key={kp.knowledgePoint} className="border-b border-rule">
|
||||
<td className="py-3 pr-4 text-ink">{kp.knowledgePoint}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono"
|
||||
style={{ color: masteryColor(kp.avg) }}
|
||||
>
|
||||
{kp.avg}
|
||||
</span>
|
||||
<div className="w-16 h-1.5 rounded-button bg-subtle overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-button"
|
||||
style={{
|
||||
width: `${kp.avg}%`,
|
||||
background: masteryColor(kp.avg),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono">
|
||||
{kp.median}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono">
|
||||
{kp.studentCount}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2 text-tiny">
|
||||
<span className="text-success">
|
||||
已掌握 {kp.distribution.mastered}
|
||||
</span>
|
||||
<span className="text-warning">
|
||||
部分 {kp.distribution.partial}
|
||||
</span>
|
||||
<span className="text-danger">
|
||||
薄弱 {kp.distribution.weak}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* 学生掌握度排名 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">学生掌握度排名</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">排名</th>
|
||||
<th className="py-3 pr-4 font-normal">学生</th>
|
||||
<th className="py-3 pr-4 font-normal">掌握度均值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diag.rankings.map((r) => (
|
||||
<tr key={r.studentId} className="border-b border-rule">
|
||||
<td className="py-3 pr-4 font-serif text-ink-muted">
|
||||
{r.rank}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={`/analytics/${r.studentId}`}
|
||||
className="text-ink hover:text-accent"
|
||||
>
|
||||
{r.studentName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono"
|
||||
style={{ color: masteryColor(r.masteryAvg) }}
|
||||
>
|
||||
{r.masteryAvg}
|
||||
</span>
|
||||
<div className="w-24 h-1.5 rounded-button bg-subtle overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-button"
|
||||
style={{
|
||||
width: `${r.masteryAvg}%`,
|
||||
background: masteryColor(r.masteryAvg),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
apps/teacher-portal/src/app/(app)/diagnostic/page.tsx
Normal file
195
apps/teacher-portal/src/app/(app)/diagnostic/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 诊断报告列表页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL DiagnosticReportsQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 类型筛选(individual/class/grade)
|
||||
* - 状态筛选(draft/published/archived)
|
||||
* - 列表:报告标题/类型/对象/状态/创建时间/操作
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { DiagnosticReportsQuery } from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
DiagnosticReportType,
|
||||
DiagnosticReportStatus,
|
||||
DiagnosticReportItem,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
type TypeFilter = "ALL" | DiagnosticReportType;
|
||||
type StatusFilter = "ALL" | DiagnosticReportStatus;
|
||||
|
||||
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "全部类型" },
|
||||
{ value: "INDIVIDUAL", label: "个人" },
|
||||
{ value: "CLASS", label: "班级" },
|
||||
{ value: "GRADE", label: "年级" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "全部状态" },
|
||||
{ value: "DRAFT", label: "草稿" },
|
||||
{ value: "PUBLISHED", label: "已发布" },
|
||||
{ value: "ARCHIVED", label: "已归档" },
|
||||
];
|
||||
|
||||
const TYPE_LABEL: Record<DiagnosticReportType, string> = {
|
||||
INDIVIDUAL: "个人",
|
||||
CLASS: "班级",
|
||||
GRADE: "年级",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<DiagnosticReportStatus, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return iso.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default function DiagnosticPage(): JSX.Element {
|
||||
const [type, setType] = useState<TypeFilter>("ALL");
|
||||
const [status, setStatus] = useState<StatusFilter>("ALL");
|
||||
|
||||
const [result] = useQuery({
|
||||
query: DiagnosticReportsQuery,
|
||||
variables:
|
||||
type === "ALL" && status === "ALL"
|
||||
? {}
|
||||
: {
|
||||
type: type === "ALL" ? null : type,
|
||||
status: status === "ALL" ? null : status,
|
||||
},
|
||||
});
|
||||
|
||||
const reports: DiagnosticReportItem[] = result.data?.diagnosticReports ?? [];
|
||||
|
||||
/** 根据报告类型决定查看链接 */
|
||||
function getViewHref(item: DiagnosticReportItem): string {
|
||||
if (item.type === "CLASS") {
|
||||
// 班级报告 → /diagnostic/class/[classId](这里用 id 占位,正式版应解析 targetType)
|
||||
// 简化:所有班级报告都跳到第一个班级诊断页
|
||||
return `/diagnostic/class/550e8400-e29b-41d4-a716-446655440010`;
|
||||
}
|
||||
if (item.type === "INDIVIDUAL") {
|
||||
return `/analytics/stu-001`;
|
||||
}
|
||||
return `/analytics`;
|
||||
}
|
||||
|
||||
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 DiagnosticReportsQuery · 诊断报告列表与状态管理(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="mb-6 flex items-baseline gap-6 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
类型
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as TypeFilter)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{TYPE_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as StatusFilter)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : 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>
|
||||
) : reports.length === 0 ? (
|
||||
<Empty title="暂无诊断报告" description="当前筛选条件下没有报告" />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">报告标题</th>
|
||||
<th className="py-3 pr-4 font-normal">类型</th>
|
||||
<th className="py-3 pr-4 font-normal">对象</th>
|
||||
<th className="py-3 pr-4 font-normal">状态</th>
|
||||
<th className="py-3 pr-4 font-normal">创建时间</th>
|
||||
<th className="py-3 pr-4 font-normal text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((r) => (
|
||||
<tr key={r.id} className="border-b border-rule">
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={getViewHref(r)}
|
||||
className="font-serif text-ink hover:text-accent"
|
||||
>
|
||||
{r.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink">{TYPE_LABEL[r.type]}</td>
|
||||
<td className="py-3 pr-4 text-ink-muted">{r.targetType}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
|
||||
{STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
|
||||
{formatDate(r.createdAt)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<Link
|
||||
href={getViewHref(r)}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
查看
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
397
apps/teacher-portal/src/app/(app)/elective/[id]/edit/page.tsx
Normal file
397
apps/teacher-portal/src/app/(app)/elective/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 编辑选修课页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ElectiveCourseDetailQuery:按 id 拉取详情(含学生列表)
|
||||
* - GraphQL UpdateElectiveCourseMutation / PublishElectiveCourseMutation / CancelElectiveCourseMutation
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:返回链接 + 标题
|
||||
* - 表单:课程名/学科/年级/容量/学期/描述
|
||||
* - 学生选课列表
|
||||
* - 越权校验:判断 teacherId 是否为当前用户
|
||||
* - "保存"/"发布"/"取消课程" 按钮
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
ElectiveCourseDetailQuery,
|
||||
UpdateElectiveCourseMutation,
|
||||
PublishElectiveCourseMutation,
|
||||
CancelElectiveCourseMutation,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
import type { ElectiveStudent } from "@/lib/graphql-p7-advanced";
|
||||
import { CURRENT_TEACHER_ID } from "@/mocks/fixtures/elective";
|
||||
|
||||
/** 学科选项 */
|
||||
const SUBJECT_OPTIONS = [
|
||||
"数学", "语文", "英语", "物理", "化学", "生物",
|
||||
"历史", "地理", "信息技术", "美术",
|
||||
];
|
||||
|
||||
/** 年级选项 */
|
||||
const GRADE_OPTIONS = ["高一", "高二", "高三"];
|
||||
|
||||
/** 学期选项 */
|
||||
const SEMESTER_OPTIONS = ["2026春季", "2025秋季", "2026秋季"];
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
open: "开放选课",
|
||||
closed: "已截止",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
export default function EditElectivePage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const courseId = params?.id ?? "";
|
||||
|
||||
const [result, reexecuteQuery] = useQuery({
|
||||
query: ElectiveCourseDetailQuery,
|
||||
variables: { id: courseId },
|
||||
pause: !courseId,
|
||||
});
|
||||
|
||||
const [, updateCourse] = useMutation(UpdateElectiveCourseMutation);
|
||||
const [, publishCourse] = useMutation(PublishElectiveCourseMutation);
|
||||
const [, cancelCourse] = useMutation(CancelElectiveCourseMutation);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [subject, setSubject] = useState("数学");
|
||||
const [grade, setGrade] = useState("高一");
|
||||
const [capacity, setCapacity] = useState("40");
|
||||
const [semester, setSemester] = useState("2026春季");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const course = result.data?.electiveCourseDetail;
|
||||
// 越权校验:教师只能编辑自己教授的课程
|
||||
const isOwner = course?.teacherId === CURRENT_TEACHER_ID;
|
||||
|
||||
// 初始化表单
|
||||
useEffect(() => {
|
||||
if (!course) return;
|
||||
setTitle(course.title);
|
||||
setSubject(course.subject);
|
||||
setGrade(course.grade);
|
||||
setCapacity(String(course.capacity));
|
||||
setSemester(course.semester);
|
||||
setDescription(course.description ?? "");
|
||||
}, [course]);
|
||||
|
||||
/** 保存更新 */
|
||||
const handleSave = async () => {
|
||||
if (!course) return;
|
||||
setError(null);
|
||||
setMsg(null);
|
||||
setSubmitting(true);
|
||||
const res = await updateCourse({
|
||||
input: {
|
||||
id: course.id,
|
||||
title: title.trim(),
|
||||
subject,
|
||||
grade,
|
||||
capacity: Number(capacity) || 0,
|
||||
semester,
|
||||
description: description.trim() || null,
|
||||
},
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setMsg("已保存");
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
/** 发布选修课 */
|
||||
const handlePublish = async () => {
|
||||
if (!course) return;
|
||||
setError(null);
|
||||
setMsg(null);
|
||||
setSubmitting(true);
|
||||
const res = await publishCourse({ id: course.id });
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setMsg("已发布");
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
/** 取消选修课 */
|
||||
const handleCancel = async () => {
|
||||
if (!course) return;
|
||||
setError(null);
|
||||
setMsg(null);
|
||||
if (!window.confirm("确认取消此选修课?取消后无法恢复。")) return;
|
||||
setSubmitting(true);
|
||||
const res = await cancelCourse({ id: course.id });
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setMsg("已取消");
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/elective"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回选修课列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">编辑选修课</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ElectiveCourseDetailQuery + UpdateElectiveCourseMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{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>
|
||||
) : !course ? (
|
||||
<Empty title="未找到选修课" description="该选修课不存在或已被删除" />
|
||||
) : !isOwner ? (
|
||||
<Empty
|
||||
title="无权编辑"
|
||||
description="只能编辑本人教授的选修课"
|
||||
/>
|
||||
) : (
|
||||
<section className="max-w-4xl space-y-10">
|
||||
{/* 表单 */}
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
当前状态:
|
||||
<span className="ml-2 text-ink">
|
||||
{STATUS_LABEL[course.status] ?? course.status}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
课程名 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学科
|
||||
</label>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
年级
|
||||
</label>
|
||||
<select
|
||||
value={grade}
|
||||
onChange={(e) => setGrade(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{GRADE_OPTIONS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
容量
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学期
|
||||
</label>
|
||||
<select
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{SEMESTER_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<p className="text-tiny text-success">{msg}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "提交中..." : "保存"}
|
||||
</button>
|
||||
{course.status !== "open" && course.status !== "cancelled" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink bg-transparent border border-accent text-accent rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
发布
|
||||
</button>
|
||||
)}
|
||||
{course.status !== "cancelled" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-danger bg-transparent border border-danger rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
取消课程
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 学生选课列表 */}
|
||||
<div>
|
||||
<h3 className="text-xl font-serif text-ink mb-2">
|
||||
选课学生
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{course.students.length} 人
|
||||
</span>
|
||||
</h3>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{course.students.length === 0 ? (
|
||||
<Empty title="暂无选课学生" description="尚未开放选课或无人选课" />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left">
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
选课时间
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{course.students.map((stu: ElectiveStudent) => (
|
||||
<tr
|
||||
key={stu.studentId}
|
||||
className="border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<td className="py-3 px-2 font-mono text-tiny text-ink">
|
||||
{stu.studentNo}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-ink">{stu.studentName}</td>
|
||||
<td className="py-3 px-2 text-ink-muted text-tiny">
|
||||
{new Date(stu.enrolledAt).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<span
|
||||
className="text-tiny"
|
||||
style={{
|
||||
color:
|
||||
stu.status === "enrolled"
|
||||
? "var(--color-success)"
|
||||
: "var(--color-danger)",
|
||||
}}
|
||||
>
|
||||
{stu.status === "enrolled" ? "已选" : "已退选"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
apps/teacher-portal/src/app/(app)/elective/create/page.tsx
Normal file
240
apps/teacher-portal/src/app/(app)/elective/create/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 创建选修课表单页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL CreateElectiveCourseMutation:创建选修课(草稿/发布)
|
||||
*
|
||||
* 布局:
|
||||
* - 表单字段:课程名/学科 select/年级 select/容量 input/学期 select/描述 textarea
|
||||
* - "保存草稿"/"发布" 按钮
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "urql";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { CreateElectiveCourseMutation } from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 学科选项 */
|
||||
const SUBJECT_OPTIONS = [
|
||||
"数学", "语文", "英语", "物理", "化学", "生物",
|
||||
"历史", "地理", "信息技术", "美术",
|
||||
];
|
||||
|
||||
/** 年级选项 */
|
||||
const GRADE_OPTIONS = ["高一", "高二", "高三"];
|
||||
|
||||
/** 学期选项 */
|
||||
const SEMESTER_OPTIONS = ["2026春季", "2025秋季", "2026秋季"];
|
||||
|
||||
export default function CreateElectivePage(): React.ReactNode {
|
||||
const router = useRouter();
|
||||
|
||||
const [createResult, createCourse] = useMutation(CreateElectiveCourseMutation);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [subject, setSubject] = useState(SUBJECT_OPTIONS[0] ?? "数学");
|
||||
const [grade, setGrade] = useState(GRADE_OPTIONS[0] ?? "高一");
|
||||
const [capacity, setCapacity] = useState("40");
|
||||
const [semester, setSemester] = useState(SEMESTER_OPTIONS[0] ?? "2026春季");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/** 提交创建(publish=true 发布,false 保存草稿) */
|
||||
const handleSubmit = async (publish: boolean) => {
|
||||
setError(null);
|
||||
|
||||
if (!title.trim()) {
|
||||
setError("请填写课程名");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await createCourse({
|
||||
input: {
|
||||
title: title.trim(),
|
||||
subject,
|
||||
grade,
|
||||
capacity: Number(capacity) || 0,
|
||||
semester,
|
||||
description: description.trim() || null,
|
||||
},
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 成功后跳回列表(publish 标记仅用于 UI 提示,mock 不区分)
|
||||
if (publish) {
|
||||
// 发布:mock 中通过 PublishElectiveCourseMutation 调用,此处简化为创建后跳转
|
||||
}
|
||||
router.push("/elective");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/elective"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回选修课列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">创建选修课</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL CreateElectiveCourseMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
<section className="max-w-2xl">
|
||||
<form className="space-y-6">
|
||||
{/* 课程名 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
课程名 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
placeholder="例如:趣味数学建模"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 学科 + 年级 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学科 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
年级 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={grade}
|
||||
onChange={(e) => setGrade(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{GRADE_OPTIONS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 容量 + 学期 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
容量 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学期 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{SEMESTER_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="课程简介、选课要求等"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "提交中..." : "保存草稿"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "提交中..." : "发布"}
|
||||
</button>
|
||||
<Link
|
||||
href="/elective"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
{createResult.data && (
|
||||
<span className="text-tiny text-success">创建成功</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
apps/teacher-portal/src/app/(app)/elective/page.tsx
Normal file
244
apps/teacher-portal/src/app/(app)/elective/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 选修课列表页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ElectiveCoursesQuery:按状态 + 学科筛选选修课列表
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:标题 + "创建选修课" 按钮
|
||||
* - 筛选:状态 select + 学科 select
|
||||
* - 列表:课程名/学科/年级/容量/已选数/状态/操作
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ElectiveCoursesQuery } from "@/lib/graphql-p7-advanced";
|
||||
import type { ElectiveCourseItem } from "@/lib/graphql-p7-advanced";
|
||||
import { CURRENT_TEACHER_ID } from "@/mocks/fixtures/elective";
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
open: "开放选课",
|
||||
closed: "已截止",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
/** 状态对应的样式令牌(语义色) */
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "var(--color-success)";
|
||||
case "closed":
|
||||
return "var(--color-warning)";
|
||||
case "cancelled":
|
||||
return "var(--color-danger)";
|
||||
default:
|
||||
return "var(--color-ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态筛选选项 */
|
||||
const STATUS_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "open", label: "开放选课" },
|
||||
{ value: "closed", label: "已截止" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
/** 学科筛选选项 */
|
||||
const SUBJECT_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "", label: "全部学科" },
|
||||
{ value: "数学", label: "数学" },
|
||||
{ value: "语文", label: "语文" },
|
||||
{ value: "英语", label: "英语" },
|
||||
{ value: "物理", label: "物理" },
|
||||
{ value: "化学", label: "化学" },
|
||||
{ value: "生物", label: "生物" },
|
||||
{ value: "历史", label: "历史" },
|
||||
{ value: "地理", label: "地理" },
|
||||
{ value: "信息技术", label: "信息技术" },
|
||||
{ value: "美术", label: "美术" },
|
||||
];
|
||||
|
||||
export default function ElectiveListPage(): React.ReactNode {
|
||||
const [status, setStatus] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ElectiveCoursesQuery,
|
||||
variables: {
|
||||
status: status || null,
|
||||
subject: subject || null,
|
||||
},
|
||||
});
|
||||
|
||||
const courses: ElectiveCourseItem[] = result.data?.electiveCourses ?? [];
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回仪表盘
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">选修课管理</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ElectiveCoursesQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 操作栏:筛选 + 创建按钮 */}
|
||||
<section className="mb-6 flex items-end justify-between gap-4">
|
||||
<div className="flex items-end gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学科
|
||||
</label>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/elective/create"
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
创建选修课
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
) : courses.length === 0 ? (
|
||||
<Empty title="暂无选修课" description="当前筛选条件下没有选修课" />
|
||||
) : (
|
||||
<section>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left">
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
课程名
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学科
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
年级
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
容量
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
已选数
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
<th className="py-3 px-2 text-tiny uppercase tracking-wide text-ink-muted text-right">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{courses.map((course) => {
|
||||
const canEdit = course.teacherId === CURRENT_TEACHER_ID;
|
||||
return (
|
||||
<tr
|
||||
key={course.id}
|
||||
className="border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<td className="py-3 px-2">
|
||||
<span className="font-serif text-ink">
|
||||
{course.title}
|
||||
</span>
|
||||
{course.description && (
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
{course.description}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-2 text-ink">{course.subject}</td>
|
||||
<td className="py-3 px-2 text-ink">{course.grade}</td>
|
||||
<td className="py-3 px-2 text-ink">{course.capacity}</td>
|
||||
<td className="py-3 px-2 text-ink">
|
||||
{course.enrolledCount}
|
||||
<span className="text-tiny text-ink-muted">
|
||||
/{course.capacity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2">
|
||||
<span
|
||||
className="text-tiny"
|
||||
style={{ color: statusColor(course.status) }}
|
||||
>
|
||||
{STATUS_LABEL[course.status] ?? course.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-right">
|
||||
{canEdit ? (
|
||||
<Link
|
||||
href={`/elective/${course.id}/edit`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
编辑
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-tiny text-ink-muted">
|
||||
非本人课程
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
519
apps/teacher-portal/src/app/(app)/error-book/page.tsx
Normal file
519
apps/teacher-portal/src/app/(app)/error-book/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 错题分析页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL ErrorBookQuery
|
||||
*
|
||||
* 7 个 Widget:
|
||||
* 1. 学科 Tab + 班级筛选
|
||||
* 2. 5 项统计卡片(错题总数/高频错题/薄弱知识点/平均错误率/上升趋势)
|
||||
* 3. 班级对比柱图(5 班级 × 错题数)- 纯 SVG
|
||||
* 4. 章节薄弱度横向柱图(10 章节 × 错题数)- 纯 SVG
|
||||
* 5. 知识点薄弱度雷达图(10 知识点)- 纯 SVG
|
||||
* 6. 学生错题分组表(前 5 学生错题数)
|
||||
* 7. Top10 高频错题列表(题干前 100 字/错误率/学科)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ErrorBookQuery } from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
ErrorBook as ErrorBookType,
|
||||
ErrorBookClassComparison,
|
||||
ErrorBookChapterWeakness,
|
||||
ErrorBookKpWeakness,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学"] as const;
|
||||
|
||||
const CLASSES = [
|
||||
{ id: "", name: "全部班级" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
|
||||
];
|
||||
|
||||
/** 班级对比柱图(垂直柱) */
|
||||
function ClassComparisonChart({
|
||||
data,
|
||||
}: {
|
||||
data: ErrorBookClassComparison[];
|
||||
}): React.ReactNode {
|
||||
const W = 540;
|
||||
const H = 200;
|
||||
const PAD = { top: 20, right: 12, bottom: 40, left: 36 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const max = Math.max(...data.map((d) => d.errorCount), 1);
|
||||
const barW = innerW / data.length - 8;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full max-w-2xl border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="班级错题对比柱图"
|
||||
>
|
||||
{/* Y 轴网格线 */}
|
||||
{[0, 0.5, 1].map((r, i) => {
|
||||
const y = PAD.top + innerH * r;
|
||||
const v = Math.round(max * (1 - r));
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={y}
|
||||
x2={W - PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* 柱子 */}
|
||||
{data.map((d, i) => {
|
||||
const x = PAD.left + i * (barW + 8) + 4;
|
||||
const h = (d.errorCount / max) * innerH;
|
||||
const y = PAD.top + innerH - h;
|
||||
return (
|
||||
<g key={d.classId}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barW}
|
||||
height={h}
|
||||
fill="var(--color-accent)"
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={y - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{d.errorCount}
|
||||
</text>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={H - PAD.bottom + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.className}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 章节薄弱度横向柱图 */
|
||||
function ChapterWeaknessChart({
|
||||
data,
|
||||
}: {
|
||||
data: ErrorBookChapterWeakness[];
|
||||
}): React.ReactNode {
|
||||
const W = 540;
|
||||
const rowH = 24;
|
||||
const labelW = 120;
|
||||
const barAreaW = W - labelW - 80;
|
||||
const H = data.length * rowH + 20;
|
||||
const max = Math.max(...data.map((d) => d.errorCount), 1);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full max-w-2xl border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="章节薄弱度横向柱图"
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const y = i * rowH + 10;
|
||||
const w = (d.errorCount / max) * barAreaW;
|
||||
return (
|
||||
<g key={d.chapter}>
|
||||
<text
|
||||
x={0}
|
||||
y={y + 14}
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{d.chapter.length > 8 ? d.chapter.slice(0, 8) + "…" : d.chapter}
|
||||
</text>
|
||||
<rect
|
||||
x={labelW}
|
||||
y={y}
|
||||
width={w}
|
||||
height={16}
|
||||
fill="var(--color-warning)"
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={labelW + w + 6}
|
||||
y={y + 13}
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{d.errorCount}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 知识点薄弱度雷达图(10 轴) */
|
||||
function KpWeaknessRadar({
|
||||
data,
|
||||
}: {
|
||||
data: ErrorBookKpWeakness[];
|
||||
}): React.ReactNode {
|
||||
const W = 360;
|
||||
const H = 320;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
const r = 110;
|
||||
const total = data.length;
|
||||
|
||||
function angleAt(i: number): number {
|
||||
return (2 * Math.PI * i) / total - Math.PI / 2;
|
||||
}
|
||||
|
||||
function point(i: number, value: number): { x: number; y: number } {
|
||||
const a = angleAt(i);
|
||||
const radius = (value / 100) * r;
|
||||
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
|
||||
}
|
||||
|
||||
// 雷达形状路径
|
||||
const path = data
|
||||
.map((d, i) => {
|
||||
const p = point(i, d.errorRate * 100);
|
||||
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ") + " Z";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full max-w-md border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="知识点薄弱度雷达图"
|
||||
>
|
||||
{/* 同心多边形 */}
|
||||
{[0.25, 0.5, 0.75, 1].map((ratio, ri) => {
|
||||
const pts = data
|
||||
.map((_, i) => {
|
||||
const a = angleAt(i);
|
||||
const radius = r * ratio;
|
||||
return `${cx + radius * Math.cos(a)},${cy + radius * Math.sin(a)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<polygon
|
||||
key={`ring-${ri}`}
|
||||
points={pts}
|
||||
fill="none"
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 轴线 */}
|
||||
{data.map((d, i) => {
|
||||
const a = angleAt(i);
|
||||
const x = cx + r * Math.cos(a);
|
||||
const y = cy + r * Math.sin(a);
|
||||
return (
|
||||
<line
|
||||
key={`axis-${i}`}
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 数据形状 */}
|
||||
<path
|
||||
d={path}
|
||||
fill="var(--color-danger)"
|
||||
fillOpacity={0.18}
|
||||
stroke="var(--color-danger)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* 轴标签 */}
|
||||
{data.map((d, i) => {
|
||||
const a = angleAt(i);
|
||||
const lx = cx + (r + 14) * Math.cos(a);
|
||||
const ly = cy + (r + 14) * Math.sin(a);
|
||||
return (
|
||||
<text
|
||||
key={`label-${i}`}
|
||||
x={lx}
|
||||
y={ly}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.knowledgePoint.length > 5
|
||||
? d.knowledgePoint.slice(0, 5) + "…"
|
||||
: d.knowledgePoint}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ErrorBookPage(): JSX.Element {
|
||||
const [subject, setSubject] = useState<string>("数学");
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ErrorBookQuery,
|
||||
variables: { subject, classId: classId || null },
|
||||
});
|
||||
|
||||
const book: ErrorBookType | undefined = result.data?.errorBook;
|
||||
|
||||
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 ErrorBookQuery · 学科 × 班级错题多维分析(P7-insights,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 学科 Tab + 班级筛选 */}
|
||||
<div className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
|
||||
<div className="flex gap-2">
|
||||
{SUBJECTS.map((s) => {
|
||||
const active = s === subject;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSubject(s)}
|
||||
className={`px-3 py-1.5 text-sm rounded-button transition-colors ${
|
||||
active
|
||||
? "text-ink-on-accent bg-accent"
|
||||
: "text-ink border border-rule hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(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"
|
||||
>
|
||||
{CLASSES.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</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>
|
||||
) : !book ? (
|
||||
<Empty title="暂无数据" description="该学科/班级暂无错题分析" />
|
||||
) : (
|
||||
<>
|
||||
{/* 5 项统计卡片 */}
|
||||
<section className="mb-8 grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
错题总数
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-ink">
|
||||
{book.summary.totalErrors}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
高频错题
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-danger">
|
||||
{book.summary.highFreqErrors}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
薄弱知识点
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-warning">
|
||||
{book.summary.weakKpCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均错误率
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-ink">
|
||||
{Math.round(book.summary.avgErrorRate * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
上升趋势
|
||||
</p>
|
||||
<p
|
||||
className={`mt-2 text-3xl font-serif ${
|
||||
book.summary.trendUp ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{book.summary.trendUp ? "↑" : "↓"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 班级对比柱图 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">班级对比</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<ClassComparisonChart data={book.classComparison} />
|
||||
</section>
|
||||
|
||||
{/* 章节薄弱度 + 知识点薄弱度雷达图 */}
|
||||
<section className="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
章节薄弱度
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<ChapterWeaknessChart data={book.chapterWeakness} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
知识点薄弱度雷达
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<KpWeaknessRadar data={book.kpWeakness} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 学生错题分组 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">学生错题分组</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">学生</th>
|
||||
<th className="py-3 pr-4 font-normal">错题数</th>
|
||||
<th className="py-3 pr-4 font-normal">高频错题</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{book.studentGroups.map((g) => (
|
||||
<tr key={g.studentId} className="border-b border-rule align-top">
|
||||
<td className="py-3 pr-4 text-ink">{g.studentName}</td>
|
||||
<td className="py-3 pr-4 font-mono text-accent">
|
||||
{g.errorCount}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<ul className="space-y-1">
|
||||
{g.topErrors.map((e) => (
|
||||
<li key={e.questionId} className="text-tiny text-ink-muted">
|
||||
<span className="font-mono text-danger">
|
||||
{Math.round(e.errorRate * 100)}%
|
||||
</span>
|
||||
{" "}
|
||||
{e.content.slice(0, 60)}
|
||||
{e.content.length > 60 ? "…" : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* Top10 高频错题 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
Top 10 高频错题
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<ol className="space-y-2">
|
||||
{book.topQuestions.map((q, i) => (
|
||||
<li
|
||||
key={q.questionId}
|
||||
className="py-3 px-4 border border-rule rounded-card bg-surface"
|
||||
>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-lg font-serif text-ink-muted">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-ink">
|
||||
{q.content.slice(0, 100)}
|
||||
{q.content.length > 100 ? "…" : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-tiny text-ink-muted font-mono">
|
||||
错误率 {Math.round(q.errorRate * 100)}% · 学科 {q.subject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
486
apps/teacher-portal/src/app/(app)/exams/[id]/analytics/page.tsx
Normal file
486
apps/teacher-portal/src/app/(app)/exams/[id]/analytics/page.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试分析仪表盘
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ExamAnalyticsQuery:分数分布/均分/最高/最低/及格率/科目对比/每题正确率/学生排名
|
||||
*
|
||||
* 5 个 SVG 图表:
|
||||
* 1. 分数分布柱图(0-59/60-69/70-79/80-89/90-100)
|
||||
* 2. 每题正确率柱图(20 题横向柱图)
|
||||
* 3. 知识点掌握度雷达图
|
||||
* 4. 班级对比柱图(含显著性)
|
||||
* 5. 历次考试趋势折线图
|
||||
*
|
||||
* 学生排名表 + "导出分析报告" 按钮(CSV)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ExamAnalyticsQuery } from "@/lib/graphql-p7-exams";
|
||||
import type {
|
||||
ExamAnalytics,
|
||||
ExamQuestionAccuracy,
|
||||
ExamHistoryTrend,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import {
|
||||
DistributionBarChart,
|
||||
ClassComparisonChart,
|
||||
KnowledgeRadarChart,
|
||||
} from "@/app/(app)/grades/analytics/charts";
|
||||
|
||||
// ============ 自定义图表(题正确率 + 历次趋势) ============
|
||||
|
||||
/** 每题正确率横向柱图 */
|
||||
function QuestionAccuracyChart({
|
||||
data,
|
||||
}: {
|
||||
data: ExamQuestionAccuracy[];
|
||||
}): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无题目正确率数据</p>;
|
||||
}
|
||||
const W = 520;
|
||||
const H = 360;
|
||||
const PAD = { top: 16, right: 32, bottom: 24, left: 48 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const barH = Math.max(6, innerH / data.length - 2);
|
||||
// 正确率 0-100
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="每题正确率横向柱图"
|
||||
>
|
||||
{/* 网格线 */}
|
||||
{[0, 25, 50, 75, 100].map((v) => {
|
||||
const x = PAD.left + (v / 100) * innerW;
|
||||
return (
|
||||
<g key={`grid-${v}`}>
|
||||
<line
|
||||
x1={x}
|
||||
y1={PAD.top}
|
||||
x2={x}
|
||||
y2={PAD.top + innerH}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={H - PAD.bottom + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{v}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* 柱子 */}
|
||||
{data.map((item, i) => {
|
||||
const y = PAD.top + i * (barH + 2);
|
||||
const w = (item.correctRate / 100) * innerW;
|
||||
return (
|
||||
<g key={item.questionId}>
|
||||
<text
|
||||
x={PAD.left - 4}
|
||||
y={y + barH / 2 + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{item.order}
|
||||
</text>
|
||||
<rect
|
||||
x={PAD.left}
|
||||
y={y}
|
||||
width={Math.max(0, w)}
|
||||
height={barH}
|
||||
fill={
|
||||
item.correctRate < 50
|
||||
? "var(--color-danger)"
|
||||
: item.correctRate < 70
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-accent)"
|
||||
}
|
||||
rx={2}
|
||||
/>
|
||||
<text
|
||||
x={PAD.left + w + 4}
|
||||
y={y + barH / 2 + 4}
|
||||
textAnchor="start"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{item.correctRate}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 历次考试趋势折线图 */
|
||||
function HistoryTrendChart({
|
||||
data,
|
||||
}: {
|
||||
data: ExamHistoryTrend[];
|
||||
}): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无历次考试趋势</p>;
|
||||
}
|
||||
const W = 520;
|
||||
const H = 200;
|
||||
const PAD = { top: 20, right: 24, bottom: 36, left: 36 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const yMax = 100;
|
||||
const yMin = 0;
|
||||
const yRange = yMax - yMin;
|
||||
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
const points = data.map((d, i) => ({
|
||||
x: PAD.left + i * xStep,
|
||||
y: PAD.top + innerH - ((d.avg - yMin) / yRange) * innerH,
|
||||
value: d.avg,
|
||||
label: d.examTitle,
|
||||
}));
|
||||
const linePath = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
|
||||
.join(" ");
|
||||
const firstPoint = points[0];
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (!firstPoint || !lastPoint) {
|
||||
return <p className="text-sm text-ink-muted">暂无历次考试趋势</p>;
|
||||
}
|
||||
const areaPath = `${linePath} L ${lastPoint.x} ${PAD.top + innerH} L ${firstPoint.x} ${PAD.top + innerH} Z`;
|
||||
const gridLines = [0, 50, 100].map((v) => {
|
||||
const ratio = 1 - (v - yMin) / yRange;
|
||||
return { y: PAD.top + innerH * ratio, value: v };
|
||||
});
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="历次考试趋势折线图"
|
||||
>
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={g.y}
|
||||
x2={W - PAD.right}
|
||||
y2={g.y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 8}
|
||||
y={g.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{g.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={areaPath} fill="var(--color-accent)" fillOpacity={0.08} />
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{points.map((p, i) => (
|
||||
<g key={`pt-${i}`}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4}
|
||||
fill="var(--color-accent)"
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{p.value}
|
||||
</text>
|
||||
<text
|
||||
x={p.x}
|
||||
y={H - PAD.bottom + 18}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
第{i + 1}次
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ CSV 导出 ============
|
||||
|
||||
/** 触发 CSV 下载 */
|
||||
function exportAnalyticsCsv(analytics: ExamAnalytics): void {
|
||||
const lines: string[] = [];
|
||||
lines.push("# 考试分析报告");
|
||||
lines.push(`# 考试: ${analytics.examTitle}`);
|
||||
lines.push(
|
||||
`# 应考 ${analytics.summary.expectedCount}, 实考 ${analytics.summary.attendedCount}, 均分 ${analytics.summary.avgScore}, 最高 ${analytics.summary.maxScore}, 最低 ${analytics.summary.minScore}, 及格率 ${analytics.summary.passRate}%`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("排名,学号,姓名,总分,等级");
|
||||
analytics.rankings.forEach((r) => {
|
||||
lines.push(
|
||||
[r.rank, r.studentNo, r.studentName, r.totalScore, r.level].join(","),
|
||||
);
|
||||
});
|
||||
lines.push("");
|
||||
lines.push("题号,题目,正确率,平均得分,满分");
|
||||
analytics.questionAccuracy.forEach((q) => {
|
||||
const title = q.questionTitle.replace(/[\r\n,]/g, " ");
|
||||
lines.push(
|
||||
[q.order, title, `${q.correctRate}%`, q.avgScore, q.maxScore].join(","),
|
||||
);
|
||||
});
|
||||
const blob = new Blob(["\ufeff" + lines.join("\n")], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `exam-analytics-${analytics.examId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ============ 页面 ============
|
||||
|
||||
export default function ExamAnalyticsPage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ExamAnalyticsQuery,
|
||||
variables: { examId },
|
||||
pause: !examId,
|
||||
});
|
||||
|
||||
const analytics: ExamAnalytics | undefined = result.data?.examAnalytics;
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={examId ? `/exams/${examId}` : "/exams"}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考试详情
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">考试分析仪表盘</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ExamAnalyticsQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : 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>
|
||||
) : !analytics ? (
|
||||
<Empty
|
||||
title="未找到分析数据"
|
||||
description="该考试暂未生成考后分析,或考试 ID 不存在"
|
||||
/>
|
||||
) : (
|
||||
<section className="space-y-6">
|
||||
{/* 顶部统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ label: "应考人数", value: `${analytics.summary.expectedCount}` },
|
||||
{ label: "实考人数", value: `${analytics.summary.attendedCount}` },
|
||||
{ label: "平均分", value: `${analytics.summary.avgScore}` },
|
||||
{ label: "最高分", value: `${analytics.summary.maxScore}` },
|
||||
{ label: "最低分", value: `${analytics.summary.minScore}` },
|
||||
{ label: "及格率", value: `${analytics.summary.passRate}%` },
|
||||
].map((c) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className="border border-rule rounded-card p-3 bg-surface"
|
||||
>
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
{c.label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-mono text-ink">{c.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportAnalyticsCsv(analytics)}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
导出分析报告(CSV)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 5 个图表 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">分数段分布</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
按五段统计学生人数(百分比归一化)
|
||||
</p>
|
||||
<DistributionBarChart data={analytics.distribution} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">每题正确率</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
红色 <50% · 黄色 50-70% · 蓝色 >70%
|
||||
</p>
|
||||
<QuestionAccuracyChart data={analytics.questionAccuracy} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
班级对比
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
* 标记表示与本班存在显著差异
|
||||
</p>
|
||||
<ClassComparisonChart data={analytics.classComparison} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
历次考试趋势
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
最近 {analytics.historyTrend.length} 次考试平均分变化
|
||||
</p>
|
||||
<HistoryTrendChart data={analytics.historyTrend} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-rule rounded-card lg:col-span-2">
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
知识点掌握度
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
五大知识点掌握度雷达图
|
||||
</p>
|
||||
<KnowledgeRadarChart data={analytics.knowledgeMastery} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 学生排名表 */}
|
||||
<div className="border border-rule rounded-card p-4 bg-surface">
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
学生排名
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
共 {analytics.rankings.length} 名
|
||||
</span>
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rule">
|
||||
<th className="text-left py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
排名
|
||||
</th>
|
||||
<th className="text-left py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="text-left py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
</th>
|
||||
<th className="text-right py-2 pr-4 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总分
|
||||
</th>
|
||||
<th className="text-right py-2 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
等级
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analytics.rankings.map((r) => (
|
||||
<tr
|
||||
key={r.studentId}
|
||||
className="border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<td className="py-2 pr-4 font-mono text-ink-muted">
|
||||
{r.rank}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-ink">
|
||||
{r.studentNo}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-ink">{r.studentName}</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-ink">
|
||||
{r.totalScore}
|
||||
</td>
|
||||
<td className="py-2 text-right font-mono">
|
||||
<span
|
||||
className={
|
||||
r.level === "A" || r.level === "B"
|
||||
? "text-success"
|
||||
: r.level === "E"
|
||||
? "text-danger"
|
||||
: "text-ink"
|
||||
}
|
||||
>
|
||||
{r.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
apps/teacher-portal/src/app/(app)/exams/[id]/edit-rich/page.tsx
Normal file
328
apps/teacher-portal/src/app/(app)/exams/[id]/edit-rich/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 富文本试卷编辑页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ExamRichEditorQuery:按 examId 拉取富文本试卷内容(Tiptap examNodes 结构)
|
||||
* - GraphQL SaveExamRichContentMutation:保存富文本内容
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:试卷标题 + 总分 + 题数
|
||||
* - 中间:富文本编辑器(contenteditable div + 工具栏:加粗/斜体/标题/列表/插入题目/分页符)
|
||||
* - "保存" 调用 SaveExamRichContentMutation
|
||||
* - "预览" 在新 tab 打开 /exams/[id]
|
||||
* - "导出 PDF" 调用 window.print()
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
ExamRichEditorQuery,
|
||||
SaveExamRichContentMutation,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 工具栏按钮定义 */
|
||||
interface ToolButton {
|
||||
command: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const TOOLBAR_GROUPS: ToolButton[][] = [
|
||||
[
|
||||
{ command: "bold", label: "加粗" },
|
||||
{ command: "italic", label: "斜体" },
|
||||
],
|
||||
[
|
||||
{ command: "formatBlock", label: "H1", value: "h1" },
|
||||
{ command: "formatBlock", label: "H2", value: "h2" },
|
||||
{ command: "formatBlock", label: "H3", value: "h3" },
|
||||
{ command: "formatBlock", label: "正文", value: "p" },
|
||||
],
|
||||
[
|
||||
{ command: "insertUnorderedList", label: "无序列表" },
|
||||
{ command: "insertOrderedList", label: "有序列表" },
|
||||
],
|
||||
[
|
||||
{ command: "insertQuestion", label: "插入题目" },
|
||||
{ command: "insertPageBreak", label: "插入分页符" },
|
||||
],
|
||||
];
|
||||
|
||||
/** 执行工具栏命令(contenteditable + document.execCommand,mock 实现) */
|
||||
function execToolbar(
|
||||
cmd: string,
|
||||
value: string | undefined,
|
||||
editorRef: React.RefObject<HTMLDivElement>,
|
||||
): void {
|
||||
if (cmd === "insertQuestion") {
|
||||
// 插入题目占位块
|
||||
document.execCommand("insertHTML", false, questionBlockHtml());
|
||||
return;
|
||||
}
|
||||
if (cmd === "insertPageBreak") {
|
||||
document.execCommand(
|
||||
"insertHTML",
|
||||
false,
|
||||
'<div class="page-break" style="page-break-after:always;border-top:2px dashed var(--color-rule);margin:1rem 0;padding-top:0.5rem;color:var(--color-ink-muted);font-size:0.75rem;text-align:center;">— 分页 —</div>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 标准命令
|
||||
document.execCommand(cmd, false, value);
|
||||
// 确保编辑器保持焦点
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
|
||||
/** 生成题目占位 HTML */
|
||||
function questionBlockHtml(): string {
|
||||
return '<div class="question-block" style="border:1px solid var(--color-rule);border-radius:0.5rem;padding:1rem;margin:1rem 0;background:var(--color-surface);"><p style="font-weight:600;margin-bottom:0.5rem;">题目(请在下方编写题干)</p><p>选项 / 作答区...</p></div>';
|
||||
}
|
||||
|
||||
export default function ExamRichEditorPage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ExamRichEditorQuery,
|
||||
variables: { examId },
|
||||
pause: !examId,
|
||||
});
|
||||
|
||||
const [, saveContent] = useMutation(SaveExamRichContentMutation);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const editor = result.data?.examRichEditor;
|
||||
|
||||
// 初始化编辑器内容(mock:将 JSON content 渲染为可编辑 HTML)
|
||||
useEffect(() => {
|
||||
if (!editor || initialized || !editorRef.current) return;
|
||||
// mock:将 content 的文本内容直接渲染
|
||||
const content = editor.content as {
|
||||
type?: string;
|
||||
content?: Array<{ type?: string; text?: string; attrs?: { level?: number } }>;
|
||||
} | null;
|
||||
if (content && Array.isArray(content.content)) {
|
||||
const html = content.content
|
||||
.map((node) => {
|
||||
if (node.type === "heading") {
|
||||
const level = node.attrs?.level ?? 1;
|
||||
return `<h${level}>${node.text ?? ""}</h${level}>`;
|
||||
}
|
||||
if (node.type === "paragraph") {
|
||||
return `<p>${node.text ?? ""}</p>`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
editorRef.current.innerHTML = html || "<p></p>";
|
||||
} else {
|
||||
editorRef.current.innerHTML = "<p></p>";
|
||||
}
|
||||
setInitialized(true);
|
||||
}, [editor, initialized]);
|
||||
|
||||
/** 保存富文本内容 */
|
||||
const handleSave = async () => {
|
||||
if (!editorRef.current) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveMsg(null);
|
||||
// 将编辑器 HTML 序列化为 JSON(mock:保存为简化结构)
|
||||
const content = {
|
||||
type: "doc",
|
||||
content: serializeEditorToNodes(editorRef.current),
|
||||
};
|
||||
const res = await saveContent({
|
||||
input: { examId, content },
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.error) {
|
||||
setSaveError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setSaveMsg("已保存");
|
||||
};
|
||||
|
||||
/** 预览:在新 tab 打开考试详情 */
|
||||
const handlePreview = () => {
|
||||
window.open(`/exams/${examId}`, "_blank");
|
||||
};
|
||||
|
||||
/** 导出 PDF:调用浏览器打印 */
|
||||
const handleExportPdf = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/exams/${examId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考试详情
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">富文本试卷编辑</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ExamRichEditorQuery + SaveExamRichContentMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{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>
|
||||
) : !editor ? (
|
||||
<Empty title="未找到试卷" description="该考试不存在或无富文本内容" />
|
||||
) : (
|
||||
<section className="max-w-4xl">
|
||||
{/* 顶部信息卡片 */}
|
||||
<div className="mb-6 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink">{editor.title}</h2>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
最后更新:
|
||||
{new Date(editor.updatedAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总分
|
||||
</span>
|
||||
<span className="ml-2 font-serif text-ink">
|
||||
{editor.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
题数
|
||||
</span>
|
||||
<span className="ml-2 font-serif text-ink">
|
||||
{editor.questionCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="mb-4 p-2 border border-rule rounded-card bg-surface flex items-center gap-2 flex-wrap">
|
||||
{TOOLBAR_GROUPS.map((group, gi) => (
|
||||
<div key={gi} className="flex items-center gap-1">
|
||||
{gi > 0 && (
|
||||
<span
|
||||
className="mx-1 inline-block w-px h-5 bg-rule"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{group.map((btn) => (
|
||||
<button
|
||||
key={`${btn.command}-${btn.label}`}
|
||||
type="button"
|
||||
onClick={() => execToolbar(btn.command, btn.value, editorRef)}
|
||||
className="px-2 py-1 text-tiny text-ink bg-transparent border border-transparent rounded-button hover:bg-subtle hover:border-rule"
|
||||
title={btn.label}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 编辑器 */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="min-h-[400px] p-6 border border-rule rounded-card bg-paper text-ink focus:outline-none focus:border-accent font-serif"
|
||||
style={{ lineHeight: 1.8 }}
|
||||
aria-label="富文本试卷编辑器"
|
||||
/>
|
||||
|
||||
{/* 错误 / 消息 */}
|
||||
{saveError && (
|
||||
<div className="mark-left mt-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">保存失败:{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveMsg && (
|
||||
<p className="mt-4 text-tiny text-success">{saveMsg}</p>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportPdf}
|
||||
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
导出 PDF
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 将编辑器 DOM 序列化为简化节点结构(mock Tiptap examNodes) */
|
||||
function serializeEditorToNodes(
|
||||
el: HTMLDivElement,
|
||||
): Array<{ type: string; text?: string; attrs?: { level?: number } }> {
|
||||
const nodes: Array<{ type: string; text?: string; attrs?: { level?: number } }> = [];
|
||||
const children = Array.from(el.children);
|
||||
for (const child of children) {
|
||||
const tag = child.tagName.toLowerCase();
|
||||
const text = child.textContent ?? "";
|
||||
if (tag === "h1") {
|
||||
nodes.push({ type: "heading", text, attrs: { level: 1 } });
|
||||
} else if (tag === "h2") {
|
||||
nodes.push({ type: "heading", text, attrs: { level: 2 } });
|
||||
} else if (tag === "h3") {
|
||||
nodes.push({ type: "heading", text, attrs: { level: 3 } });
|
||||
} else if (tag === "ul" || tag === "ol") {
|
||||
nodes.push({ type: tag === "ul" ? "bulletList" : "orderedList", text });
|
||||
} else if (tag === "div") {
|
||||
nodes.push({ type: "questionBlock", text });
|
||||
} else {
|
||||
nodes.push({ type: "paragraph", text });
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
228
apps/teacher-portal/src/app/(app)/exams/[id]/page.tsx
Normal file
228
apps/teacher-portal/src/app/(app)/exams/[id]/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 考试详情页 - 显示考试基本信息 + 题目列表
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL ExamDetailQuery(P3 扩展,MSW mock)
|
||||
* - 动态路由 /exams/[id],id 为考试 UUID
|
||||
* - DRAFT 状态展示"发布考试"按钮(调用 CreateExamMutation 复用为发布动作占位)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ExamDetailQuery, CreateExamMutation } from "@/lib/graphql";
|
||||
import type { ExamQuestion } from "@/lib/graphql";
|
||||
|
||||
/** 题目类型中文映射 */
|
||||
const QUESTION_TYPE_LABEL: Record<ExamQuestion["type"], string> = {
|
||||
SINGLE_CHOICE: "单选题",
|
||||
MULTIPLE_CHOICE: "多选题",
|
||||
SHORT_ANSWER: "简答题",
|
||||
ESSAY: "论述题",
|
||||
};
|
||||
|
||||
/** 考试状态中文映射 */
|
||||
const EXAM_STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
IN_PROGRESS: "进行中",
|
||||
GRADING: "批改中",
|
||||
SCORED: "已完成",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
export default function ExamDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ExamDetailQuery,
|
||||
variables: { id: examId },
|
||||
pause: !examId,
|
||||
});
|
||||
|
||||
const [publishResult, publishExam] = useMutation(CreateExamMutation);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [publishError, setPublishError] = useState<string | null>(null);
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!result.data?.examDetail) return;
|
||||
setPublishing(true);
|
||||
setPublishError(null);
|
||||
// 复用 CreateExamMutation 作为发布动作占位(MSW mock 接受相同 input)
|
||||
const res = await publishExam({
|
||||
input: {
|
||||
classId: result.data.examDetail.classId,
|
||||
title: result.data.examDetail.title,
|
||||
description: result.data.examDetail.description,
|
||||
examDate: result.data.examDetail.examDate,
|
||||
duration: result.data.examDetail.duration,
|
||||
totalScore: result.data.examDetail.totalScore,
|
||||
},
|
||||
});
|
||||
setPublishing(false);
|
||||
if (res.error) {
|
||||
setPublishError(res.error.message);
|
||||
return;
|
||||
}
|
||||
// 发布成功后回到考试列表
|
||||
router.push(`/exams?classId=${result.data.examDetail.classId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/exams?classId=${result.data?.examDetail?.classId ?? ""}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考试列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">考试详情</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ExamDetailQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{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>
|
||||
) : !result.data?.examDetail ? (
|
||||
<Empty title="未找到考试" description="该考试不存在或已被删除" />
|
||||
) : (
|
||||
<section className="max-w-4xl">
|
||||
{/* 基本信息 */}
|
||||
<div className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">
|
||||
{result.data.examDetail.title}
|
||||
</h2>
|
||||
{result.data.examDetail.description && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
{result.data.examDetail.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="rule-thin mb-4 mt-3" />
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
考试时间
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{new Date(result.data.examDetail.examDate).toLocaleString("zh-CN")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
时长
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{result.data.examDetail.duration} 分钟
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
满分
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{result.data.examDetail.totalScore} 分
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{EXAM_STATUS_LABEL[result.data.examDetail.status] ?? result.data.examDetail.status}
|
||||
{result.data.examDetail.status === "DRAFT" && (
|
||||
<span className="ml-2 text-tiny text-warning">未发布</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* 发布按钮(仅 DRAFT 状态可见) */}
|
||||
{result.data.examDetail.status === "DRAFT" && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={publishing}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{publishing ? "发布中..." : "发布考试"}
|
||||
</button>
|
||||
{publishError && (
|
||||
<p className="mt-2 text-tiny text-danger">
|
||||
发布失败:{publishError}
|
||||
</p>
|
||||
)}
|
||||
{publishResult.data && !publishError && (
|
||||
<p className="mt-2 text-tiny text-success">发布成功</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-xl font-serif text-ink">
|
||||
题目列表
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{result.data.examDetail.questions.length} 题
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{result.data.examDetail.questions.length === 0 ? (
|
||||
<Empty title="暂无题目" description="该考试尚未添加题目" />
|
||||
) : (
|
||||
<ol className="space-y-0">
|
||||
{result.data.examDetail.questions.map((q: ExamQuestion) => (
|
||||
<li
|
||||
key={q.id}
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-1 text-tiny font-mono text-ink-muted">
|
||||
{q.order}.
|
||||
</div>
|
||||
<div className="col-span-7">
|
||||
<h4 className="text-base font-serif text-ink">
|
||||
{q.title}
|
||||
</h4>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
类型:{QUESTION_TYPE_LABEL[q.type]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-ink">
|
||||
{q.score} 分
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
|
||||
{q.id.slice(0, 8)}...
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
388
apps/teacher-portal/src/app/(app)/exams/[id]/proctoring/page.tsx
Normal file
388
apps/teacher-portal/src/app/(app)/exams/[id]/proctoring/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 监考面板页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ProctoringStatusQuery:按 examId 拉取监考状态(摘要 + 学生列表 + 近期事件)
|
||||
* - GraphQL ProctoringEventMutation:提交监考事件(WARNING/FLAG/NOTE)
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:标题 + "开始监考"/"结束监考" 按钮 + 手动刷新按钮
|
||||
* - 摘要卡片:应考数/在线数/已交卷数/异常数
|
||||
* - 左侧:学生状态列表(按状态筛选)
|
||||
* - 右侧:近期事件流(含操作按钮"标记"/"备注")
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
ProctoringStatusQuery,
|
||||
ProctoringEventMutation,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
import type {
|
||||
ProctoringStudent,
|
||||
ProctoringEvent,
|
||||
ProctoringStudentStatus,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 学生状态中文映射 */
|
||||
const STUDENT_STATUS_LABEL: Record<ProctoringStudentStatus, string> = {
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
submitted: "已交卷",
|
||||
flagged: "异常",
|
||||
};
|
||||
|
||||
/** 学生状态对应颜色 */
|
||||
function studentStatusColor(status: ProctoringStudentStatus): string {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "var(--color-success)";
|
||||
case "submitted":
|
||||
return "var(--color-ink-muted)";
|
||||
case "flagged":
|
||||
return "var(--color-danger)";
|
||||
default:
|
||||
return "var(--color-warning)";
|
||||
}
|
||||
}
|
||||
|
||||
/** 事件类型中文映射 */
|
||||
const EVENT_TYPE_LABEL: Record<string, string> = {
|
||||
WARNING: "警告",
|
||||
FLAG: "标记",
|
||||
NOTE: "备注",
|
||||
};
|
||||
|
||||
/** 事件类型颜色 */
|
||||
function eventTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case "WARNING":
|
||||
return "var(--color-warning)";
|
||||
case "FLAG":
|
||||
return "var(--color-danger)";
|
||||
case "NOTE":
|
||||
return "var(--color-ink-muted)";
|
||||
default:
|
||||
return "var(--color-ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态筛选选项 */
|
||||
const STATUS_FILTER_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "online", label: "在线" },
|
||||
{ value: "offline", label: "离线" },
|
||||
{ value: "submitted", label: "已交卷" },
|
||||
{ value: "flagged", label: "异常" },
|
||||
];
|
||||
|
||||
export default function ProctoringPage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const examId = params?.id ?? "";
|
||||
|
||||
const [result, reexecuteQuery] = useQuery({
|
||||
query: ProctoringStatusQuery,
|
||||
variables: { examId },
|
||||
pause: !examId,
|
||||
requestPolicy: "network-only",
|
||||
});
|
||||
|
||||
const [, submitEvent] = useMutation(ProctoringEventMutation);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||||
const [proctoringActive, setProctoringActive] = useState(false);
|
||||
|
||||
const status = result.data?.proctoringStatus;
|
||||
const summary = status?.summary;
|
||||
const allStudents: ProctoringStudent[] = status?.students ?? [];
|
||||
const recentEvents: ProctoringEvent[] = status?.recentEvents ?? [];
|
||||
|
||||
// 按状态筛选学生
|
||||
const filteredStudents = statusFilter
|
||||
? allStudents.filter((s) => s.status === statusFilter)
|
||||
: allStudents;
|
||||
|
||||
/** 提交监考事件 */
|
||||
const handleEvent = async (
|
||||
studentId: string,
|
||||
eventType: "WARNING" | "FLAG" | "NOTE",
|
||||
note?: string,
|
||||
) => {
|
||||
setActionMsg(null);
|
||||
const res = await submitEvent({
|
||||
input: {
|
||||
examId,
|
||||
studentId,
|
||||
eventType,
|
||||
note: note ?? null,
|
||||
},
|
||||
});
|
||||
if (res.error) {
|
||||
setActionMsg(`操作失败:${res.error.message}`);
|
||||
return;
|
||||
}
|
||||
setActionMsg("已提交事件");
|
||||
// 刷新数据
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
/** 手动刷新 */
|
||||
const handleRefresh = () => {
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
/** 开始/结束监考(mock:本地状态切换) */
|
||||
const toggleProctoring = () => {
|
||||
setProctoringActive((prev) => !prev);
|
||||
handleRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/exams/${examId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考试详情
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">监考面板</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ProctoringStatusQuery + ProctoringEventMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : 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>
|
||||
) : !status || !summary ? (
|
||||
<Empty title="未找到监考数据" description="该考试无监考信息" />
|
||||
) : (
|
||||
<section className="space-y-8">
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{summary.startedAt && (
|
||||
<p className="text-tiny text-ink-muted">
|
||||
监考开始:
|
||||
{new Date(summary.startedAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="px-3 py-1.5 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleProctoring}
|
||||
className={
|
||||
proctoringActive
|
||||
? "px-4 py-2 text-sm text-ink-on-accent bg-danger rounded-button hover:opacity-90"
|
||||
: "px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
}
|
||||
>
|
||||
{proctoringActive ? "结束监考" : "开始监考"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 摘要卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<SummaryCard
|
||||
label="应考数"
|
||||
value={summary.expectedCount}
|
||||
color="var(--color-ink)"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="在线数"
|
||||
value={summary.onlineCount}
|
||||
color="var(--color-success)"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="已交卷"
|
||||
value={summary.submittedCount}
|
||||
color="var(--color-ink-muted)"
|
||||
/>
|
||||
<SummaryCard
|
||||
label="异常数"
|
||||
value={summary.flaggedCount}
|
||||
color="var(--color-danger)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{actionMsg && (
|
||||
<p className="text-tiny text-success">{actionMsg}</p>
|
||||
)}
|
||||
|
||||
{/* 左右分栏:学生列表 + 事件流 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 左侧:学生状态列表 */}
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-lg font-serif text-ink">学生状态</h3>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-2 py-1 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
{STATUS_FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{filteredStudents.length === 0 ? (
|
||||
<Empty title="暂无学生" description="当前筛选无学生记录" />
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{filteredStudents.map((stu) => (
|
||||
<li
|
||||
key={stu.studentId}
|
||||
className="flex items-center justify-between p-3 border border-rule rounded-card hover:bg-subtle"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-serif text-ink">
|
||||
{stu.studentName}
|
||||
<span className="ml-2 text-tiny font-mono text-ink-muted">
|
||||
{stu.studentNo}
|
||||
</span>
|
||||
</p>
|
||||
{stu.lastEventAt && (
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
最近事件:
|
||||
{new Date(stu.lastEventAt).toLocaleTimeString("zh-CN")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-tiny"
|
||||
style={{ color: studentStatusColor(stu.status) }}
|
||||
>
|
||||
{STUDENT_STATUS_LABEL[stu.status]}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleEvent(stu.studentId, "FLAG", "教师标记异常")
|
||||
}
|
||||
className="px-2 py-0.5 text-tiny text-danger bg-transparent border border-danger rounded-button hover:bg-subtle"
|
||||
>
|
||||
标记
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const note = window.prompt("输入备注:");
|
||||
if (note) handleEvent(stu.studentId, "NOTE", note);
|
||||
}}
|
||||
className="px-2 py-0.5 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
备注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右侧:事件流 */}
|
||||
<section>
|
||||
<h3 className="text-lg font-serif text-ink mb-4">近期事件</h3>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{recentEvents.length === 0 ? (
|
||||
<Empty title="暂无事件" description="监考期间无事件记录" />
|
||||
) : (
|
||||
<ul className="space-y-3 max-h-[600px] overflow-y-auto">
|
||||
{recentEvents.map((evt) => (
|
||||
<li
|
||||
key={evt.eventId}
|
||||
className="p-3 border border-rule rounded-card"
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-1">
|
||||
<span
|
||||
className="text-tiny font-serif"
|
||||
style={{ color: eventTypeColor(evt.eventType) }}
|
||||
>
|
||||
{EVENT_TYPE_LABEL[evt.eventType] ?? evt.eventType}
|
||||
</span>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
{new Date(evt.createdAt).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink">
|
||||
<span className="font-serif">{evt.studentName}</span>
|
||||
<span className="ml-2 text-tiny font-mono text-ink-muted">
|
||||
{evt.studentNo}
|
||||
</span>
|
||||
</p>
|
||||
{evt.note && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
{evt.note}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 摘要卡片子组件 */
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="p-4 border border-rule rounded-card bg-surface">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 text-3xl font-serif"
|
||||
style={{ color }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
apps/teacher-portal/src/app/(app)/exams/new/page.tsx
Normal file
249
apps/teacher-portal/src/app/(app)/exams/new/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 新建考试表单页
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL CreateExamMutation(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 预填班级 ID
|
||||
* - 表单字段:标题、描述、考试日期、时长、满分
|
||||
* - 乐观更新:本地先插入临时项,失败后回滚(基于 urql mutation cache 更新)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useMutation, useQuery } from "urql";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
import { CreateExamMutation, ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
|
||||
function NewExamContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
// 拉取班级列表(用于班级选择)
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
|
||||
const [createExamResult, createExam] = useMutation(CreateExamMutation);
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [examDate, setExamDate] = useState("");
|
||||
const [duration, setDuration] = useState("120");
|
||||
const [totalScore, setTotalScore] = useState("100");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!classId) {
|
||||
setError("请选择班级");
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setError("请填写考试标题");
|
||||
return;
|
||||
}
|
||||
if (!examDate) {
|
||||
setError("请选择考试日期");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
// 乐观更新:urql mutation 完成后由组件状态驱动跳转;
|
||||
// 失败回滚体现在错误信息展示 + 不跳转。
|
||||
const res = await createExam({
|
||||
input: {
|
||||
classId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
examDate: new Date(examDate).toISOString(),
|
||||
duration: Number(duration) || 0,
|
||||
totalScore: Number(totalScore) || 0,
|
||||
},
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
// 成功后跳回考试列表
|
||||
router.push(`/exams?classId=${classId}`);
|
||||
};
|
||||
|
||||
if (classesResult.fetching) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={4} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = (classesResult.data?.classes ?? []) as Class[];
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/exams?classId=${classId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回考试列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">新建考试</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL CreateExamMutation · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
<section className="max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 班级选择 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
标题 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
placeholder="例如:2026 春季期中考试"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="考试范围、注意事项等"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 考试日期 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
考试日期 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={examDate}
|
||||
onChange={(e) => setExamDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 时长 + 满分 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
时长(分钟)<span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
满分 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={totalScore}
|
||||
onChange={(e) => setTotalScore(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "提交中..." : "创建考试"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/exams?classId=${classId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
{createExamResult.data && (
|
||||
<span className="text-tiny text-success">创建成功</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewExamPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={3} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NewExamContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
* 数据来源(contract.md §2.4):GraphQL ClassExamsQuery(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 指定班级
|
||||
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
|
||||
* - P3:列表项可点击进入详情页,顶部提供"新建考试"入口
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassExamsQuery, ClassQuery } from "@/lib/graphql";
|
||||
@@ -46,13 +48,21 @@ function ExamsContent() {
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">
|
||||
{cls ? `${cls.name} - 考试管理` : "考试管理"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassExamsQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">
|
||||
{cls ? `${cls.name} - 考试管理` : "考试管理"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassExamsQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/exams/new?classId=${classId}`}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建考试
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
@@ -66,16 +76,31 @@ function ExamsContent() {
|
||||
</p>
|
||||
</div>
|
||||
) : exams.length === 0 ? (
|
||||
<Empty title="暂无考试" description="该班级尚未创建考试" />
|
||||
<Empty
|
||||
title="暂无考试"
|
||||
description="该班级尚未创建考试"
|
||||
action={
|
||||
<Link
|
||||
href={`/exams/new?classId=${classId}`}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
创建第一场考试
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{(exams as ExamItem[]).map((exam) => (
|
||||
<li
|
||||
key={exam.id}
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<div className="col-span-7">
|
||||
<h3 className="text-lg font-serif text-ink">{exam.title}</h3>
|
||||
<Link href={`/exams/${exam.id}`}>
|
||||
<h3 className="text-lg font-serif text-ink hover:text-accent">
|
||||
{exam.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{exam.description && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
{exam.description}
|
||||
@@ -90,8 +115,13 @@ function ExamsContent() {
|
||||
<div className="col-span-3 text-tiny text-ink-muted">
|
||||
状态: {exam.status}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
|
||||
{exam.id.slice(0, 8)}...
|
||||
<div className="col-span-2 text-right text-tiny">
|
||||
<Link
|
||||
href={`/exams/${exam.id}`}
|
||||
className="font-mono text-ink-muted hover:text-accent"
|
||||
>
|
||||
查看详情 →
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
598
apps/teacher-portal/src/app/(app)/grades/analytics/charts.tsx
Normal file
598
apps/teacher-portal/src/app/(app)/grades/analytics/charts.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩分析图表组件集(纯 SVG,无外部依赖)
|
||||
*
|
||||
* 5 个图表:
|
||||
* 1. TrendLineChart - 趋势折线图(avg/max/min 三线)
|
||||
* 2. DistributionBarChart - 分数分布柱图(5 段)
|
||||
* 3. SubjectComparisonChart - 学科对比柱图(6 学科)
|
||||
* 4. ClassComparisonChart - 班级对比柱图(含显著性 * 标记)
|
||||
* 5. KnowledgeRadarChart - 知识点掌握度雷达图(5 轴)
|
||||
*
|
||||
* 颜色使用 var(--color-*) 语义令牌(project_rules §3.10)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type {
|
||||
GradeTrendPoint,
|
||||
GradeDistributionBand,
|
||||
SubjectComparisonItem,
|
||||
ClassComparisonItem,
|
||||
KnowledgeMasteryItem,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
|
||||
// ============ 1. 趋势折线图(avg/max/min 三线) ============
|
||||
|
||||
const TREND_PADDING = { top: 24, right: 32, bottom: 36, left: 36 };
|
||||
const TREND_W = 520;
|
||||
const TREND_H = 220;
|
||||
|
||||
export function TrendLineChart({ data }: { data: GradeTrendPoint[] }): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无趋势数据</p>;
|
||||
}
|
||||
const innerW = TREND_W - TREND_PADDING.left - TREND_PADDING.right;
|
||||
const innerH = TREND_H - TREND_PADDING.top - TREND_PADDING.bottom;
|
||||
|
||||
const allValues = data.flatMap((d) => [d.avg, d.max, d.min]);
|
||||
const yMax = Math.max(100, ...allValues);
|
||||
const yMin = Math.min(0, ...allValues);
|
||||
const yRange = yMax - yMin || 1;
|
||||
|
||||
const xStep = data.length > 1 ? innerW / (data.length - 1) : 0;
|
||||
|
||||
const toPoint = (value: number, i: number) => ({
|
||||
x: TREND_PADDING.left + i * xStep,
|
||||
y: TREND_PADDING.top + innerH - ((value - yMin) / yRange) * innerH,
|
||||
});
|
||||
|
||||
const buildPath = (key: "avg" | "max" | "min") =>
|
||||
data
|
||||
.map((d, i) => {
|
||||
const p = toPoint(d[key], i);
|
||||
return `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Y 轴网格(0 / 50 / 100)
|
||||
const gridLines = [0, 50, 100].map((v) => {
|
||||
const ratio = 1 - (v - yMin) / yRange;
|
||||
return { y: TREND_PADDING.top + innerH * ratio, value: v };
|
||||
});
|
||||
|
||||
const lineColor = (key: "avg" | "max" | "min") =>
|
||||
key === "avg"
|
||||
? "var(--color-accent)"
|
||||
: key === "max"
|
||||
? "var(--color-success)"
|
||||
: "var(--color-danger)";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${TREND_W} ${TREND_H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="成绩趋势折线图"
|
||||
>
|
||||
{/* 网格线 + Y 刻度 */}
|
||||
{gridLines.map((g, i) => (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={TREND_PADDING.left}
|
||||
y1={g.y}
|
||||
x2={TREND_W - TREND_PADDING.right}
|
||||
y2={g.y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={TREND_PADDING.left - 8}
|
||||
y={g.y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{g.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* 三条线 */}
|
||||
{(["max", "avg", "min"] as const).map((key) => (
|
||||
<g key={key}>
|
||||
<path
|
||||
d={buildPath(key)}
|
||||
fill="none"
|
||||
stroke={lineColor(key)}
|
||||
strokeWidth={key === "avg" ? 2.5 : 1.5}
|
||||
strokeDasharray={key === "avg" ? undefined : "4 3"}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{data.map((d, i) => {
|
||||
const p = toPoint(d[key], i);
|
||||
return (
|
||||
<circle
|
||||
key={`${key}-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={key === "avg" ? 4 : 3}
|
||||
fill={lineColor(key)}
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
{/* X 轴月份标签 */}
|
||||
{data.map((d, i) => (
|
||||
<text
|
||||
key={`x-${i}`}
|
||||
x={TREND_PADDING.left + i * xStep}
|
||||
y={TREND_H - TREND_PADDING.bottom + 18}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.month}
|
||||
</text>
|
||||
))}
|
||||
{/* avg 数值标签 */}
|
||||
{data.map((d, i) => {
|
||||
const p = toPoint(d.avg, i);
|
||||
return (
|
||||
<text
|
||||
key={`avg-label-${i}`}
|
||||
x={p.x}
|
||||
y={p.y - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{d.avg}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 2. 分数分布柱图 ============
|
||||
|
||||
export function DistributionBarChart({
|
||||
data,
|
||||
}: {
|
||||
data: GradeDistributionBand[];
|
||||
}): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无分布数据</p>;
|
||||
}
|
||||
const maxCount = Math.max(1, ...data.map((d) => d.count));
|
||||
const W = 480;
|
||||
const H = 180;
|
||||
const PAD = { top: 20, right: 16, bottom: 36, left: 36 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const barW = innerW / data.length;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="分数分布柱图"
|
||||
>
|
||||
{/* Y 轴网格 */}
|
||||
{[0, 0.5, 1].map((ratio, i) => {
|
||||
const y = PAD.top + innerH * ratio;
|
||||
const value = Math.round(maxCount * (1 - ratio));
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={y}
|
||||
x2={W - PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* 柱子 */}
|
||||
{data.map((band, i) => {
|
||||
const barH = (band.count / maxCount) * innerH;
|
||||
const x = PAD.left + i * barW + barW * 0.2;
|
||||
const y = PAD.top + innerH - barH;
|
||||
const w = barW * 0.6;
|
||||
return (
|
||||
<g key={band.label}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={Math.max(0, barH)}
|
||||
fill="var(--color-accent)"
|
||||
rx={3}
|
||||
/>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={y - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{band.count}
|
||||
</text>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={H - PAD.bottom + 18}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{band.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 3. 学科对比柱图 ============
|
||||
|
||||
export function SubjectComparisonChart({
|
||||
data,
|
||||
}: {
|
||||
data: SubjectComparisonItem[];
|
||||
}): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无学科对比数据</p>;
|
||||
}
|
||||
const maxAvg = Math.max(1, ...data.map((d) => d.avg));
|
||||
const W = 480;
|
||||
const H = 180;
|
||||
const PAD = { top: 20, right: 16, bottom: 36, left: 36 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const barW = innerW / data.length;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="学科对比柱图"
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio, i) => {
|
||||
const y = PAD.top + innerH * ratio;
|
||||
const value = Math.round(maxAvg * (1 - ratio));
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={y}
|
||||
x2={W - PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{data.map((item, i) => {
|
||||
const barH = (item.avg / maxAvg) * innerH;
|
||||
const x = PAD.left + i * barW + barW * 0.2;
|
||||
const y = PAD.top + innerH - barH;
|
||||
const w = barW * 0.6;
|
||||
return (
|
||||
<g key={item.subject}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={Math.max(0, barH)}
|
||||
fill="var(--color-accent)"
|
||||
rx={3}
|
||||
/>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={y - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{item.avg}
|
||||
</text>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={H - PAD.bottom + 18}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{item.subject}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 4. 班级对比柱图(含显著性 * 标记) ============
|
||||
|
||||
export function ClassComparisonChart({
|
||||
data,
|
||||
}: {
|
||||
data: ClassComparisonItem[];
|
||||
}): React.ReactNode {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">暂无班级对比数据</p>;
|
||||
}
|
||||
const maxAvg = Math.max(1, ...data.map((d) => d.avg));
|
||||
const W = 480;
|
||||
const H = 200;
|
||||
const PAD = { top: 24, right: 16, bottom: 48, left: 36 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const barW = innerW / data.length;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="班级对比柱图"
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio, i) => {
|
||||
const y = PAD.top + innerH * ratio;
|
||||
const value = Math.round(maxAvg * (1 - ratio));
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={y}
|
||||
x2={W - PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{data.map((item, i) => {
|
||||
const barH = (item.avg / maxAvg) * innerH;
|
||||
const x = PAD.left + i * barW + barW * 0.2;
|
||||
const y = PAD.top + innerH - barH;
|
||||
const w = barW * 0.6;
|
||||
return (
|
||||
<g key={item.classId}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={Math.max(0, barH)}
|
||||
fill={
|
||||
item.significant
|
||||
? "var(--color-warning)"
|
||||
: "var(--color-accent)"
|
||||
}
|
||||
rx={3}
|
||||
/>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={y - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{item.avg}
|
||||
{item.significant ? "*" : ""}
|
||||
</text>
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={H - PAD.bottom + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{item.className}
|
||||
</text>
|
||||
{item.significant && (
|
||||
<text
|
||||
x={x + w / 2}
|
||||
y={H - PAD.bottom + 28}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-warning)"
|
||||
>
|
||||
显著差异
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 5. 知识点掌握度雷达图 ============
|
||||
|
||||
export function KnowledgeRadarChart({
|
||||
data,
|
||||
}: {
|
||||
data: KnowledgeMasteryItem[];
|
||||
}): React.ReactNode {
|
||||
if (data.length < 3) {
|
||||
return <p className="text-sm text-ink-muted">暂无知识点掌握度数据</p>;
|
||||
}
|
||||
const W = 360;
|
||||
const H = 360;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
const radius = 120;
|
||||
const n = data.length;
|
||||
// 5 个层级(20/40/60/80/100)
|
||||
const rings = [0.2, 0.4, 0.6, 0.8, 1];
|
||||
|
||||
// 计算顶点坐标
|
||||
const angle = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
|
||||
const pointAt = (i: number, r: number) => ({
|
||||
x: cx + r * Math.cos(angle(i)),
|
||||
y: cy + r * Math.sin(angle(i)),
|
||||
});
|
||||
|
||||
// 数据多边形顶点
|
||||
const dataPoints = data.map((d, i) =>
|
||||
pointAt(i, (d.mastery / 100) * radius),
|
||||
);
|
||||
|
||||
const dataPath = dataPoints
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
|
||||
.join(" ") + " Z";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full max-w-sm border border-rule rounded-card bg-surface mx-auto"
|
||||
role="img"
|
||||
aria-label="知识点掌握度雷达图"
|
||||
>
|
||||
{/* 同心多边形网格 */}
|
||||
{rings.map((ratio, ri) => {
|
||||
const r = radius * ratio;
|
||||
const pts = data
|
||||
.map((_, i) => {
|
||||
const p = pointAt(i, r);
|
||||
return `${p.x},${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<polygon
|
||||
key={`ring-${ri}`}
|
||||
points={pts}
|
||||
fill="none"
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={ri === rings.length - 1 ? undefined : "3 3"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 轴线 */}
|
||||
{data.map((_, i) => {
|
||||
const p = pointAt(i, radius);
|
||||
return (
|
||||
<line
|
||||
key={`axis-${i}`}
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={p.x}
|
||||
y2={p.y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 数据多边形 */}
|
||||
<path
|
||||
d={dataPath}
|
||||
fill="var(--color-accent)"
|
||||
fillOpacity={0.15}
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* 数据点 + 标签 */}
|
||||
{dataPoints.map((p, i) => {
|
||||
const d = data[i];
|
||||
if (!d) return null;
|
||||
const labelP = pointAt(i, radius + 24);
|
||||
return (
|
||||
<g key={`pt-${i}`}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4}
|
||||
fill="var(--color-accent)"
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={labelP.x}
|
||||
y={labelP.y}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.knowledgePoint}
|
||||
</text>
|
||||
<text
|
||||
x={labelP.x}
|
||||
y={labelP.y + 13}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-accent)"
|
||||
>
|
||||
{d.mastery}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
217
apps/teacher-portal/src/app/(app)/grades/analytics/page.tsx
Normal file
217
apps/teacher-portal/src/app/(app)/grades/analytics/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩分析仪表盘
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL GradeAnalyticsQuery:趋势/分布/学科对比/班级对比/知识点掌握度
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 5 个 SVG 图表(参考 analytics/TrendChart.tsx 模式):
|
||||
* 1. 趋势折线图(avg/max/min 三线)
|
||||
* 2. 分数分布柱图(5 段)
|
||||
* 3. 学科对比柱图(6 学科)
|
||||
* 4. 班级对比柱图(含显著性 * 标记)
|
||||
* 5. 知识点掌握度雷达图(5 轴)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import { GradeAnalyticsQuery } from "@/lib/graphql-p7-grades";
|
||||
import type { GradeAnalytics } from "@/lib/graphql-p7-grades";
|
||||
import {
|
||||
TrendLineChart,
|
||||
DistributionBarChart,
|
||||
SubjectComparisonChart,
|
||||
ClassComparisonChart,
|
||||
KnowledgeRadarChart,
|
||||
} from "./charts";
|
||||
|
||||
/** 学期选项 */
|
||||
const SEMESTERS = [
|
||||
{ value: "", label: "全部学期" },
|
||||
{ value: "1", label: "第一学期" },
|
||||
{ value: "2", label: "第二学期" },
|
||||
];
|
||||
|
||||
/** 考试类型选项 */
|
||||
const EXAM_TYPES = [
|
||||
{ value: "", label: "全部考试" },
|
||||
{ value: "midterm", label: "期中考试" },
|
||||
{ value: "final", label: "期末考试" },
|
||||
{ value: "unit", label: "单元测验" },
|
||||
];
|
||||
|
||||
export default function GradeAnalyticsPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [semester, setSemester] = useState("");
|
||||
const [examType, setExamType] = useState("");
|
||||
|
||||
// 班级列表
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
|
||||
// classId 为空时回退到第一个班级
|
||||
const targetClassId = classId || firstClassId;
|
||||
|
||||
const [analyticsResult] = useQuery({
|
||||
query: GradeAnalyticsQuery,
|
||||
variables: {
|
||||
classId: targetClassId,
|
||||
semester: semester || null,
|
||||
examType: examType || null,
|
||||
},
|
||||
pause: !targetClassId,
|
||||
});
|
||||
|
||||
const analytics: GradeAnalytics | undefined =
|
||||
analyticsResult.data?.gradeAnalytics;
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/grades"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回成绩查询
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">成绩分析仪表盘</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL GradeAnalyticsQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选区 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={targetClassId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学期
|
||||
</label>
|
||||
<select
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{SEMESTERS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
考试类型
|
||||
</label>
|
||||
<select
|
||||
value={examType}
|
||||
onChange={(e) => setExamType(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{EXAM_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{classesResult.fetching ? (
|
||||
<Loading lines={4} />
|
||||
) : analyticsResult.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : analyticsResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{analyticsResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : !analytics ? (
|
||||
<Empty title="暂无分析数据" description="请选择班级后查看成绩分析" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 1. 趋势折线图 */}
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">成绩趋势</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
实线为平均分,虚线为最高/最低分
|
||||
</p>
|
||||
<TrendLineChart data={analytics.trend} />
|
||||
</div>
|
||||
|
||||
{/* 2. 分数分布柱图 */}
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">分数段分布</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
按五段统计学生人数
|
||||
</p>
|
||||
<DistributionBarChart data={analytics.distribution} />
|
||||
</div>
|
||||
|
||||
{/* 3. 学科对比柱图 */}
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">学科对比</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
六大学科平均分对比
|
||||
</p>
|
||||
<SubjectComparisonChart data={analytics.subjectComparison} />
|
||||
</div>
|
||||
|
||||
{/* 4. 班级对比柱图 */}
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
班级对比
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
* 标记表示与其他班级存在显著差异
|
||||
</p>
|
||||
<ClassComparisonChart data={analytics.classComparison} />
|
||||
</div>
|
||||
|
||||
{/* 5. 知识点掌握度雷达图 */}
|
||||
<div className="p-4 border border-rule rounded-card lg:col-span-2">
|
||||
<h2 className="text-base font-serif text-ink mb-3">
|
||||
知识点掌握度
|
||||
</h2>
|
||||
<p className="text-tiny text-ink-muted mb-3">
|
||||
五大知识点掌握度雷达图
|
||||
</p>
|
||||
<KnowledgeRadarChart data={analytics.knowledgeMastery} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
325
apps/teacher-portal/src/app/(app)/grades/entry/page.tsx
Normal file
325
apps/teacher-portal/src/app/(app)/grades/entry/page.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 成绩批量录入页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL GradeEntryQuery:按 examId + classId 拉取学生成绩录入列表
|
||||
* - GraphQL SaveGradeEntriesMutation:批量保存成绩录入
|
||||
* - GraphQL ClassesQuery + ClassExamsQuery:班级 + 试卷下拉选项
|
||||
*
|
||||
* 试卷-班级年级匹配:仅展示同年级的试卷/班级(按 gradeId 过滤)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery, ClassExamsQuery } from "@/lib/graphql";
|
||||
import type { Class, ExamItem } from "@/lib/graphql";
|
||||
import { GradeEntryQuery, SaveGradeEntriesMutation } from "@/lib/graphql-p7-grades";
|
||||
import type { GradeEntryItem, SaveGradeEntryInput } from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 单行编辑状态 */
|
||||
interface EntryRow {
|
||||
studentId: string;
|
||||
score: string;
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export default function GradeEntryPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [examId, setExamId] = useState("");
|
||||
|
||||
// 班级列表
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
|
||||
// 选中班级后拉取试卷列表(同年级过滤:ClassExamsQuery 已按 classId 返回该班试卷)
|
||||
const [examsResult] = useQuery({
|
||||
query: ClassExamsQuery,
|
||||
variables: { classId },
|
||||
pause: !classId,
|
||||
});
|
||||
const exams: ExamItem[] = examsResult.data?.classExams ?? [];
|
||||
|
||||
// 成绩录入列表
|
||||
const [entryResult, reexecuteEntry] = useQuery({
|
||||
query: GradeEntryQuery,
|
||||
variables: { examId, classId },
|
||||
pause: !examId || !classId,
|
||||
});
|
||||
const entries: GradeEntryItem[] = entryResult.data?.gradeEntry ?? [];
|
||||
|
||||
// 批量保存 mutation
|
||||
const [saveResult, saveEntries] = useMutation(SaveGradeEntriesMutation);
|
||||
|
||||
// 编辑行状态(按 studentId 索引)
|
||||
const [rows, setRows] = useState<Record<string, EntryRow>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||
|
||||
// 当 entries 变化时,初始化 rows
|
||||
useMemo(() => {
|
||||
const next: Record<string, EntryRow> = {};
|
||||
entries.forEach((e) => {
|
||||
next[e.studentId] = {
|
||||
studentId: e.studentId,
|
||||
score: e.score?.toString() ?? "",
|
||||
feedback: e.feedback ?? "",
|
||||
};
|
||||
});
|
||||
setRows(next);
|
||||
setSavedMsg(null);
|
||||
setSaveError(null);
|
||||
}, [entries]);
|
||||
|
||||
// 年级匹配校验:选中班级后,同年级班级列表(用于校验提示)
|
||||
const selectedClass = classes.find((c) => c.id === classId);
|
||||
const sameGradeClassNames = useMemo(() => {
|
||||
if (!selectedClass) return [];
|
||||
return classes
|
||||
.filter((c) => c.gradeId === selectedClass.gradeId)
|
||||
.map((c) => c.name);
|
||||
}, [classes, selectedClass]);
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
if (!examId || !classId) {
|
||||
setSaveError("请先选择班级和试卷");
|
||||
return;
|
||||
}
|
||||
const payload: SaveGradeEntryInput[] = [];
|
||||
for (const e of entries) {
|
||||
const row = rows[e.studentId];
|
||||
if (!row) continue;
|
||||
const scoreNum = Number(row.score);
|
||||
if (row.score === "" || Number.isNaN(scoreNum)) continue;
|
||||
payload.push({
|
||||
studentId: e.studentId,
|
||||
score: scoreNum,
|
||||
feedback: row.feedback.trim() || null,
|
||||
});
|
||||
}
|
||||
if (payload.length === 0) {
|
||||
setSaveError("请至少填写一条有效成绩");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
setSavedMsg(null);
|
||||
// 乐观更新:本地展示保存中状态
|
||||
const res = await saveEntries({
|
||||
input: { examId, classId, entries: payload },
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setSaveError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setSavedMsg(`已保存 ${res.data?.saveGradeEntries?.savedCount ?? payload.length} 条成绩`);
|
||||
// 刷新录入列表
|
||||
reexecuteEntry({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/grades"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回成绩查询
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">成绩批量录入</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL GradeEntryQuery + SaveGradeEntriesMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级 + 试卷选择 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => {
|
||||
setClassId(e.target.value);
|
||||
setExamId("");
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
试卷
|
||||
</label>
|
||||
<select
|
||||
value={examId}
|
||||
onChange={(e) => setExamId(e.target.value)}
|
||||
disabled={!classId}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
>
|
||||
<option value="">请选择试卷</option>
|
||||
{exams.map((ex) => (
|
||||
<option key={ex.id} value={ex.id}>
|
||||
{ex.title}({ex.totalScore} 分)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{selectedClass && sameGradeClassNames.length > 0 && (
|
||||
<p className="text-tiny text-ink-muted">
|
||||
同年级班级:{sameGradeClassNames.join("、")}(年级 {selectedClass.gradeId})
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!classId || !examId ? (
|
||||
<Empty
|
||||
title="请选择班级和试卷"
|
||||
description="选择班级后选择试卷,再拉取学生成绩录入列表"
|
||||
/>
|
||||
) : entryResult.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : entryResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{entryResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<Empty title="暂无学生" description="该班级尚未导入学生名单" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
学生成绩录入
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{entries.length} 名学生
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{saveError && (
|
||||
<span className="text-tiny text-danger">{saveError}</span>
|
||||
)}
|
||||
{savedMsg && !submitting && (
|
||||
<span className="text-tiny text-success">{savedMsg}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAll}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "保存中..." : "保存全部"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{/* 录入表格 */}
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
|
||||
分数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
反馈
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const row = rows[e.studentId];
|
||||
return (
|
||||
<tr
|
||||
key={e.studentId}
|
||||
className="border-t border-rule"
|
||||
>
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{e.studentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{e.studentNo}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={row?.score ?? ""}
|
||||
onChange={(ev) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[e.studentId]: {
|
||||
studentId: e.studentId,
|
||||
score: ev.target.value,
|
||||
feedback: row?.feedback ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-24 px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="0-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={row?.feedback ?? ""}
|
||||
onChange={(ev) =>
|
||||
setRows((prev) => ({
|
||||
...prev,
|
||||
[e.studentId]: {
|
||||
studentId: e.studentId,
|
||||
score: row?.score ?? "",
|
||||
feedback: ev.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="可选:批改意见"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{saveResult.data && !saveError && !submitting && (
|
||||
<p className="mt-4 text-tiny text-success">
|
||||
已保存 {saveResult.data.saveGradeEntries?.savedCount ?? 0} 条成绩记录
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,286 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Grades 页面 - 成绩查询
|
||||
* Grades 页面 - 成绩查询 + 录入 + 分析图表
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL ExamGradesQuery(P3 扩展,MSW mock)
|
||||
* 数据来源(contract.md §2.4):
|
||||
* - GraphQL ExamGradesQuery(P3 扩展,MSW mock)拉取成绩列表
|
||||
* - GraphQL RecordGradeMutation(P3 扩展,MSW mock)录入成绩
|
||||
* - 通过 URL ?examId=xxx 指定考试
|
||||
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
|
||||
* - 成绩分析:平均分/最高分/最低分/及格率 + 纯 SVG 柱状图(不引入 recharts,P4 再迁移)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { useState, useMemo, useRef, Suspense } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ExamGradesQuery } from "@/lib/graphql";
|
||||
import { ExamGradesQuery, RecordGradeMutation } from "@/lib/graphql";
|
||||
import type { GradeItem } from "@/lib/graphql";
|
||||
import { SaveGradeEntriesMutation } from "@/lib/graphql-p7-grades";
|
||||
import type { SaveGradeEntryInput } from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 柱状图分段(成绩分布) */
|
||||
interface ScoreBand {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 计算成绩统计 */
|
||||
function computeStats(grades: GradeItem[]) {
|
||||
if (grades.length === 0) {
|
||||
return {
|
||||
average: 0,
|
||||
max: 0,
|
||||
min: 0,
|
||||
passRate: 0,
|
||||
bands: [] as ScoreBand[],
|
||||
};
|
||||
}
|
||||
const scores = grades.map((g) => g.score);
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0);
|
||||
const average = sum / scores.length;
|
||||
const max = Math.max(...scores);
|
||||
const min = Math.min(...scores);
|
||||
const passCount = scores.filter((s) => s >= 60).length;
|
||||
const passRate = (passCount / scores.length) * 100;
|
||||
|
||||
// 分段:0-59, 60-69, 70-79, 80-89, 90-100
|
||||
const bandDefs = [
|
||||
{ label: "0-59", min: 0, max: 59 },
|
||||
{ label: "60-69", min: 60, max: 69 },
|
||||
{ label: "70-79", min: 70, max: 79 },
|
||||
{ label: "80-89", min: 80, max: 89 },
|
||||
{ label: "90-100", min: 90, max: 100 },
|
||||
];
|
||||
const bands: ScoreBand[] = bandDefs.map((b) => ({
|
||||
...b,
|
||||
count: scores.filter((s) => s >= b.min && s <= b.max).length,
|
||||
}));
|
||||
|
||||
return { average, max, min, passRate, bands };
|
||||
}
|
||||
|
||||
function GradesContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialExamId = searchParams.get("examId") ?? "";
|
||||
const [examId, setExamId] = useState(initialExamId);
|
||||
|
||||
const [gradesResult] = useQuery({
|
||||
const [gradesResult, reexecuteQuery] = useQuery({
|
||||
query: ExamGradesQuery,
|
||||
variables: { examId },
|
||||
pause: !examId,
|
||||
});
|
||||
|
||||
const grades = gradesResult.data?.examGrades ?? [];
|
||||
const [recordResult, recordGrade] = useMutation(RecordGradeMutation);
|
||||
// P7:批量导入成绩 mutation
|
||||
const [, saveEntries] = useMutation(SaveGradeEntriesMutation);
|
||||
|
||||
// 录入表单状态
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [studentId, setStudentId] = useState("");
|
||||
const [score, setScore] = useState("");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// P7:Excel 导入状态
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importMsg, setImportMsg] = useState<string | null>(null);
|
||||
|
||||
const grades = (gradesResult.data?.examGrades ?? []) as GradeItem[];
|
||||
|
||||
// 成绩统计(useMemo 避免重复计算)
|
||||
const stats = useMemo(() => computeStats(grades), [grades]);
|
||||
const maxBandCount = Math.max(1, ...stats.bands.map((b) => b.count));
|
||||
|
||||
const handleRecordGrade = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!studentId.trim()) {
|
||||
setFormError("请填写学生 ID");
|
||||
return;
|
||||
}
|
||||
if (!examId) {
|
||||
setFormError("请先指定考试 ID");
|
||||
return;
|
||||
}
|
||||
const scoreNum = Number(score);
|
||||
if (Number.isNaN(scoreNum)) {
|
||||
setFormError("分数必须为数字");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
// 乐观更新:本地表单先清空,失败后保留输入由错误提示
|
||||
const res = await recordGrade({
|
||||
input: {
|
||||
studentId: studentId.trim(),
|
||||
examId,
|
||||
score: scoreNum,
|
||||
feedback: feedback.trim() || null,
|
||||
},
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (res.error) {
|
||||
setFormError(res.error.message);
|
||||
return;
|
||||
}
|
||||
// 成功后刷新成绩列表
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
setStudentId("");
|
||||
setScore("");
|
||||
setFeedback("");
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
// P7:Excel 导入处理(解析 CSV/文本为 entries,调用 SaveGradeEntriesMutation)
|
||||
const handleExcelImport = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !examId) {
|
||||
setImportMsg(examId ? "请选择文件" : "请先指定考试 ID");
|
||||
return;
|
||||
}
|
||||
setImportMsg(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
||||
const entries: SaveGradeEntryInput[] = [];
|
||||
for (const line of lines) {
|
||||
const parts = line.split(/[,\t]/).map((p) => p.trim());
|
||||
const sid = parts[0];
|
||||
const sc = parts[1];
|
||||
if (!sid || !sc) continue;
|
||||
const scoreNum = Number(sc);
|
||||
if (Number.isNaN(scoreNum)) continue;
|
||||
entries.push({ studentId: sid, score: scoreNum });
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
setImportMsg("未解析到有效数据(格式:学号,分数)");
|
||||
return;
|
||||
}
|
||||
const res = await saveEntries({
|
||||
input: { examId, classId: examId, entries },
|
||||
});
|
||||
if (res.error) {
|
||||
setImportMsg(`导入失败:${res.error.message}`);
|
||||
return;
|
||||
}
|
||||
setImportMsg(`已导入 ${entries.length} 条成绩`);
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
} catch {
|
||||
setImportMsg("文件读取失败");
|
||||
}
|
||||
// 清空 file input 以便重复选择同一文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// P7:导出当前成绩列表为 CSV
|
||||
const handleExportCsv = () => {
|
||||
if (grades.length === 0) return;
|
||||
const header = "学生ID,学生姓名,分数,反馈\n";
|
||||
const rows = grades
|
||||
.map(
|
||||
(g) =>
|
||||
`${g.studentId},${g.studentName},${g.score},${g.feedback ?? ""}`,
|
||||
)
|
||||
.join("\n");
|
||||
const csv = "\uFEFF" + header + rows;
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `成绩_${examId}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
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 ExamGradesQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">成绩查询</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ExamGradesQuery + RecordGradeMutation · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
disabled={grades.length === 0}
|
||||
className="px-3 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
导出 CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!examId}
|
||||
className="px-3 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
Excel 导入
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.csv"
|
||||
onChange={handleExcelImport}
|
||||
className="hidden"
|
||||
/>
|
||||
{examId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
{showForm ? "收起录入" : "录入成绩"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* P7:子模块导航 */}
|
||||
<nav className="mb-6 flex items-center gap-4">
|
||||
<Link
|
||||
href="/grades/entry"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 录入成绩
|
||||
</Link>
|
||||
<Link
|
||||
href="/grades/stats"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 统计
|
||||
</Link>
|
||||
<Link
|
||||
href="/grades/analytics"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 分析
|
||||
</Link>
|
||||
<Link
|
||||
href="/grades/report-card"
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
→ 报告卡
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{importMsg && (
|
||||
<p className="mb-4 text-tiny text-ink-muted">{importMsg}</p>
|
||||
)}
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
<div className="mb-6 flex items-baseline gap-3">
|
||||
@@ -54,6 +296,77 @@ function GradesContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 录入表单 */}
|
||||
{showForm && examId && (
|
||||
<section className="mb-8 p-4 border border-rule rounded-card bg-subtle">
|
||||
<h2 className="text-base font-serif text-ink mb-3">录入成绩</h2>
|
||||
<form onSubmit={handleRecordGrade} className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学生 ID <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="学生 UUID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
分数 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={score}
|
||||
onChange={(e) => setScore(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="0-100"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
反馈
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="可选:批改意见"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "录入中..." : "保存成绩"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{formError && (
|
||||
<span className="text-tiny text-danger">{formError}</span>
|
||||
)}
|
||||
{recordResult.data && !formError && !submitting && (
|
||||
<span className="text-tiny text-success">已保存</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{gradesResult.fetching ? (
|
||||
<Loading lines={4} />
|
||||
) : gradesResult.error ? (
|
||||
@@ -67,35 +380,123 @@ function GradesContent() {
|
||||
) : grades.length === 0 ? (
|
||||
<Empty title="暂无成绩" description="该考试尚未录入成绩" />
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{(grades as GradeItem[]).map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-6">
|
||||
<h3 className="text-lg font-serif text-ink">{g.studentName}</h3>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
学生 ID: {g.studentId}
|
||||
</p>
|
||||
{g.feedback && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
反馈: {g.feedback}
|
||||
</p>
|
||||
)}
|
||||
<>
|
||||
{/* 成绩分析 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">成绩分析</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<dl className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均分
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-accent">
|
||||
{stats.average.toFixed(1)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 text-2xl font-serif text-accent">
|
||||
{g.score}
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最高分
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-success">
|
||||
{stats.max}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny text-ink-muted">
|
||||
{g.examId ? `考试: ${g.examId.slice(0, 8)}...` : "作业成绩"}
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最低分
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-danger">
|
||||
{stats.min}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
|
||||
{g.id.slice(0, 8)}...
|
||||
<div className="p-3 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
及格率
|
||||
</dt>
|
||||
<dd className="mt-1 text-2xl font-serif text-ink">
|
||||
{stats.passRate.toFixed(1)}%
|
||||
</dd>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dl>
|
||||
|
||||
{/* 柱状图(纯 SVG,无外部依赖) */}
|
||||
<div className="p-4 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-sm font-serif text-ink mb-4">分数段分布</h3>
|
||||
<div className="flex items-end gap-4 h-40">
|
||||
{stats.bands.map((band) => {
|
||||
const heightPct = (band.count / maxBandCount) * 100;
|
||||
return (
|
||||
<div
|
||||
key={band.label}
|
||||
className="flex-1 flex flex-col items-center"
|
||||
>
|
||||
<div className="w-full flex flex-col items-center justify-end h-32">
|
||||
<span className="text-tiny text-ink-muted mb-1">
|
||||
{band.count}
|
||||
</span>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t-button"
|
||||
style={{ height: `${heightPct}%`, minHeight: band.count > 0 ? "4px" : "0" }}
|
||||
aria-label={`${band.label} 段 ${band.count} 人`}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-2 text-tiny font-mono text-ink-muted">
|
||||
{band.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 成绩列表 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-2">成绩列表</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<ul className="space-y-0">
|
||||
{grades.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
>
|
||||
<div className="col-span-6">
|
||||
<h3 className="text-lg font-serif text-ink">
|
||||
{g.studentName}
|
||||
</h3>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
学生 ID: {g.studentId}
|
||||
</p>
|
||||
{g.feedback && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
反馈: {g.feedback}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-2xl font-serif text-accent">
|
||||
{g.score}
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny text-ink-muted">
|
||||
{g.examId ? `考试: ${g.examId.slice(0, 8)}...` : "作业成绩"}
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
导出 CSV
|
||||
</button>
|
||||
<p className="mt-1 text-tiny font-mono text-ink-muted">
|
||||
{g.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
280
apps/teacher-portal/src/app/(app)/grades/report-card/page.tsx
Normal file
280
apps/teacher-portal/src/app/(app)/grades/report-card/page.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 学生成绩报告卡
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ReportCardQuery:学生成绩报告卡(学年/学期/科目成绩 + 排名)
|
||||
*
|
||||
* 打印:window.print()
|
||||
* A4 纸质风格:bg-paper/text-ink 字体
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ReportCardQuery } from "@/lib/graphql-p7-grades";
|
||||
import type { ReportCard } from "@/lib/graphql-p7-grades";
|
||||
import {
|
||||
REPORT_CARD_STUDENTS,
|
||||
REPORT_CARD_SEMESTERS,
|
||||
} from "@/mocks/fixtures/grades-extended";
|
||||
|
||||
export default function ReportCardPage(): React.ReactNode {
|
||||
const [studentId, setStudentId] = useState<string>(
|
||||
REPORT_CARD_STUDENTS[0]?.id ?? "",
|
||||
);
|
||||
const [academicYear, setAcademicYear] = useState<string>("");
|
||||
const [semester, setSemester] = useState<string>("");
|
||||
|
||||
// 学年选项(从 SEMESTERS 去重)
|
||||
const yearOptions = Array.from(
|
||||
new Set(REPORT_CARD_SEMESTERS.map((s) => s.year)),
|
||||
);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: ReportCardQuery,
|
||||
variables: {
|
||||
studentId,
|
||||
academicYear: academicYear || null,
|
||||
semester: semester || null,
|
||||
},
|
||||
pause: !studentId,
|
||||
});
|
||||
|
||||
const card: ReportCard | undefined = result.data?.reportCard;
|
||||
|
||||
/** 触发浏览器打印 */
|
||||
function handlePrint() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between print:hidden">
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/grades"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回成绩查询
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">学生成绩报告卡</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ReportCardQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
{card && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
打印
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8 print:hidden" />
|
||||
|
||||
{/* 筛选区 */}
|
||||
<section className="mb-8 print:hidden">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学生
|
||||
</label>
|
||||
<select
|
||||
value={studentId}
|
||||
onChange={(e) => setStudentId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{REPORT_CARD_STUDENTS.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}({s.no})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学年
|
||||
</label>
|
||||
<select
|
||||
value={academicYear}
|
||||
onChange={(e) => setAcademicYear(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">全部学年</option>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学期
|
||||
</label>
|
||||
<select
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">全部学期</option>
|
||||
<option value="1">第一学期</option>
|
||||
<option value="2">第二学期</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
) : !card ? (
|
||||
<Empty title="暂无报告卡" description="该学生未找到报告卡数据" />
|
||||
) : (
|
||||
// A4 纸质风格报告卡
|
||||
<article className="max-w-3xl mx-auto bg-paper border border-rule rounded-card p-10 shadow-sm">
|
||||
{/* 报告头 */}
|
||||
<header className="text-center mb-8 pb-6 border-b-2 border-accent">
|
||||
<h1 className="text-2xl font-serif text-ink">
|
||||
{card.className} · 成绩报告卡
|
||||
</h1>
|
||||
<p className="mt-2 text-tiny font-mono text-ink-muted">
|
||||
{card.academicYear} 学年第 {card.semester} 学期
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 学生信息 */}
|
||||
<section className="mb-8">
|
||||
<dl className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
</dt>
|
||||
<dd className="mt-1 font-serif text-ink text-lg">
|
||||
{card.studentName}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</dt>
|
||||
<dd className="mt-1 font-mono text-ink">{card.studentNo}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</dt>
|
||||
<dd className="mt-1 font-serif text-ink">{card.className}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 学科成绩表格 */}
|
||||
<section className="mb-8">
|
||||
<table className="w-full text-sm border border-rule">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
学科
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule w-24">
|
||||
成绩
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule w-24">
|
||||
班级排名
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
任课教师
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{card.subjects.map((s) => (
|
||||
<tr key={s.subject} className="border-b border-rule">
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{s.subject}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-lg text-accent">
|
||||
{s.score}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
第 {s.rank} 名
|
||||
</td>
|
||||
<td className="px-4 py-3 font-sans text-sm text-ink">
|
||||
{s.teacherName}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-subtle">
|
||||
<td className="px-4 py-3 font-serif text-ink">总分</td>
|
||||
<td className="px-4 py-3 font-serif text-xl text-accent">
|
||||
{card.totalScore}
|
||||
</td>
|
||||
<td className="px-4 py-3" colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* 排名信息 */}
|
||||
<section className="mb-8 grid grid-cols-2 gap-6">
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级排名
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-serif text-accent">
|
||||
{card.classRank}
|
||||
<span className="ml-1 text-sm font-sans text-ink-muted">
|
||||
/ {card.classSize}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
年级排名
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-serif text-ink">
|
||||
{card.gradeRank}
|
||||
<span className="ml-1 text-sm font-sans text-ink-muted">
|
||||
/ {card.gradeSize}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 班主任评语 */}
|
||||
<section className="mb-6">
|
||||
<h2 className="text-base font-serif text-ink mb-3">班主任评语</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<p className="text-sm leading-relaxed text-ink p-4 bg-subtle rounded-card font-sans">
|
||||
{card.teacherComment}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 签名区 */}
|
||||
<footer className="mt-10 pt-6 border-t border-rule flex justify-between text-sm text-ink-muted font-sans">
|
||||
<span>班主任签字:________________</span>
|
||||
<span>日期:{new Date().toLocaleDateString("zh-CN")}</span>
|
||||
</footer>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
apps/teacher-portal/src/app/(app)/grades/stats/page.tsx
Normal file
285
apps/teacher-portal/src/app/(app)/grades/stats/page.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 班级成绩统计页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL GradeStatsQuery:班级成绩统计(avg/median/max/min/passRate/stdDev/rankings)
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 导出 CSV:Blob + URL.createObjectURL
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import { GradeStatsQuery } from "@/lib/graphql-p7-grades";
|
||||
import type { GradeStats } from "@/lib/graphql-p7-grades";
|
||||
import { SUBJECTS } from "@/mocks/fixtures/grades-extended";
|
||||
|
||||
/** 等级颜色映射(语义令牌) */
|
||||
function levelColor(level: string): string {
|
||||
switch (level) {
|
||||
case "A":
|
||||
return "var(--color-success)";
|
||||
case "B":
|
||||
return "var(--color-accent)";
|
||||
case "C":
|
||||
return "var(--color-ink)";
|
||||
case "D":
|
||||
return "var(--color-warning)";
|
||||
case "E":
|
||||
return "var(--color-danger)";
|
||||
default:
|
||||
return "var(--color-ink-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
export default function GradeStatsPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [subject, setSubject] = useState<string>(SUBJECTS[1] ?? "数学");
|
||||
|
||||
// 班级列表
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
|
||||
// classId 为空时回退到第一个班级(用户未选择时)
|
||||
const targetClassId = classId || firstClassId;
|
||||
|
||||
const [statsResult] = useQuery({
|
||||
query: GradeStatsQuery,
|
||||
variables: { classId: targetClassId, subject },
|
||||
pause: !targetClassId,
|
||||
});
|
||||
|
||||
const stats: GradeStats | undefined = statsResult.data?.gradeStats;
|
||||
|
||||
/** 导出 CSV:排名表 */
|
||||
function exportCsv() {
|
||||
if (!stats) return;
|
||||
const header = "排名,学号,姓名,总分,等级\n";
|
||||
const rows = stats.rankings
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.rank},${r.studentNo},${r.studentName},${r.totalScore},${r.level}`,
|
||||
)
|
||||
.join("\n");
|
||||
// BOM 头确保 Excel 正确识别 UTF-8
|
||||
const csv = "\uFEFF" + header + rows;
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `成绩统计_${stats.className}_${stats.subject}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/grades"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回成绩查询
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">班级成绩统计</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL GradeStatsQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
{stats && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportCsv}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
导出 CSV
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级 + 学科筛选 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={targetClassId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学科
|
||||
</label>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{classesResult.fetching ? (
|
||||
<Loading lines={4} />
|
||||
) : statsResult.fetching ? (
|
||||
<Loading lines={6} />
|
||||
) : statsResult.error ? (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">
|
||||
加载失败:{statsResult.error.message}
|
||||
</p>
|
||||
</div>
|
||||
) : !stats ? (
|
||||
<Empty title="暂无统计数据" description="请选择班级和学科" />
|
||||
) : (
|
||||
<>
|
||||
{/* 5 个统计卡片 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
{stats.className} · {stats.subject}
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<dl className="grid grid-cols-5 gap-4">
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均分
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-accent">
|
||||
{stats.avg.toFixed(1)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
中位数
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-ink">
|
||||
{stats.median.toFixed(1)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最高分
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-success">
|
||||
{stats.max}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
最低分
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-danger">
|
||||
{stats.min}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
及格率
|
||||
</dt>
|
||||
<dd className="mt-2 text-3xl font-serif text-ink">
|
||||
{stats.passRate.toFixed(1)}%
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-4 text-tiny text-ink-muted">
|
||||
标准差 σ = {stats.stdDev.toFixed(1)} · 共 {stats.totalCount} 名学生
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 排名表格 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">学生排名</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-20">
|
||||
排名
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
姓名
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-32">
|
||||
总分
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
|
||||
等级
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.rankings.map((r) => (
|
||||
<tr key={r.studentId} className="border-t border-rule">
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{r.rank <= 3 ? (
|
||||
<span className="text-accent">{r.rank}</span>
|
||||
) : (
|
||||
r.rank
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{r.studentNo}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{r.studentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-lg text-ink">
|
||||
{r.totalScore}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-block px-2 py-0.5 rounded-button text-tiny font-mono"
|
||||
style={{
|
||||
color: levelColor(r.level),
|
||||
border: `1px solid ${levelColor(r.level)}`,
|
||||
}}
|
||||
>
|
||||
{r.level}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
319
apps/teacher-portal/src/app/(app)/homework/[id]/page.tsx
Normal file
319
apps/teacher-portal/src/app/(app)/homework/[id]/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业详情页 + 批改界面
|
||||
*
|
||||
* 数据来源(contract.md §2.4):
|
||||
* - GraphQL HomeworkDetailQuery(P3 扩展,MSW mock)获取作业详情 + 提交列表
|
||||
* - GraphQL RecordGradeMutation(P3 扩展,MSW mock)录入批改分数
|
||||
* - 动态路由 /homework/[id]
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { HomeworkDetailQuery, RecordGradeMutation } from "@/lib/graphql";
|
||||
import type { HomeworkSubmission } from "@/lib/graphql";
|
||||
|
||||
/** 提交状态中文映射 */
|
||||
const SUBMISSION_STATUS_LABEL: Record<string, string> = {
|
||||
NOT_SUBMITTED: "未提交",
|
||||
SUBMITTED: "已提交",
|
||||
GRADED: "已批改",
|
||||
};
|
||||
|
||||
interface GradingFormState {
|
||||
[submissionId: string]: {
|
||||
score: string;
|
||||
feedback: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function HomeworkDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
const [result, reexecuteQuery] = useQuery({
|
||||
query: HomeworkDetailQuery,
|
||||
variables: { id: homeworkId },
|
||||
pause: !homeworkId,
|
||||
});
|
||||
|
||||
const [recordGradeResult, recordGrade] = useMutation(RecordGradeMutation);
|
||||
|
||||
// 批改表单状态:按 submissionId 索引
|
||||
const [gradingForms, setGradingForms] = useState<GradingFormState>({});
|
||||
const [activeSubmissionId, setActiveSubmissionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||
const [errorId, setErrorId] = useState<string | null>(null);
|
||||
|
||||
const openGradingForm = (sub: HomeworkSubmission) => {
|
||||
setActiveSubmissionId(sub.id);
|
||||
setGradingForms((prev) => ({
|
||||
...prev,
|
||||
[sub.id]: {
|
||||
score: sub.score?.toString() ?? "",
|
||||
feedback: sub.feedback ?? "",
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const closeGradingForm = () => {
|
||||
setActiveSubmissionId(null);
|
||||
};
|
||||
|
||||
const handleRecordGrade = async (sub: HomeworkSubmission) => {
|
||||
const form = gradingForms[sub.id];
|
||||
if (!form) return;
|
||||
const score = Number(form.score);
|
||||
if (Number.isNaN(score)) {
|
||||
setErrorId(sub.id);
|
||||
return;
|
||||
}
|
||||
setSubmittingId(sub.id);
|
||||
setErrorId(null);
|
||||
// 乐观更新:本地表单先展示新值;失败回滚由错误信息提示
|
||||
const res = await recordGrade({
|
||||
input: {
|
||||
studentId: sub.studentId,
|
||||
homeworkId: homeworkId,
|
||||
score,
|
||||
feedback: form.feedback.trim() || null,
|
||||
},
|
||||
});
|
||||
setSubmittingId(null);
|
||||
if (res.error) {
|
||||
setErrorId(sub.id);
|
||||
return;
|
||||
}
|
||||
// 成功后重新拉取详情刷新提交列表
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
closeGradingForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/homework?classId=${result.data?.homeworkDetail?.classId ?? ""}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回作业列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">作业详情</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL HomeworkDetailQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{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>
|
||||
) : !result.data?.homeworkDetail ? (
|
||||
<Empty title="未找到作业" description="该作业不存在或已被删除" />
|
||||
) : (
|
||||
<section className="max-w-4xl">
|
||||
{/* 基本信息 */}
|
||||
<div className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">
|
||||
{result.data.homeworkDetail.title}
|
||||
</h2>
|
||||
{result.data.homeworkDetail.description && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
{result.data.homeworkDetail.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="rule-thin mb-4 mt-3" />
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
截止时间
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{new Date(result.data.homeworkDetail.dueDate).toLocaleString("zh-CN")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink">
|
||||
{SUBMISSION_STATUS_LABEL[result.data.homeworkDetail.status] ?? result.data.homeworkDetail.status}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 学生提交列表 */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-xl font-serif text-ink">
|
||||
学生提交
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
{result.data.homeworkDetail.submissions.length} 份
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{result.data.homeworkDetail.submissions.length === 0 ? (
|
||||
<Empty title="暂无提交" description="学生尚未提交作业" />
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{result.data.homeworkDetail.submissions.map(
|
||||
(sub: HomeworkSubmission) => {
|
||||
const isActive = activeSubmissionId === sub.id;
|
||||
const form = gradingForms[sub.id];
|
||||
const hasError = errorId === sub.id;
|
||||
return (
|
||||
<li
|
||||
key={sub.id}
|
||||
className="py-4 border-b border-rule"
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-4 items-baseline">
|
||||
<div className="col-span-5">
|
||||
<h4 className="text-base font-serif text-ink">
|
||||
{sub.studentName}
|
||||
</h4>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
学生 ID: {sub.studentId}
|
||||
</p>
|
||||
{sub.submittedAt && (
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
提交时间:
|
||||
{new Date(sub.submittedAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-ink">
|
||||
{sub.score !== null ? `${sub.score} 分` : "—"}
|
||||
</div>
|
||||
<div className="col-span-2 text-tiny text-ink-muted">
|
||||
{SUBMISSION_STATUS_LABEL[sub.status] ?? sub.status}
|
||||
</div>
|
||||
<div className="col-span-3 text-right">
|
||||
{sub.status !== "NOT_SUBMITTED" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isActive
|
||||
? closeGradingForm()
|
||||
: openGradingForm(sub)
|
||||
}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
{isActive ? "收起" : "批改"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已有反馈展示 */}
|
||||
{sub.feedback && !isActive && (
|
||||
<p className="mt-2 text-sm text-ink-muted pl-1">
|
||||
反馈:{sub.feedback}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 批改表单 */}
|
||||
{isActive && form && (
|
||||
<div className="mt-4 p-4 border border-rule rounded-card bg-subtle">
|
||||
<div className="grid grid-cols-3 gap-4 mb-3">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
分数 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.score}
|
||||
onChange={(e) =>
|
||||
setGradingForms((prev) => ({
|
||||
...prev,
|
||||
[sub.id]: {
|
||||
...form,
|
||||
score: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="0-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
反馈
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.feedback}
|
||||
onChange={(e) =>
|
||||
setGradingForms((prev) => ({
|
||||
...prev,
|
||||
[sub.id]: {
|
||||
...form,
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="可选:批改意见"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasError && (
|
||||
<p className="text-tiny text-danger mb-2">
|
||||
{recordGradeResult.error?.message ?? "录入失败"}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRecordGrade(sub)}
|
||||
disabled={submittingId === sub.id}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submittingId === sub.id
|
||||
? "录入中..."
|
||||
: "保存批改"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeGradingForm}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{recordGradeResult.data && !hasError && submittingId === null && (
|
||||
<span className="text-tiny text-success">已保存</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业批量批改视图
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL HomeworkAssignmentSubmissionsQuery:按作业 ID 拉取所有提交列表
|
||||
* - GraphQL AiBatchGradingQuery:批量自动评分建议
|
||||
*
|
||||
* 顶部统计:已批/已提交/未提交
|
||||
* 表格:学生 | 提交时间 | 分数 | 状态 | "批改" 链接
|
||||
* "批量自动评分" 按钮触发 AI 评分建议
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
HomeworkAssignmentSubmissionsQuery,
|
||||
AiBatchGradingQuery,
|
||||
GradeSubmissionMutation,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
import type {
|
||||
HomeworkSubmissionDetail,
|
||||
AiBatchGradingItem,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
SUBMITTED: "已提交",
|
||||
GRADED: "已批改",
|
||||
};
|
||||
|
||||
export default function AssignmentSubmissionsPage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const homeworkId = params?.id ?? "";
|
||||
|
||||
const [result, reexecuteQuery] = useQuery({
|
||||
query: HomeworkAssignmentSubmissionsQuery,
|
||||
variables: { homeworkId },
|
||||
pause: !homeworkId,
|
||||
});
|
||||
|
||||
// AI 批量评分建议(点击按钮后触发)
|
||||
const [aiResult, executeAi] = useQuery({
|
||||
query: AiBatchGradingQuery,
|
||||
variables: { homeworkId },
|
||||
pause: true, // 手动触发
|
||||
});
|
||||
|
||||
const [, gradeSubmission] = useMutation(GradeSubmissionMutation);
|
||||
const [batching, setBatching] = useState(false);
|
||||
const [batchMsg, setBatchMsg] = useState<string | null>(null);
|
||||
|
||||
const submissions: HomeworkSubmissionDetail[] =
|
||||
result.data?.homeworkAssignmentSubmissions ?? [];
|
||||
const aiSuggestions: AiBatchGradingItem[] =
|
||||
aiResult.data?.aiBatchGrading ?? [];
|
||||
|
||||
// 统计
|
||||
const totalCount = submissions.length;
|
||||
const gradedCount = submissions.filter((s) => s.status === "GRADED").length;
|
||||
const submittedCount = submissions.filter(
|
||||
(s) => s.status === "SUBMITTED",
|
||||
).length;
|
||||
|
||||
/** 批量自动评分:触发 AI 建议查询(urql 响应式更新,建议加载后显示"应用 AI"按钮) */
|
||||
const handleBatchAutoGrade = () => {
|
||||
if (submissions.length === 0) return;
|
||||
setBatching(true);
|
||||
setBatchMsg(null);
|
||||
executeAi();
|
||||
setBatchMsg("AI 评分建议已生成,请逐份复核后点击「应用 AI」");
|
||||
setBatching(false);
|
||||
};
|
||||
|
||||
/** 应用 AI 建议到单份提交 */
|
||||
const handleApplyAiSuggestion = async (
|
||||
sub: HomeworkSubmissionDetail,
|
||||
) => {
|
||||
const suggestion = aiSuggestions.find(
|
||||
(a) => a.submissionId === sub.submissionId,
|
||||
);
|
||||
if (!suggestion) {
|
||||
setBatchMsg("未找到该提交的 AI 建议");
|
||||
return;
|
||||
}
|
||||
const res = await gradeSubmission({
|
||||
input: {
|
||||
submissionId: sub.submissionId,
|
||||
score: suggestion.suggestedScore,
|
||||
feedback: suggestion.suggestion,
|
||||
aiAssisted: true,
|
||||
},
|
||||
});
|
||||
if (res.error) {
|
||||
setBatchMsg(`保存失败:${res.error.message}`);
|
||||
return;
|
||||
}
|
||||
setBatchMsg(`已应用 AI 建议到 ${sub.studentName}`);
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/homework/submissions"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回提交列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">批量批改</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL HomeworkAssignmentSubmissionsQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 顶部统计 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
已批改
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-serif text-success">
|
||||
{gradedCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
待批改
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-serif text-warning">
|
||||
{submittedCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总提交
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-serif text-ink">{totalCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchAutoGrade}
|
||||
disabled={batching || submissions.length === 0}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{batching ? "生成中..." : "批量自动评分"}
|
||||
</button>
|
||||
{batchMsg && (
|
||||
<span className="text-tiny text-ink-muted">{batchMsg}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : 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>
|
||||
) : submissions.length === 0 ? (
|
||||
<Empty title="暂无提交" description="该作业尚未有学生提交" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
提交时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
|
||||
分数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted w-24">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{submissions.map((sub) => {
|
||||
const aiSuggestion = aiSuggestions.find(
|
||||
(a) => a.submissionId === sub.submissionId,
|
||||
);
|
||||
return (
|
||||
<tr key={sub.submissionId} className="border-t border-rule">
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{sub.studentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{sub.studentNo}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{sub.submittedAt
|
||||
? new Date(sub.submittedAt).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif text-accent">
|
||||
{sub.totalScore !== null ? sub.totalScore : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="text-tiny"
|
||||
style={{
|
||||
color:
|
||||
sub.status === "GRADED"
|
||||
? "var(--color-success)"
|
||||
: "var(--color-warning)",
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[sub.status] ?? sub.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{aiSuggestion && sub.status === "SUBMITTED" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApplyAiSuggestion(sub)}
|
||||
className="text-tiny uppercase tracking-wide text-warning hover:opacity-70"
|
||||
>
|
||||
应用 AI
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/homework/submissions/${sub.submissionId}`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
批改
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
apps/teacher-portal/src/app/(app)/homework/new/page.tsx
Normal file
210
apps/teacher-portal/src/app/(app)/homework/new/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 布置作业表单页
|
||||
*
|
||||
* 数据来源(contract.md §2.4):GraphQL AssignHomeworkMutation(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 预填班级 ID
|
||||
* - 表单字段:班级选择、标题、描述、截止日期
|
||||
* - 乐观更新:成功后跳回列表;失败展示错误并保留输入
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useMutation, useQuery } from "urql";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
import { AssignHomeworkMutation, ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
|
||||
function NewHomeworkContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const presetClassId = searchParams.get("classId") ?? "";
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const [assignResult, assignHomework] = useMutation(AssignHomeworkMutation);
|
||||
|
||||
const [classId, setClassId] = useState(presetClassId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!classId) {
|
||||
setError("请选择班级");
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
setError("请填写作业标题");
|
||||
return;
|
||||
}
|
||||
if (!dueDate) {
|
||||
setError("请选择截止日期");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const res = await assignHomework({
|
||||
input: {
|
||||
classId,
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
dueDate: new Date(dueDate).toISOString(),
|
||||
},
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
router.push(`/homework?classId=${classId}`);
|
||||
};
|
||||
|
||||
if (classesResult.fetching) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={4} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classes = (classesResult.data?.classes ?? []) as Class[];
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/homework?classId=${classId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回作业列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">布置作业</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL AssignHomeworkMutation · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
<section className="max-w-2xl">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 班级选择 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="">请选择班级</option>
|
||||
{classes.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
标题 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
placeholder="例如:函数练习题"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="作业要求、范围、注意事项等"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 截止日期 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
截止日期 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "提交中..." : "布置作业"}
|
||||
</button>
|
||||
<Link
|
||||
href={`/homework?classId=${classId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
{assignResult.data && (
|
||||
<span className="text-tiny text-success">布置成功</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewHomeworkPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={3} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NewHomeworkContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@
|
||||
* 数据来源(contract.md §2.4):GraphQL ClassHomeworkQuery(P3 扩展,MSW mock)
|
||||
* - 通过 URL ?classId=xxx 指定班级
|
||||
* - P2 阶段用 MSW mock 数据,待 teacher-bff P3 启用 core-edu 聚合
|
||||
* - P3:列表项可点击进入详情页(批改界面),顶部提供"布置作业"入口
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassHomeworkQuery, ClassQuery } from "@/lib/graphql";
|
||||
@@ -46,13 +48,21 @@ function HomeworkContent() {
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-serif text-ink">
|
||||
{cls ? `${cls.name} - 作业管理` : "作业管理"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassHomeworkQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">
|
||||
{cls ? `${cls.name} - 作业管理` : "作业管理"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ClassHomeworkQuery · core-edu 域(P3 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/homework/new?classId=${classId}`}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
布置作业
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
@@ -66,16 +76,31 @@ function HomeworkContent() {
|
||||
</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Empty title="暂无作业" description="该班级尚未布置作业" />
|
||||
<Empty
|
||||
title="暂无作业"
|
||||
description="该班级尚未布置作业"
|
||||
action={
|
||||
<Link
|
||||
href={`/homework/new?classId=${classId}`}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
布置第一份作业
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{(items as HomeworkItem[]).map((hw) => (
|
||||
<li
|
||||
key={hw.id}
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule"
|
||||
className="py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<div className="col-span-7">
|
||||
<h3 className="text-lg font-serif text-ink">{hw.title}</h3>
|
||||
<Link href={`/homework/${hw.id}`}>
|
||||
<h3 className="text-lg font-serif text-ink hover:text-accent">
|
||||
{hw.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{hw.description && (
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
{hw.description}
|
||||
@@ -88,8 +113,13 @@ function HomeworkContent() {
|
||||
<div className="col-span-3 text-tiny text-ink-muted">
|
||||
状态: {hw.status}
|
||||
</div>
|
||||
<div className="col-span-2 text-right text-tiny font-mono text-ink-muted">
|
||||
{hw.id.slice(0, 8)}...
|
||||
<div className="col-span-2 text-right text-tiny">
|
||||
<Link
|
||||
href={`/homework/${hw.id}`}
|
||||
className="font-mono text-ink-muted hover:text-accent"
|
||||
>
|
||||
查看详情 →
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 单份提交批改页(核心)
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL HomeworkAssignmentSubmissionsQuery:按作业 ID 拉取所有提交(用于定位当前 + 上一份/下一份)
|
||||
* - GraphQL GradeSubmissionMutation:保存批改
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:学生信息 + 提交时间 + 状态
|
||||
* - 左侧:题目作答展示(题干 + 学生作答 + 正确答案)
|
||||
* - 右侧:评分表单(每题分数 + 反馈 + AI 评分建议按钮)
|
||||
* - 底部:上一份/下一份导航
|
||||
*
|
||||
* AI 辅助按钮:mock 流式返回评分建议(逐字显示,setInterval 模拟 SSE)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { HomeworkAssignmentSubmissionsQuery } from "@/lib/graphql-p7-grades";
|
||||
import { GradeSubmissionMutation } from "@/lib/graphql-p7-grades";
|
||||
import type { HomeworkSubmissionDetail, SubmissionAnswer } from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 题型中文映射 */
|
||||
const QUESTION_TYPE_LABEL: Record<string, string> = {
|
||||
SINGLE_CHOICE: "单选题",
|
||||
MULTIPLE_CHOICE: "多选题",
|
||||
SHORT_ANSWER: "简答题",
|
||||
ESSAY: "论述题",
|
||||
};
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
SUBMITTED: "已提交",
|
||||
GRADED: "已批改",
|
||||
};
|
||||
|
||||
/** 每题评分表单状态 */
|
||||
interface QuestionGrade {
|
||||
score: string;
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
/** 从 submissionId 解析 homeworkId(规约:${homeworkId}-sub-XXX) */
|
||||
function parseHomeworkId(submissionId: string): string {
|
||||
const match = submissionId.match(/^(.+)-sub-\d+$/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
export default function SubmissionGradingPage(): React.ReactNode {
|
||||
const params = useParams<{ submissionId: string }>();
|
||||
const submissionId = params?.submissionId ?? "";
|
||||
const homeworkId = parseHomeworkId(submissionId);
|
||||
|
||||
// 拉取该作业所有提交(用于定位当前 + 导航)
|
||||
const [result, reexecuteQuery] = useQuery({
|
||||
query: HomeworkAssignmentSubmissionsQuery,
|
||||
variables: { homeworkId },
|
||||
pause: !homeworkId,
|
||||
});
|
||||
|
||||
const [, gradeSubmission] = useMutation(GradeSubmissionMutation);
|
||||
|
||||
const allSubmissions: HomeworkSubmissionDetail[] =
|
||||
result.data?.homeworkAssignmentSubmissions ?? [];
|
||||
|
||||
// 定位当前提交 + 前后导航
|
||||
const currentIndex = allSubmissions.findIndex(
|
||||
(s) => s.submissionId === submissionId,
|
||||
);
|
||||
const submission: HomeworkSubmissionDetail | undefined =
|
||||
currentIndex >= 0 ? allSubmissions[currentIndex] : undefined;
|
||||
const prevSubmission =
|
||||
currentIndex > 0 ? allSubmissions[currentIndex - 1] : null;
|
||||
const nextSubmission =
|
||||
currentIndex >= 0 && currentIndex < allSubmissions.length - 1
|
||||
? allSubmissions[currentIndex + 1] ?? null
|
||||
: null;
|
||||
|
||||
// 每题评分状态
|
||||
const [questionGrades, setQuestionGrades] = useState<
|
||||
Record<string, QuestionGrade>
|
||||
>({});
|
||||
const [totalFeedback, setTotalFeedback] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// AI 流式建议状态:按 questionId 索引
|
||||
const [aiStreaming, setAiStreaming] = useState<Record<string, string>>({});
|
||||
const [aiLoading, setAiLoading] = useState<string | null>(null);
|
||||
|
||||
// 当 submission 变化时,初始化评分表单
|
||||
useEffect(() => {
|
||||
if (!submission) return;
|
||||
const next: Record<string, QuestionGrade> = {};
|
||||
submission.answers.forEach((a) => {
|
||||
next[a.questionId] = {
|
||||
score: a.score?.toString() ?? "",
|
||||
feedback: a.feedback ?? "",
|
||||
};
|
||||
});
|
||||
setQuestionGrades(next);
|
||||
setTotalFeedback(submission.answers[0]?.feedback ?? "");
|
||||
setSaveMsg(null);
|
||||
setSaveError(null);
|
||||
setAiStreaming({});
|
||||
}, [submission]);
|
||||
|
||||
// 计算当前总分(实时)
|
||||
const computedTotal = useMemo(() => {
|
||||
let total = 0;
|
||||
submission?.answers.forEach((a) => {
|
||||
const g = questionGrades[a.questionId];
|
||||
if (g && g.score !== "") {
|
||||
const n = Number(g.score);
|
||||
if (!Number.isNaN(n)) total += n;
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}, [submission, questionGrades]);
|
||||
|
||||
/** 保存批改 */
|
||||
const handleSave = async () => {
|
||||
if (!submission) return;
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
setSaveMsg(null);
|
||||
const questionScores = submission.answers.map((a) => {
|
||||
const g = questionGrades[a.questionId];
|
||||
const score = Number(g?.score ?? "0");
|
||||
return {
|
||||
questionId: a.questionId,
|
||||
score: Number.isNaN(score) ? 0 : score,
|
||||
feedback: g?.feedback || null,
|
||||
};
|
||||
});
|
||||
const res = await gradeSubmission({
|
||||
input: {
|
||||
submissionId: submission.submissionId,
|
||||
score: computedTotal,
|
||||
feedback: totalFeedback.trim() || null,
|
||||
aiAssisted: Object.keys(aiStreaming).length > 0,
|
||||
questionScores,
|
||||
},
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setSaveError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setSaveMsg("已保存批改");
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
/** AI 评分建议(mock SSE 流式:逐字显示) */
|
||||
const handleAiSuggest = (answer: SubmissionAnswer) => {
|
||||
if (!answer.aiSuggestion) return;
|
||||
setAiLoading(answer.questionId);
|
||||
const full = answer.aiSuggestion;
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
i += 2;
|
||||
setAiStreaming((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: full.slice(0, i),
|
||||
}));
|
||||
if (i >= full.length) {
|
||||
clearInterval(timer);
|
||||
setAiLoading(null);
|
||||
// 自动填充建议分数(maxScore 的 80%)
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: {
|
||||
score: Math.round(answer.maxScore * 0.8).toString(),
|
||||
feedback: full,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={
|
||||
homeworkId
|
||||
? `/homework/assignments/${homeworkId}/submissions`
|
||||
: "/homework/submissions"
|
||||
}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回批量批改
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">提交批改</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL HomeworkAssignmentSubmissionsQuery + GradeSubmissionMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : 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>
|
||||
) : !submission ? (
|
||||
<Empty title="未找到提交" description="该提交不存在或已被删除" />
|
||||
) : (
|
||||
<>
|
||||
{/* 顶部:学生信息 + 提交时间 + 状态 */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
{submission.homeworkTitle}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
学生:
|
||||
<span className="font-serif text-ink">
|
||||
{submission.studentName}
|
||||
</span>
|
||||
<span className="mx-2" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
学号:
|
||||
<span className="font-mono text-tiny">
|
||||
{submission.studentNo}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
提交时间:
|
||||
{submission.submittedAt
|
||||
? new Date(submission.submittedAt).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span
|
||||
className="text-tiny"
|
||||
style={{
|
||||
color:
|
||||
submission.status === "GRADED"
|
||||
? "var(--color-success)"
|
||||
: "var(--color-warning)",
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[submission.status] ?? submission.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 左右分栏:作答展示 + 评分表单 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* 左侧:题目作答展示 */}
|
||||
<section>
|
||||
<h3 className="text-lg font-serif text-ink mb-4">题目作答</h3>
|
||||
<div className="rule-thin mb-4" />
|
||||
<ul className="space-y-6">
|
||||
{submission.answers.map((answer, i) => {
|
||||
const aiText = aiStreaming[answer.questionId];
|
||||
const isAiLoading = aiLoading === answer.questionId;
|
||||
return (
|
||||
<li
|
||||
key={answer.questionId}
|
||||
className="border border-rule rounded-card p-4"
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-base font-serif text-ink">
|
||||
第 {i + 1} 题 ·{" "}
|
||||
{QUESTION_TYPE_LABEL[answer.questionType] ??
|
||||
answer.questionType}
|
||||
</h4>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
满分 {answer.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink mb-3">
|
||||
{answer.questionTitle}
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学生作答
|
||||
</span>
|
||||
<p className="mt-1 text-ink p-2 bg-subtle rounded-button">
|
||||
{answer.studentAnswer}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
正确答案
|
||||
</span>
|
||||
<p className="mt-1 text-success p-2 bg-surface rounded-button border border-rule">
|
||||
{answer.correctAnswer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* AI 流式建议展示 */}
|
||||
{aiText !== undefined && (
|
||||
<div className="mt-3 p-3 border border-accent rounded-card bg-surface">
|
||||
<p className="text-tiny uppercase tracking-wide text-accent mb-1">
|
||||
AI 评分建议
|
||||
{isAiLoading && (
|
||||
<span className="ml-2 animate-pulse">▋</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-ink">{aiText}</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 右侧:评分表单 */}
|
||||
<section>
|
||||
<h3 className="text-lg font-serif text-ink mb-4">评分表单</h3>
|
||||
<div className="rule-thin mb-4" />
|
||||
<ul className="space-y-6">
|
||||
{submission.answers.map((answer, i) => {
|
||||
const g = questionGrades[answer.questionId];
|
||||
const aiText = aiStreaming[answer.questionId];
|
||||
return (
|
||||
<li
|
||||
key={answer.questionId}
|
||||
className="border border-rule rounded-card p-4"
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-sm font-serif text-ink">
|
||||
第 {i + 1} 题评分
|
||||
</h4>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
/ {answer.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
分数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={answer.maxScore}
|
||||
value={g?.score ?? ""}
|
||||
onChange={(e) =>
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: {
|
||||
score: e.target.value,
|
||||
feedback: g?.feedback ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
反馈
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={g?.feedback ?? ""}
|
||||
onChange={(e) =>
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[answer.questionId]: {
|
||||
score: g?.score ?? "",
|
||||
feedback: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="可选:本题反馈"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{answer.aiSuggestion && aiText === undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAiSuggest(answer)}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
AI 评分建议
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* 总分 + 总反馈 */}
|
||||
<div className="mt-6 p-4 border border-rule rounded-card bg-subtle">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总分
|
||||
</span>
|
||||
<span className="text-2xl font-serif text-accent">
|
||||
{computedTotal}
|
||||
</span>
|
||||
</div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
总体反馈
|
||||
</label>
|
||||
<textarea
|
||||
value={totalFeedback}
|
||||
onChange={(e) => setTotalFeedback(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="可选:总体评语"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "保存中..." : "保存批改"}
|
||||
</button>
|
||||
{saveMsg && (
|
||||
<span className="text-tiny text-success">{saveMsg}</span>
|
||||
)}
|
||||
{saveError && (
|
||||
<span className="text-tiny text-danger">{saveError}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 底部:上一份/下一份导航 */}
|
||||
<section className="border-t border-rule pt-6 flex items-baseline justify-between">
|
||||
<div>
|
||||
{prevSubmission ? (
|
||||
<Link
|
||||
href={`/homework/submissions/${prevSubmission.submissionId}`}
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
← 上一份({prevSubmission.studentName})
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-ink-muted">已是第一份</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-tiny font-mono text-ink-muted">
|
||||
{currentIndex + 1} / {allSubmissions.length}
|
||||
</span>
|
||||
<div>
|
||||
{nextSubmission ? (
|
||||
<Link
|
||||
href={`/homework/submissions/${nextSubmission.submissionId}`}
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
下一份({nextSubmission.studentName}) →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-ink-muted">已是最后一份</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 扫描批改视图页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL SubmissionScanGradingQuery:按 submissionId 拉取扫描批改视图
|
||||
* - GraphQL SaveScanGradingMutation:保存扫描批改
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:学生信息 + 提交时间
|
||||
* - 左侧:扫描图片预览(可缩放 zoom in/out)
|
||||
* - 右侧:识别作答 + 评分表单(每题:题号 + 题干 + 已识别作答 + AI 评分建议 + 分数 input + 反馈 textarea)
|
||||
* - 底部:"保存批改" 调用 SaveScanGradingMutation + "上一份"/"下一份" 导航
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
SubmissionScanGradingQuery,
|
||||
SaveScanGradingMutation,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
import type {
|
||||
SubmissionScanGrading,
|
||||
ScanGradingItem,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 每题评分表单状态 */
|
||||
interface QuestionGrade {
|
||||
score: string;
|
||||
feedback: string;
|
||||
recognizedAnswer: string;
|
||||
}
|
||||
|
||||
export default function ScanGradingPage(): React.ReactNode {
|
||||
const params = useParams<{ submissionId: string }>();
|
||||
const submissionId = params?.submissionId ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: SubmissionScanGradingQuery,
|
||||
variables: { submissionId },
|
||||
pause: !submissionId,
|
||||
});
|
||||
|
||||
const [, saveGrading] = useMutation(SaveScanGradingMutation);
|
||||
|
||||
const scan: SubmissionScanGrading | null =
|
||||
result.data?.submissionScanGrading ?? null;
|
||||
|
||||
// 评分表单状态
|
||||
const [questionGrades, setQuestionGrades] = useState<
|
||||
Record<string, QuestionGrade>
|
||||
>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// 图片缩放状态
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [currentImageIdx, setCurrentImageIdx] = useState(0);
|
||||
|
||||
// 当 scan 变化时,初始化评分表单
|
||||
useEffect(() => {
|
||||
if (!scan) return;
|
||||
const next: Record<string, QuestionGrade> = {};
|
||||
scan.items.forEach((item) => {
|
||||
next[item.questionId] = {
|
||||
score: item.score?.toString() ?? "",
|
||||
feedback: item.feedback ?? "",
|
||||
recognizedAnswer: item.recognizedAnswer,
|
||||
};
|
||||
});
|
||||
setQuestionGrades(next);
|
||||
setSaveMsg(null);
|
||||
setSaveError(null);
|
||||
setCurrentImageIdx(0);
|
||||
}, [scan]);
|
||||
|
||||
// 计算当前总分(实时)
|
||||
const computedTotal = useMemo(() => {
|
||||
let total = 0;
|
||||
scan?.items.forEach((item) => {
|
||||
const g = questionGrades[item.questionId];
|
||||
if (g && g.score !== "") {
|
||||
const n = Number(g.score);
|
||||
if (!Number.isNaN(n)) total += n;
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}, [scan, questionGrades]);
|
||||
|
||||
/** 保存批改 */
|
||||
const handleSave = async () => {
|
||||
if (!scan) return;
|
||||
setSubmitting(true);
|
||||
setSaveError(null);
|
||||
setSaveMsg(null);
|
||||
const items = scan.items.map((item) => {
|
||||
const g = questionGrades[item.questionId];
|
||||
const score = Number(g?.score ?? "0");
|
||||
return {
|
||||
questionId: item.questionId,
|
||||
score: Number.isNaN(score) ? 0 : score,
|
||||
feedback: g?.feedback || null,
|
||||
};
|
||||
});
|
||||
const res = await saveGrading({
|
||||
input: { submissionId: scan.submissionId, items },
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setSaveError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setSaveMsg("已保存批改");
|
||||
};
|
||||
|
||||
const images = scan?.images ?? [];
|
||||
const currentImage = images[currentImageIdx];
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href={`/homework/submissions/${submissionId}`}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回批改详情
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">扫描批改</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL SubmissionScanGradingQuery + SaveScanGradingMutation · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : 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>
|
||||
) : !scan ? (
|
||||
<Empty title="未找到扫描批改数据" description="该提交无扫描批改记录" />
|
||||
) : (
|
||||
<>
|
||||
{/* 顶部:学生信息 + 提交时间 */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
{scan.homeworkTitle}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
学生:
|
||||
<span className="font-serif text-ink">
|
||||
{scan.studentName}
|
||||
</span>
|
||||
<span className="mx-2" aria-hidden="true">·</span>
|
||||
学号:
|
||||
<span className="font-mono text-tiny">
|
||||
{scan.studentNo}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
提交时间:
|
||||
{new Date(scan.submittedAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
当前总分
|
||||
</span>
|
||||
<p className="text-2xl font-serif text-accent">
|
||||
{computedTotal}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 左右分栏:扫描图片 + 评分表单 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* 左侧:扫描图片预览 */}
|
||||
<section>
|
||||
<h3 className="text-lg font-serif text-ink mb-4">
|
||||
扫描图片
|
||||
{images.length > 1 && (
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
第 {currentImageIdx + 1} / {images.length} 页
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
{images.length === 0 ? (
|
||||
<Empty title="无扫描图片" description="该提交无扫描图片" />
|
||||
) : (
|
||||
<div>
|
||||
{/* 图片预览区 */}
|
||||
<div className="border border-rule rounded-card bg-subtle p-4 flex items-center justify-center overflow-hidden" style={{ minHeight: "400px" }}>
|
||||
{currentImage && (
|
||||
<img
|
||||
src={currentImage.url}
|
||||
alt={`扫描第 ${currentImage.page} 页`}
|
||||
style={{ transform: `scale(${zoom})`, maxHeight: "380px", transition: "transform 0.2s" }}
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 缩放控制 + 翻页 */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => Math.max(0.5, z - 0.1))}
|
||||
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
缩小
|
||||
</button>
|
||||
<span className="text-tiny font-mono text-ink-muted">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((z) => Math.min(3, z + 0.1))}
|
||||
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
放大
|
||||
</button>
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIdx((i) => Math.max(0, i - 1))}
|
||||
disabled={currentImageIdx === 0}
|
||||
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentImageIdx((i) => Math.min(images.length - 1, i + 1))}
|
||||
disabled={currentImageIdx >= images.length - 1}
|
||||
className="px-2 py-1 text-tiny text-ink bg-transparent border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右侧:识别作答 + 评分表单 */}
|
||||
<section>
|
||||
<h3 className="text-lg font-serif text-ink mb-4">识别作答 + 评分</h3>
|
||||
<div className="rule-thin mb-4" />
|
||||
|
||||
<ul className="space-y-4 max-h-[600px] overflow-y-auto pr-2">
|
||||
{scan.items.map((item: ScanGradingItem, i) => {
|
||||
const g = questionGrades[item.questionId];
|
||||
return (
|
||||
<li
|
||||
key={item.questionId}
|
||||
className="border border-rule rounded-card p-4"
|
||||
>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<h4 className="text-sm font-serif text-ink">
|
||||
第 {i + 1} 题
|
||||
</h4>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
/ {item.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink mb-3">
|
||||
{item.questionTitle}
|
||||
</p>
|
||||
|
||||
{/* 已识别作答(可编辑) */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
已识别作答
|
||||
</label>
|
||||
<textarea
|
||||
value={g?.recognizedAnswer ?? ""}
|
||||
onChange={(e) =>
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[item.questionId]: {
|
||||
score: g?.score ?? "",
|
||||
feedback: g?.feedback ?? "",
|
||||
recognizedAnswer: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 bg-subtle border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI 评分建议 */}
|
||||
{item.aiSuggestion && (
|
||||
<div className="mb-3 p-2 border border-accent rounded-card bg-surface">
|
||||
<p className="text-tiny uppercase tracking-wide text-accent mb-1">
|
||||
AI 评分建议
|
||||
<span className="ml-2 text-ink-muted">
|
||||
建议 {item.aiSuggestedScore} 分
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-tiny text-ink">
|
||||
{item.aiSuggestion}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[item.questionId]: {
|
||||
score: String(item.aiSuggestedScore ?? ""),
|
||||
feedback: g?.feedback ?? item.aiSuggestion ?? "",
|
||||
recognizedAnswer: g?.recognizedAnswer ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="mt-1 text-tiny text-accent hover:opacity-70"
|
||||
>
|
||||
采用建议
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分数 + 反馈 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
分数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={item.maxScore}
|
||||
value={g?.score ?? ""}
|
||||
onChange={(e) =>
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[item.questionId]: {
|
||||
score: e.target.value,
|
||||
feedback: g?.feedback ?? "",
|
||||
recognizedAnswer: g?.recognizedAnswer ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
反馈
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={g?.feedback ?? ""}
|
||||
onChange={(e) =>
|
||||
setQuestionGrades((prev) => ({
|
||||
...prev,
|
||||
[item.questionId]: {
|
||||
score: g?.score ?? "",
|
||||
feedback: e.target.value,
|
||||
recognizedAnswer: g?.recognizedAnswer ?? "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-2 py-1 bg-paper border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="可选:本题反馈"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "保存中..." : "保存批改"}
|
||||
</button>
|
||||
{saveMsg && (
|
||||
<span className="text-tiny text-success">{saveMsg}</span>
|
||||
)}
|
||||
{saveError && (
|
||||
<span className="text-tiny text-danger">{saveError}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 底部:上一份/下一份导航 */}
|
||||
<section className="border-t border-rule pt-6 flex items-baseline justify-between">
|
||||
<div>
|
||||
{scan.prevSubmissionId ? (
|
||||
<Link
|
||||
href={`/homework/submissions/${scan.prevSubmissionId}/scan-grading`}
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
← 上一份
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-ink-muted">已是第一份</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{scan.nextSubmissionId ? (
|
||||
<Link
|
||||
href={`/homework/submissions/${scan.nextSubmissionId}/scan-grading`}
|
||||
className="text-sm text-accent hover:opacity-70"
|
||||
>
|
||||
下一份 →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-ink-muted">已是最后一份</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
apps/teacher-portal/src/app/(app)/homework/submissions/page.tsx
Normal file
203
apps/teacher-portal/src/app/(app)/homework/submissions/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 作业提交评审列表
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL HomeworkSubmissionsQuery:按作业维度统计提交/已批数
|
||||
* - GraphQL ClassesQuery:班级下拉选项
|
||||
*
|
||||
* 列出所有作业的提交统计,"去批改" 链接跳转批量批改视图
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import { HomeworkSubmissionsQuery } from "@/lib/graphql-p7-grades";
|
||||
import type { HomeworkSubmissionStat } from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 状态中文映射 */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
graded: "已批改",
|
||||
};
|
||||
|
||||
export default function HomeworkSubmissionsPage(): React.ReactNode {
|
||||
const [classId, setClassId] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
// 班级列表
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
|
||||
// 提交统计
|
||||
const [result] = useQuery({
|
||||
query: HomeworkSubmissionsQuery,
|
||||
variables: {
|
||||
classId: classId || null,
|
||||
status: status || null,
|
||||
},
|
||||
});
|
||||
|
||||
const stats: HomeworkSubmissionStat[] =
|
||||
result.data?.homeworkSubmissions ?? [];
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/homework"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回作业管理
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">作业提交评审</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL HomeworkSubmissionsQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选区 */}
|
||||
<section className="mb-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">全部班级</option>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="graded">已批改</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
) : stats.length === 0 ? (
|
||||
<Empty title="暂无提交" description="没有符合条件的作业提交记录" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
作业
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
已提交/总数
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
已批/已提交
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均分
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-tiny uppercase tracking-wide text-ink-muted">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.map((s) => {
|
||||
const submitRate =
|
||||
s.totalCount > 0
|
||||
? Math.round((s.submittedCount / s.totalCount) * 100)
|
||||
: 0;
|
||||
const gradeRate =
|
||||
s.submittedCount > 0
|
||||
? Math.round((s.gradedCount / s.submittedCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={s.homeworkId} className="border-t border-rule">
|
||||
<td className="px-4 py-3 font-serif text-ink">
|
||||
{s.title}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-ink-muted">
|
||||
{s.className}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-tiny text-ink-muted">
|
||||
{STATUS_LABEL[s.status] ?? s.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-tiny text-ink">
|
||||
{s.submittedCount}/{s.totalCount}
|
||||
<span className="ml-1 text-ink-muted">
|
||||
({submitRate}%)
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-tiny text-ink">
|
||||
{s.gradedCount}/{s.submittedCount}
|
||||
<span className="ml-1 text-ink-muted">
|
||||
({gradeRate}%)
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-serif text-accent">
|
||||
{s.avgScore !== null ? s.avgScore : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link
|
||||
href={`/homework/assignments/${s.homeworkId}/submissions`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
去批改
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
607
apps/teacher-portal/src/app/(app)/leave/page.tsx
Normal file
607
apps/teacher-portal/src/app/(app)/leave/page.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 请假审批页 - 教师请假申请管理
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL LeaveRequestsQuery:按 dataScope/status 筛选 + 分页
|
||||
* - GraphQL ApproveLeaveRequestMutation:批准/拒绝请假
|
||||
* - GraphQL SubmitLeaveRequestMutation:教师提交请假申请
|
||||
*
|
||||
* 功能:
|
||||
* - dataScope 筛选(我的/本班/本年级/全校)
|
||||
* - 状态筛选(全部/待审批/已批准/已拒绝)
|
||||
* - 审批对话框(批准/拒绝 + 评论)
|
||||
* - 提交请假申请模态框(类型/起止日期/原因)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
LeaveRequestsQuery,
|
||||
ApproveLeaveRequestMutation,
|
||||
SubmitLeaveRequestMutation,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
LeaveRequest,
|
||||
LeaveRequestsFilter,
|
||||
LeaveType,
|
||||
LeaveStatus,
|
||||
ApproveLeaveRequestInput,
|
||||
SubmitLeaveRequestInput,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type { DataScope } from "@/lib/graphql";
|
||||
import {
|
||||
DATA_SCOPES,
|
||||
LEAVE_TYPE_LABEL,
|
||||
LEAVE_STATUS_LABEL,
|
||||
} from "@/mocks/fixtures/leave-requests";
|
||||
|
||||
const LEAVE_TYPES: { value: LeaveType; label: string }[] = [
|
||||
{ value: "SICK", label: "病假" },
|
||||
{ value: "PERSONAL", label: "事假" },
|
||||
{ value: "ANNUAL", label: "年假" },
|
||||
{ value: "OTHER", label: "其他" },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: { value: LeaveStatus | "ALL"; label: string }[] = [
|
||||
{ value: "ALL", label: "全部" },
|
||||
{ value: "PENDING", label: "待审批" },
|
||||
{ value: "APPROVED", label: "已批准" },
|
||||
{ value: "REJECTED", label: "已拒绝" },
|
||||
];
|
||||
|
||||
function statusBadgeClass(status: LeaveStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-warning border-warning";
|
||||
case "APPROVED":
|
||||
return "text-success border-success";
|
||||
case "REJECTED":
|
||||
return "text-danger border-danger";
|
||||
default:
|
||||
return "text-ink-muted border-rule";
|
||||
}
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function LeavePage(): React.ReactNode {
|
||||
const [dataScope, setDataScope] = useState<DataScope>("SELF");
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveStatus | "ALL">("ALL");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 审批对话框状态
|
||||
const [reviewTarget, setReviewTarget] = useState<LeaveRequest | null>(null);
|
||||
const [reviewAction, setReviewAction] = useState<"APPROVE" | "REJECT">(
|
||||
"APPROVE",
|
||||
);
|
||||
const [reviewComment, setReviewComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||
|
||||
// 提交请假对话框状态
|
||||
const [showSubmit, setShowSubmit] = useState(false);
|
||||
const [formType, setFormType] = useState<LeaveType>("SICK");
|
||||
const [formStart, setFormStart] = useState(todayStr());
|
||||
const [formEnd, setFormEnd] = useState(todayStr());
|
||||
const [formReason, setFormReason] = useState("");
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
||||
|
||||
const filter: LeaveRequestsFilter = useMemo(
|
||||
() => ({
|
||||
dataScope,
|
||||
status: statusFilter === "ALL" ? null : statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
[dataScope, statusFilter, page],
|
||||
);
|
||||
|
||||
const [result, reexecute] = useQuery({
|
||||
query: LeaveRequestsQuery,
|
||||
variables: { filter },
|
||||
});
|
||||
|
||||
const [, approveLeave] = useMutation(ApproveLeaveRequestMutation);
|
||||
const [, submitLeave] = useMutation(SubmitLeaveRequestMutation);
|
||||
|
||||
const data = result.data?.leaveRequests;
|
||||
const items: LeaveRequest[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const openReview = (
|
||||
request: LeaveRequest,
|
||||
action: "APPROVE" | "REJECT",
|
||||
): void => {
|
||||
setReviewTarget(request);
|
||||
setReviewAction(action);
|
||||
setReviewComment("");
|
||||
setReviewError(null);
|
||||
};
|
||||
|
||||
const closeReview = (): void => {
|
||||
setReviewTarget(null);
|
||||
setReviewComment("");
|
||||
setReviewError(null);
|
||||
};
|
||||
|
||||
const handleApprove = async (): Promise<void> => {
|
||||
if (!reviewTarget) return;
|
||||
setSubmitting(true);
|
||||
setReviewError(null);
|
||||
const input: ApproveLeaveRequestInput = {
|
||||
requestId: reviewTarget.id,
|
||||
action: reviewAction,
|
||||
comment: reviewComment.trim() || null,
|
||||
};
|
||||
const res = await approveLeave({ input });
|
||||
setSubmitting(false);
|
||||
if (res.error) {
|
||||
setReviewError(res.error.message);
|
||||
return;
|
||||
}
|
||||
closeReview();
|
||||
reexecute({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
const handleSubmitLeave = async (): Promise<void> => {
|
||||
if (!formStart || !formEnd || !formReason.trim()) {
|
||||
setFormError("请填写起止日期和原因");
|
||||
return;
|
||||
}
|
||||
if (formEnd < formStart) {
|
||||
setFormError("结束日期不能早于开始日期");
|
||||
return;
|
||||
}
|
||||
setFormSubmitting(true);
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
const input: SubmitLeaveRequestInput = {
|
||||
type: formType,
|
||||
startDate: formStart,
|
||||
endDate: formEnd,
|
||||
reason: formReason.trim(),
|
||||
};
|
||||
const res = await submitLeave({ input });
|
||||
setFormSubmitting(false);
|
||||
if (res.error) {
|
||||
setFormError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setFormSuccess(
|
||||
`已提交:${res.data?.submitLeaveRequest?.requestId ?? "—"},等待审批`,
|
||||
);
|
||||
// 重置表单
|
||||
setFormType("SICK");
|
||||
setFormStart(todayStr());
|
||||
setFormEnd(todayStr());
|
||||
setFormReason("");
|
||||
reexecute({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
const closeSubmit = (): void => {
|
||||
setShowSubmit(false);
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
setFormReason("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">请假管理</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL LeaveRequestsQuery + ApproveLeaveRequestMutation +
|
||||
SubmitLeaveRequestMutation(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSubmit(true);
|
||||
setFormSuccess(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
提交请假申请
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
范围:
|
||||
</label>
|
||||
{DATA_SCOPES.map((s) => {
|
||||
const active = dataScope === s.value;
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDataScope(s.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
active
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态:
|
||||
</label>
|
||||
{STATUS_FILTERS.map((s) => {
|
||||
const active = statusFilter === s.value;
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter(s.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
active
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : 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>
|
||||
) : items.length === 0 ? (
|
||||
<Empty title="暂无请假申请" description="当前筛选条件下没有记录" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
申请列表
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
共 {total} 条
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
申请人
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
起止日期
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
原因
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-t border-rule">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-serif text-ink">{r.applicantName}</p>
|
||||
<p className="text-tiny font-mono text-ink-muted">
|
||||
{r.applicantRole}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-ink">
|
||||
{LEAVE_TYPE_LABEL[r.type]}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
{r.startDate} ~ {r.endDate}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-tiny text-ink-muted max-w-xs">
|
||||
<p className="truncate">{r.reason}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-button text-tiny font-mono border ${statusBadgeClass(
|
||||
r.status,
|
||||
)}`}
|
||||
>
|
||||
{LEAVE_STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{r.status === "PENDING" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openReview(r, "APPROVE")}
|
||||
className="text-tiny uppercase tracking-wide text-success hover:opacity-70"
|
||||
>
|
||||
批准
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openReview(r, "REJECT")}
|
||||
className="text-tiny uppercase tracking-wide text-danger hover:opacity-70"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-tiny text-ink-muted">
|
||||
{r.reviewedAt
|
||||
? `${r.reviewerName ?? "—"} · ${r.reviewedAt.slice(
|
||||
0,
|
||||
10,
|
||||
)}`
|
||||
: "—"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
第 {page} 页(每页 {pageSize} 条)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={items.length < pageSize}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 审批对话框 */}
|
||||
{reviewTarget && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "hsl(var(--color-ink) / 0.3)" }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-lg p-8 bg-paper border border-rule rounded-card">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-xl font-serif text-ink">
|
||||
{reviewAction === "APPROVE" ? "批准请假" : "拒绝请假"}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeReview}
|
||||
className="text-tiny text-ink-muted hover:opacity-70"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-ink-muted">
|
||||
申请人:<span className="text-ink">
|
||||
{reviewTarget.applicantName}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
类型:
|
||||
<span className="text-ink">
|
||||
{LEAVE_TYPE_LABEL[reviewTarget.type]}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
日期:<span className="font-mono text-ink">
|
||||
{reviewTarget.startDate} ~ {reviewTarget.endDate}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
原因:<span className="text-ink">{reviewTarget.reason}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
审批意见
|
||||
</label>
|
||||
<textarea
|
||||
value={reviewComment}
|
||||
onChange={(e) => setReviewComment(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="可选:填写审批意见"
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{reviewError && (
|
||||
<p className="mb-3 text-tiny text-danger">{reviewError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeReview}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApprove}
|
||||
disabled={submitting}
|
||||
className={`px-4 py-2 text-sm text-ink-on-accent rounded-button disabled:opacity-50 hover:opacity-80 ${
|
||||
reviewAction === "APPROVE" ? "bg-success" : "bg-danger"
|
||||
}`}
|
||||
>
|
||||
{submitting
|
||||
? "处理中..."
|
||||
: reviewAction === "APPROVE"
|
||||
? "确认批准"
|
||||
: "确认拒绝"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交请假对话框 */}
|
||||
{showSubmit && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "hsl(var(--color-ink) / 0.3)" }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-lg p-8 bg-paper border border-rule rounded-card">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-xl font-serif text-ink">提交请假申请</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSubmit}
|
||||
className="text-tiny text-ink-muted hover:opacity-70"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
{formSuccess ? (
|
||||
<div className="mb-4 p-4 border border-rule rounded-card bg-subtle">
|
||||
<p className="text-sm text-success">{formSuccess}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormSuccess(null)}
|
||||
className="mt-2 text-tiny text-accent hover:opacity-70"
|
||||
>
|
||||
再提交一条
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-4">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
类型
|
||||
</label>
|
||||
<select
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value as LeaveType)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{LEAVE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
开始日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formStart}
|
||||
onChange={(e) => setFormStart(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
结束日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formEnd}
|
||||
onChange={(e) => setFormEnd(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
原因
|
||||
</label>
|
||||
<textarea
|
||||
value={formReason}
|
||||
onChange={(e) => setFormReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="请填写请假原因"
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{formError && (
|
||||
<p className="mb-3 text-tiny text-danger">{formError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSubmit}
|
||||
disabled={formSubmitting}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitLeave}
|
||||
disabled={formSubmitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{formSubmitting ? "提交中..." : "提交申请"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课案编辑器(核心,P7-insights)
|
||||
*
|
||||
* 数据来源:
|
||||
* - GraphQL LessonPlanDetailQuery
|
||||
* - GraphQL UpdateLessonPlanMutation
|
||||
* - GraphQL AIGenerateLessonPlanMutation
|
||||
*
|
||||
* 三栏布局:
|
||||
* - 左侧:教材/章节选择 + 班级关联
|
||||
* - 中间:富文本编辑器(contenteditable,工具栏:加粗/斜体/标题/列表)
|
||||
* - 右侧:AI 助手面板(生成 + 历史记录)
|
||||
*
|
||||
* 顶部:保存/发布/导出 PDF(window.print())
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
LessonPlanDetailQuery,
|
||||
UpdateLessonPlanMutation,
|
||||
AIGenerateLessonPlanMutation,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
LessonPlanDetail,
|
||||
AIGenerateLessonPlanInput,
|
||||
LessonPlanAIHistoryItem,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "DRAFT", label: "草稿" },
|
||||
{ value: "PUBLISHED", label: "已发布" },
|
||||
{ value: "ARCHIVED", label: "已归档" },
|
||||
] as const;
|
||||
|
||||
const TEXTBOOKS = [
|
||||
{ id: "", title: "不关联教材" },
|
||||
{ id: "tb-001", title: "小学语文三年级上册" },
|
||||
{ id: "tb-002", title: "小学数学六年级上册" },
|
||||
{ id: "tb-003", title: "初中英语七年级上册" },
|
||||
];
|
||||
|
||||
export default function LessonPlanEditPage(): JSX.Element {
|
||||
const params = useParams<{ planId: string }>();
|
||||
const planId = params?.planId ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: LessonPlanDetailQuery,
|
||||
variables: { planId },
|
||||
pause: !planId,
|
||||
});
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
|
||||
const plan: LessonPlanDetail | undefined = result.data?.lessonPlanDetail;
|
||||
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("DRAFT");
|
||||
const [textbookId, setTextbookId] = useState<string>("");
|
||||
const [chapter, setChapter] = useState<string>("");
|
||||
const [classIds, setClassIds] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [aiPrompt, setAiPrompt] = useState<string>("");
|
||||
const [aiHistory, setAiHistory] = useState<LessonPlanAIHistoryItem[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
|
||||
const [, updateLessonPlan] = useMutation(UpdateLessonPlanMutation);
|
||||
const [, aiGenerate] = useMutation(AIGenerateLessonPlanMutation);
|
||||
|
||||
// 同步初始数据到本地 state + 编辑器内容
|
||||
useEffect(() => {
|
||||
if (plan) {
|
||||
setTitle(plan.title);
|
||||
setStatus(plan.status);
|
||||
setTextbookId(plan.textbookId ?? "");
|
||||
setChapter(plan.chapter);
|
||||
setClassIds(plan.classIds);
|
||||
setAiHistory(plan.aiHistory);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerText = plan.content;
|
||||
}
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
const getEditorContent = (): string => {
|
||||
if (!editorRef.current) return "";
|
||||
// 简化:使用 innerText 作为内容(contenteditable 内的文本)
|
||||
return editorRef.current.innerText;
|
||||
};
|
||||
|
||||
const setEditorContent = (content: string): void => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerText = content;
|
||||
}
|
||||
};
|
||||
|
||||
const appendEditorContent = (content: string): void => {
|
||||
if (!editorRef.current) return;
|
||||
const current = editorRef.current.innerText;
|
||||
editorRef.current.innerText = `${current}\n\n${content}`;
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string): void => {
|
||||
if (typeof document !== "undefined" && editorRef.current) {
|
||||
editorRef.current.focus();
|
||||
document.execCommand(command, false, value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (newStatus?: string): Promise<void> => {
|
||||
if (!plan) return;
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
const res = await updateLessonPlan({
|
||||
input: {
|
||||
planId: plan.id,
|
||||
title,
|
||||
content: getEditorContent(),
|
||||
status: (newStatus ?? status) as "DRAFT" | "PUBLISHED" | "ARCHIVED",
|
||||
textbookId: textbookId || null,
|
||||
chapter,
|
||||
classIds,
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.error || !res.data?.updateLessonPlan) {
|
||||
setSaveMsg(`保存失败:${res.error?.message ?? "未知错误"}`);
|
||||
return;
|
||||
}
|
||||
if (newStatus) setStatus(newStatus);
|
||||
setSaveMsg("已保存 ✓");
|
||||
setTimeout(() => setSaveMsg(null), 2000);
|
||||
};
|
||||
|
||||
const handleExportPdf = (): void => {
|
||||
if (typeof window !== "undefined") window.print();
|
||||
};
|
||||
|
||||
const handleAIGenerate = async (): Promise<void> => {
|
||||
if (!plan || generating || !aiPrompt) return;
|
||||
setGenerating(true);
|
||||
setAiError(null);
|
||||
const input: AIGenerateLessonPlanInput = {
|
||||
subject: plan.subject,
|
||||
grade: plan.grade,
|
||||
chapter: plan.chapter,
|
||||
prompt: aiPrompt,
|
||||
};
|
||||
const res = await aiGenerate({ input });
|
||||
setGenerating(false);
|
||||
if (res.error || !res.data?.aiGenerateLessonPlan) {
|
||||
setAiError(res.error?.message ?? "生成失败");
|
||||
return;
|
||||
}
|
||||
const generated = res.data.aiGenerateLessonPlan as {
|
||||
id: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
model: string;
|
||||
generatedAt: string;
|
||||
};
|
||||
appendEditorContent(generated.content);
|
||||
setAiHistory((prev) => [
|
||||
{
|
||||
id: generated.id,
|
||||
prompt: aiPrompt,
|
||||
generatedAt: generated.generatedAt,
|
||||
model: generated.model,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
setAiPrompt("");
|
||||
};
|
||||
|
||||
if (!planId) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="未指定课案" description="请从课案列表进入编辑" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.fetching) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<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>
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="课案不存在" description="可能已被删除" />
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="mt-4 inline-block text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
{/* 顶部工具栏 */}
|
||||
<header className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-serif text-ink">{plan.title}</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL LessonPlanDetailQuery · 课案编辑器(P7-insights)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{saveMsg && (
|
||||
<span className="text-tiny text-success font-mono">{saveMsg}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave()}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave("PUBLISHED")}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
发布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportPdf}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
导出 PDF
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{/* 三栏布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr_320px] gap-6">
|
||||
{/* 左侧:教材/章节 + 班级关联 */}
|
||||
<aside className="space-y-6">
|
||||
<div className="p-5 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
|
||||
教材与章节
|
||||
</h3>
|
||||
<label className="block mb-3">
|
||||
<span className="text-tiny text-ink-muted">教材</span>
|
||||
<select
|
||||
value={textbookId}
|
||||
onChange={(e) => setTextbookId(e.target.value)}
|
||||
className="mt-1 w-full px-2 py-1.5 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{TEXTBOOKS.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-tiny text-ink-muted">章节</span>
|
||||
<input
|
||||
type="text"
|
||||
value={chapter}
|
||||
onChange={(e) => setChapter(e.target.value)}
|
||||
className="mt-1 w-full px-2 py-1.5 bg-transparent border-b border-rule text-sm text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="block mt-3">
|
||||
<span className="text-tiny text-ink-muted">状态</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="mt-1 w-full px-2 py-1.5 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
|
||||
班级关联
|
||||
</h3>
|
||||
{classes.length === 0 ? (
|
||||
<p className="text-tiny text-ink-muted">暂无班级</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{classes.map((c) => {
|
||||
const checked = classIds.includes(c.id);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<label className="flex items-center gap-2 text-sm text-ink">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setClassIds((prev) => [...prev, c.id]);
|
||||
} else {
|
||||
setClassIds((prev) =>
|
||||
prev.filter((id) => id !== c.id),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{c.name}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 中间:富文本编辑器 */}
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full px-2 py-1.5 bg-transparent border-b border-rule text-xl font-serif text-ink focus:outline-none focus:border-accent"
|
||||
placeholder="课案标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 flex gap-1 border border-rule rounded-button p-1 bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec("bold")}
|
||||
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded font-bold"
|
||||
title="加粗"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec("italic")}
|
||||
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded italic"
|
||||
title="斜体"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec("formatBlock", "<h2>")}
|
||||
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded font-serif"
|
||||
title="标题"
|
||||
>
|
||||
H
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec("insertUnorderedList")}
|
||||
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded"
|
||||
title="无序列表"
|
||||
>
|
||||
• 列表
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exec("insertOrderedList")}
|
||||
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded"
|
||||
title="有序列表"
|
||||
>
|
||||
1. 列表
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorContent(getEditorContent())}
|
||||
className="px-3 py-1 text-sm text-ink hover:bg-subtle rounded ml-auto"
|
||||
title="刷新内容"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="min-h-[480px] p-5 border border-rule rounded-card bg-surface text-sm text-ink font-sans focus:outline-none focus:border-accent whitespace-pre-wrap"
|
||||
role="textbox"
|
||||
aria-label="课案内容编辑器"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 右侧:AI 助手 */}
|
||||
<aside className="space-y-6">
|
||||
<div className="p-5 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
|
||||
AI 助手
|
||||
</h3>
|
||||
<label className="block mb-3">
|
||||
<span className="text-tiny text-ink-muted">生成提示</span>
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="如:为本节课生成更详细的练习题与板书设计"
|
||||
className="mt-1 w-full px-2 py-1.5 bg-transparent border border-rule rounded text-sm text-ink resize-none focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
{aiError && (
|
||||
<p className="text-tiny text-danger mb-2">{aiError}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAIGenerate}
|
||||
disabled={generating || !aiPrompt}
|
||||
className="w-full px-3 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{generating ? "生成中…" : "AI 生成教案"}
|
||||
</button>
|
||||
<p className="mt-2 text-tiny text-ink-muted">
|
||||
生成的内容将追加到编辑器末尾
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
|
||||
生成历史
|
||||
</h3>
|
||||
{aiHistory.length === 0 ? (
|
||||
<p className="text-tiny text-ink-muted">暂无生成记录</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{aiHistory.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="pb-3 border-b border-rule last:border-b-0"
|
||||
>
|
||||
<p className="text-sm text-ink">{h.prompt}</p>
|
||||
<p className="mt-1 text-tiny text-ink-muted font-mono">
|
||||
{h.model} · {h.generatedAt.slice(0, 16).replace("T", " ")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
apps/teacher-portal/src/app/(app)/lesson-plans/calendar/page.tsx
Normal file
217
apps/teacher-portal/src/app/(app)/lesson-plans/calendar/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课案日历视图(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL LessonPlanCalendarQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 月份切换
|
||||
* - 日历网格(每天显示课案数,点击展开当天课案列表)
|
||||
* - CSS Grid 实现
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
import { LessonPlanCalendarQuery } from "@/lib/graphql-p7-insights";
|
||||
import type { LessonPlanCalendarDay } from "@/lib/graphql-p7-insights";
|
||||
|
||||
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
|
||||
export default function LessonPlanCalendarPage(): JSX.Element {
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState<number>(today.getFullYear());
|
||||
const [month, setMonth] = useState<number>(today.getMonth() + 1);
|
||||
const [selectedDay, setSelectedDay] = useState<LessonPlanCalendarDay | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: LessonPlanCalendarQuery,
|
||||
variables: { year, month },
|
||||
});
|
||||
|
||||
const calendar = result.data?.lessonPlanCalendar;
|
||||
|
||||
/** 月首日 weekday(周一为 0) */
|
||||
const firstWeekday = useMemo(() => {
|
||||
const first = new Date(year, month - 1, 1);
|
||||
// JS getDay:周日=0,周一=1...;我们以周一为起始,转换
|
||||
return (first.getDay() + 6) % 7;
|
||||
}, [year, month]);
|
||||
|
||||
/** 月份天数 */
|
||||
const daysInMonth = useMemo(
|
||||
() => new Date(year, month, 0).getDate(),
|
||||
[year, month],
|
||||
);
|
||||
|
||||
const goPrevMonth = (): void => {
|
||||
if (month === 1) {
|
||||
setYear((y) => y - 1);
|
||||
setMonth(12);
|
||||
} else {
|
||||
setMonth((m) => m - 1);
|
||||
}
|
||||
setSelectedDay(null);
|
||||
};
|
||||
|
||||
const goNextMonth = (): void => {
|
||||
if (month === 12) {
|
||||
setYear((y) => y + 1);
|
||||
setMonth(1);
|
||||
} else {
|
||||
setMonth((m) => m + 1);
|
||||
}
|
||||
setSelectedDay(null);
|
||||
};
|
||||
|
||||
/** 按日期生成单元格(含前置空格) */
|
||||
const cells: Array<{ day: number; data: LessonPlanCalendarDay | null }> = useMemo(() => {
|
||||
const list: Array<{ day: number; data: LessonPlanCalendarDay | null }> = [];
|
||||
for (let i = 0; i < firstWeekday; i++) {
|
||||
list.push({ day: 0, data: null });
|
||||
}
|
||||
const dayMap = new Map<string, LessonPlanCalendarDay>();
|
||||
calendar?.days.forEach((d: LessonPlanCalendarDay) => dayMap.set(d.date, d));
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
list.push({ day: d, data: dayMap.get(dateStr) ?? null });
|
||||
}
|
||||
return list;
|
||||
}, [firstWeekday, daysInMonth, calendar, year, month]);
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-serif text-ink">课案日历</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL LessonPlanCalendarQuery · 按月份浏览每日课案分布(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 月份切换 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
{year} 年 {month} 月
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrevMonth}
|
||||
className="px-3 py-1.5 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
← 上月
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNextMonth}
|
||||
className="px-3 py-1.5 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
下月 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : 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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 日历网格 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
className="text-center text-tiny uppercase tracking-wide text-ink-muted py-2"
|
||||
>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((c, i) => {
|
||||
if (c.day === 0) {
|
||||
return <div key={`empty-${i}`} className="aspect-square" />;
|
||||
}
|
||||
const data = c.data;
|
||||
const count = data?.planCount ?? 0;
|
||||
const isSelected =
|
||||
selectedDay?.date === (data?.date ?? "");
|
||||
return (
|
||||
<button
|
||||
key={`day-${c.day}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedDay(data)}
|
||||
className={`aspect-square p-2 border rounded text-left transition-colors ${
|
||||
isSelected
|
||||
? "border-accent bg-accent/10"
|
||||
: count > 0
|
||||
? "border-rule bg-surface hover:bg-subtle"
|
||||
: "border-rule bg-transparent hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-serif text-ink">{c.day}</div>
|
||||
{count > 0 && (
|
||||
<div className="mt-1 text-tiny font-mono text-accent">
|
||||
{count} 课案
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当天课案列表 */}
|
||||
<aside className="p-5 border border-rule rounded-card bg-surface">
|
||||
<h3 className="text-tiny uppercase tracking-wide text-ink-muted mb-3">
|
||||
当天课案
|
||||
</h3>
|
||||
{!selectedDay || selectedDay.planCount === 0 ? (
|
||||
<p className="text-sm text-ink-muted">
|
||||
{selectedDay
|
||||
? `${selectedDay.date} 当天无课案`
|
||||
: "点击日历选择某一天查看课案列表"}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-ink mb-3 font-serif">
|
||||
{selectedDay.date}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{selectedDay.planTitles.map((t, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="text-sm text-ink py-2 border-b border-rule"
|
||||
>
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
377
apps/teacher-portal/src/app/(app)/lesson-plans/heatmap/page.tsx
Normal file
377
apps/teacher-portal/src/app/(app)/lesson-plans/heatmap/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课标覆盖热力图页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL LessonPlanHeatmapQuery:按教材 ID 拉取热力图(教材知识点 × 课案覆盖矩阵)
|
||||
*
|
||||
* 布局:
|
||||
* - 顶部:教材选择 select + 导出 CSV 按钮
|
||||
* - 热力图矩阵(X 轴:教材知识点,Y 轴:课案)
|
||||
* - 每个格子颜色深浅表示覆盖度(0-3:浅 → 深)
|
||||
* - SVG 实现,hover 显示 tooltip(知识点名/课案名/覆盖数)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { LessonPlanHeatmapQuery } from "@/lib/graphql-p7-advanced";
|
||||
import type { LessonPlanHeatmap as HeatmapData } from "@/lib/graphql-p7-advanced";
|
||||
import { listHeatmapTextbooks } from "@/mocks/fixtures/lesson-plan-heatmap";
|
||||
|
||||
/** 教材选项(mock:从 fixture 获取) */
|
||||
const TEXTBOOK_OPTIONS = listHeatmapTextbooks();
|
||||
|
||||
/** SVG 布局常量 */
|
||||
const CELL_W = 22;
|
||||
const CELL_H = 26;
|
||||
const LABEL_COL_W = 160; // Y 轴标签(课案名)宽度
|
||||
const LABEL_ROW_H = 60; // X 轴标签(知识点名)高度
|
||||
const HEADER_PADDING = 10;
|
||||
|
||||
/** 按覆盖度映射填充色(语义令牌变量,0 最浅,3 最深) */
|
||||
function coverageColor(coverage: number): string {
|
||||
switch (coverage) {
|
||||
case 0:
|
||||
return "var(--color-surface)";
|
||||
case 1:
|
||||
return "var(--color-accent-soft, hsl(var(--accent-h) 60% 80%))";
|
||||
case 2:
|
||||
return "var(--color-accent-muted, hsl(var(--accent-h) 55% 65%))";
|
||||
case 3:
|
||||
return "var(--color-accent)";
|
||||
default:
|
||||
return "var(--color-surface)";
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 coverage 取浅色文字(深色格子用反色文字) */
|
||||
function textColor(coverage: number): string {
|
||||
return coverage >= 2
|
||||
? "var(--color-ink-on-accent)"
|
||||
: "var(--color-ink-muted)";
|
||||
}
|
||||
|
||||
/** Tooltip 状态 */
|
||||
interface TooltipState {
|
||||
kpName: string;
|
||||
planName: string;
|
||||
coverage: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export default function LessonPlanHeatmapPage(): React.ReactNode {
|
||||
const [textbookId, setTextbookId] = useState(
|
||||
TEXTBOOK_OPTIONS[0]?.textbookId ?? "",
|
||||
);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: LessonPlanHeatmapQuery,
|
||||
variables: { textbookId },
|
||||
pause: !textbookId,
|
||||
});
|
||||
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
const heatmap: HeatmapData | null = result.data?.lessonPlanHeatmap ?? null;
|
||||
|
||||
// 构建 cell 查找 Map:`${kpId}|${planId}` → coverage
|
||||
const cellMap = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
if (heatmap) {
|
||||
for (const cell of heatmap.cells) {
|
||||
m.set(`${cell.kpId}|${cell.planId}`, cell.coverage);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [heatmap]);
|
||||
|
||||
// SVG 尺寸
|
||||
const kpCount = heatmap?.knowledgePoints.length ?? 0;
|
||||
const planCount = heatmap?.lessonPlans.length ?? 0;
|
||||
const svgWidth = LABEL_COL_W + kpCount * CELL_W + HEADER_PADDING;
|
||||
const svgHeight = LABEL_ROW_H + planCount * CELL_H + HEADER_PADDING;
|
||||
|
||||
/** 导出 CSV */
|
||||
const handleExportCsv = () => {
|
||||
if (!heatmap) return;
|
||||
const header = [
|
||||
"课案/知识点",
|
||||
...heatmap.knowledgePoints.map((kp) => kp.name),
|
||||
].join(",");
|
||||
const rows = heatmap.lessonPlans.map((plan) => {
|
||||
const cells = heatmap.knowledgePoints.map((kp) => {
|
||||
const cov = cellMap.get(`${kp.kpId}|${plan.planId}`) ?? 0;
|
||||
return String(cov);
|
||||
});
|
||||
return [plan.title, ...cells].join(",");
|
||||
});
|
||||
const csv = [header, ...rows].join("\n");
|
||||
// 添加 BOM 以支持 Excel 中文
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `heatmap-${heatmap.textbookId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<p className="mb-2">
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-3xl font-serif text-ink">课标覆盖热力图</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL LessonPlanHeatmapQuery · core-edu 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 操作栏:教材选择 + 导出 CSV */}
|
||||
<section className="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
教材
|
||||
</label>
|
||||
<select
|
||||
value={textbookId}
|
||||
onChange={(e) => setTextbookId(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{TEXTBOOK_OPTIONS.map((tb) => (
|
||||
<option key={tb.textbookId} value={tb.textbookId}>
|
||||
{tb.textbookTitle}({tb.subject})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{heatmap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportCsv}
|
||||
className="px-4 py-2 text-sm text-ink bg-transparent border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
导出 CSV
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={8} />
|
||||
) : 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>
|
||||
) : !heatmap ? (
|
||||
<Empty title="未找到热力图数据" description="请选择其他教材" />
|
||||
) : heatmap.knowledgePoints.length === 0 ? (
|
||||
<Empty title="暂无知识点" description="该教材无知识点数据" />
|
||||
) : (
|
||||
<section>
|
||||
{/* 图例 */}
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
覆盖度
|
||||
</span>
|
||||
{[0, 1, 2, 3].map((c) => (
|
||||
<div key={c} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-4 h-4 rounded-button"
|
||||
style={{ background: coverageColor(c) }}
|
||||
/>
|
||||
<span className="text-tiny text-ink-muted">{c} 课案</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SVG 热力图(可滚动容器) */}
|
||||
<div
|
||||
className="relative overflow-auto border border-rule rounded-card bg-paper"
|
||||
style={{ maxHeight: "70vh" }}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
role="img"
|
||||
aria-label={`${heatmap.textbookTitle} 课标覆盖热力图`}
|
||||
>
|
||||
{/* X 轴标签:知识点名(旋转 -60°) */}
|
||||
<g>
|
||||
{heatmap.knowledgePoints.map((kp, i) => {
|
||||
const x = LABEL_COL_W + i * CELL_W + CELL_W / 2;
|
||||
const y = LABEL_ROW_H - 8;
|
||||
return (
|
||||
<text
|
||||
key={kp.kpId}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
transform={`rotate(-60, ${x}, ${y})`}
|
||||
>
|
||||
{kp.name}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Y 轴标签:课案名 */}
|
||||
<g>
|
||||
{heatmap.lessonPlans.map((plan, j) => {
|
||||
const y = LABEL_ROW_H + j * CELL_H + CELL_H / 2 + 4;
|
||||
return (
|
||||
<text
|
||||
key={plan.planId}
|
||||
x={LABEL_COL_W - 8}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
fontSize="11"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{plan.title}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* 单元格 */}
|
||||
<g>
|
||||
{heatmap.knowledgePoints.map((kp, i) =>
|
||||
heatmap.lessonPlans.map((plan, j) => {
|
||||
const cellX = LABEL_COL_W + i * CELL_W;
|
||||
const cellY = LABEL_ROW_H + j * CELL_H;
|
||||
const coverage = cellMap.get(`${kp.kpId}|${plan.planId}`) ?? 0;
|
||||
return (
|
||||
<rect
|
||||
key={`${kp.kpId}-${plan.planId}`}
|
||||
x={cellX + 1}
|
||||
y={cellY + 1}
|
||||
width={CELL_W - 2}
|
||||
height={CELL_H - 2}
|
||||
fill={coverageColor(coverage)}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={0.5}
|
||||
onMouseEnter={() =>
|
||||
setTooltip({
|
||||
kpName: kp.name,
|
||||
planName: plan.title,
|
||||
coverage,
|
||||
x: cellX + CELL_W,
|
||||
y: cellY,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<title>{`${kp.name} × ${plan.title}:${coverage} 课案`}</title>
|
||||
</rect>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* 单元格内覆盖数文字(仅 coverage > 0 显示) */}
|
||||
<g pointerEvents="none">
|
||||
{heatmap.knowledgePoints.map((kp, i) =>
|
||||
heatmap.lessonPlans.map((plan, j) => {
|
||||
const coverage = cellMap.get(`${kp.kpId}|${plan.planId}`) ?? 0;
|
||||
if (coverage === 0) return null;
|
||||
const cellX = LABEL_COL_W + i * CELL_W + CELL_W / 2;
|
||||
const cellY = LABEL_ROW_H + j * CELL_H + CELL_H / 2 + 4;
|
||||
return (
|
||||
<text
|
||||
key={`text-${kp.kpId}-${plan.planId}`}
|
||||
x={cellX}
|
||||
y={cellY}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill={textColor(coverage)}
|
||||
>
|
||||
{coverage}
|
||||
</text>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Tooltip(hover 显示) */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="absolute pointer-events-none p-2 border border-rule rounded-card bg-paper text-tiny"
|
||||
style={{
|
||||
left: `${tooltip.x + 8}px`,
|
||||
top: `${tooltip.y + 8}px`,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<p className="font-serif text-ink">{tooltip.kpName}</p>
|
||||
<p className="text-ink-muted">{tooltip.planName}</p>
|
||||
<p className="mt-1 text-accent">
|
||||
覆盖:{tooltip.coverage} 课案
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计摘要 */}
|
||||
<div className="mt-6 grid grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label="知识点数"
|
||||
value={heatmap.knowledgePoints.length}
|
||||
/>
|
||||
<StatCard
|
||||
label="课案数"
|
||||
value={heatmap.lessonPlans.length}
|
||||
/>
|
||||
<StatCard
|
||||
label="总覆盖数"
|
||||
value={heatmap.cells.reduce((acc, c) => acc + c.coverage, 0)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统计卡片子组件 */
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="p-4 border border-rule rounded-card bg-surface">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-serif text-ink">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/teacher-portal/src/app/(app)/lesson-plans/library/page.tsx
Normal file
185
apps/teacher-portal/src/app/(app)/lesson-plans/library/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 校内课案库(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL LessonPlanLibraryQuery + ForkLessonPlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 学科 + 年级筛选
|
||||
* - 列表:标题/作者/学科/年级/章节/评分/Fork 数/操作
|
||||
* - "Fork 到我的课案" 调用 ForkLessonPlanMutation
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
LessonPlanLibraryQuery,
|
||||
ForkLessonPlanMutation,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
import type { LessonPlanLibraryItem } from "@/lib/graphql-p7-insights";
|
||||
|
||||
const SUBJECT_FILTERS = ["全部", "语文", "数学", "英语"] as const;
|
||||
const GRADE_FILTERS = [
|
||||
"全部",
|
||||
"小学三年级",
|
||||
"小学六年级",
|
||||
"初中一年级",
|
||||
"高中一年级",
|
||||
"高中三年级",
|
||||
] as const;
|
||||
|
||||
export default function LessonPlanLibraryPage(): JSX.Element {
|
||||
const [subject, setSubject] = useState<string>("全部");
|
||||
const [grade, setGrade] = useState<string>("全部");
|
||||
const [forkingId, setForkingId] = useState<string | null>(null);
|
||||
const [forkedIds, setForkedIds] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: LessonPlanLibraryQuery,
|
||||
variables:
|
||||
subject === "全部" && grade === "全部"
|
||||
? {}
|
||||
: {
|
||||
subject: subject === "全部" ? null : subject,
|
||||
grade: grade === "全部" ? null : grade,
|
||||
},
|
||||
});
|
||||
|
||||
const [, forkLessonPlan] = useMutation(ForkLessonPlanMutation);
|
||||
|
||||
const items: LessonPlanLibraryItem[] = result.data?.lessonPlanLibrary ?? [];
|
||||
|
||||
const handleFork = async (planId: string): Promise<void> => {
|
||||
setForkingId(planId);
|
||||
setError(null);
|
||||
const res = await forkLessonPlan({ planId });
|
||||
setForkingId(null);
|
||||
if (res.error || !res.data?.forkLessonPlan) {
|
||||
setError(res.error?.message ?? "Fork 失败");
|
||||
return;
|
||||
}
|
||||
setForkedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(planId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-serif text-ink">校内课案库</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL LessonPlanLibraryQuery · 浏览校内同仁分享的课案并 Fork(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="mb-6 flex items-baseline gap-6 flex-wrap">
|
||||
<div className="flex items-center 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"
|
||||
>
|
||||
{SUBJECT_FILTERS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
年级
|
||||
</label>
|
||||
<select
|
||||
value={grade}
|
||||
onChange={(e) => setGrade(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"
|
||||
>
|
||||
{GRADE_FILTERS.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mark-left mb-4 py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : items.length === 0 ? (
|
||||
<Empty
|
||||
title="暂无课案"
|
||||
description="当前筛选条件下没有可 Fork 的校内课案"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((item) => {
|
||||
const forked = forkedIds.has(item.id);
|
||||
const forking = forkingId === item.id;
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="p-5 border border-rule rounded-card bg-surface flex flex-col gap-3"
|
||||
>
|
||||
<h3 className="font-serif text-lg text-ink">{item.title}</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
|
||||
{item.subject}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
|
||||
{item.grade}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink-muted">
|
||||
{item.chapter}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-tiny text-ink-muted">
|
||||
作者:{item.author} · Fork 数 {item.forkCount} · 评分{" "}
|
||||
<span className="font-mono text-accent">{item.rating}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFork(item.id)}
|
||||
disabled={forking || forked}
|
||||
className="mt-2 px-3 py-1.5 text-tiny text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{forking
|
||||
? "Fork 中…"
|
||||
: forked
|
||||
? "已 Fork ✓"
|
||||
: "Fork 到我的课案"}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
apps/teacher-portal/src/app/(app)/lesson-plans/new/page.tsx
Normal file
220
apps/teacher-portal/src/app/(app)/lesson-plans/new/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 新建课案页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL CreateLessonPlanMutation
|
||||
*
|
||||
* 功能:
|
||||
* - 模板选择器(5 个模板)
|
||||
* - 表单:标题/学科/年级/章节/教材关联
|
||||
* - "选择模板后创建" 跳转到 /lesson-plans/[planId]/edit
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "urql";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
import { CreateLessonPlanMutation } from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
CreateLessonPlanInput,
|
||||
LessonPlanTemplate,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
import { LESSON_PLAN_TEMPLATES } from "@/mocks/fixtures/lesson-plans";
|
||||
|
||||
const SUBJECTS = ["语文", "数学", "英语"] as const;
|
||||
const GRADES = [
|
||||
"小学三年级",
|
||||
"小学六年级",
|
||||
"初中一年级",
|
||||
"高中一年级",
|
||||
"高中三年级",
|
||||
] as const;
|
||||
|
||||
export default function NewLessonPlanPage(): JSX.Element {
|
||||
const router = useRouter();
|
||||
const [template, setTemplate] = useState<LessonPlanTemplate>("REGULAR");
|
||||
const [title, setTitle] = useState("");
|
||||
const [subject, setSubject] = useState<string>(SUBJECTS[0] ?? "数学");
|
||||
const [grade, setGrade] = useState<string>(GRADES[0] ?? "小学三年级");
|
||||
const [chapter, setChapter] = useState("");
|
||||
const [textbookId, setTextbookId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [, createLessonPlan] = useMutation(CreateLessonPlanMutation);
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!title || !chapter) {
|
||||
setError("请填写标题与章节");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const input: CreateLessonPlanInput = {
|
||||
title,
|
||||
subject,
|
||||
grade,
|
||||
chapter,
|
||||
textbookId: textbookId || null,
|
||||
template,
|
||||
};
|
||||
const res = await createLessonPlan({ input });
|
||||
setSubmitting(false);
|
||||
if (res.error || !res.data?.createLessonPlan) {
|
||||
setError(res.error?.message ?? "创建失败");
|
||||
return;
|
||||
}
|
||||
const created = res.data.createLessonPlan as { id: string };
|
||||
router.push(`/lesson-plans/${created.id}/edit`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回课案列表
|
||||
</Link>
|
||||
<h1 className="mt-3 text-3xl font-serif text-ink">新建课案</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL CreateLessonPlanMutation · 选择模板后创建跳转至编辑器(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 模板选择器 */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">选择模板</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{LESSON_PLAN_TEMPLATES.map((t) => {
|
||||
const active = t.id === template;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTemplate(t.id)}
|
||||
className={`p-4 border rounded-card text-left transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-rule bg-surface hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
<p className="font-serif text-ink">{t.name}</p>
|
||||
<p className="mt-1 text-tiny text-ink-muted">{t.description}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 表单 */}
|
||||
<section className="mb-8 max-w-2xl">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">基本信息</h2>
|
||||
{submitting ? (
|
||||
<Loading lines={4} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
标题
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:函数的单调性与极值"
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学科
|
||||
</span>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
年级
|
||||
</span>
|
||||
<select
|
||||
value={grade}
|
||||
onChange={(e) => setGrade(e.target.value)}
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||||
>
|
||||
{GRADES.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
章节
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={chapter}
|
||||
onChange={(e) => setChapter(e.target.value)}
|
||||
placeholder="如:选修 2-2·导数应用"
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="col-span-2 flex flex-col gap-1">
|
||||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
教材关联(可选)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={textbookId}
|
||||
onChange={(e) => setTextbookId(e.target.value)}
|
||||
placeholder="如:tb-001"
|
||||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={submitting}
|
||||
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "创建中…" : "选择模板后创建"}
|
||||
</button>
|
||||
<Link
|
||||
href="/lesson-plans"
|
||||
className="px-5 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
取消
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
apps/teacher-portal/src/app/(app)/lesson-plans/page.tsx
Normal file
162
apps/teacher-portal/src/app/(app)/lesson-plans/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 课案列表页(P7-insights)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §6 备课):
|
||||
* - GraphQL LessonPlansQuery
|
||||
*
|
||||
* 功能:
|
||||
* - 学科筛选
|
||||
* - 列表:标题/学科/年级/章节/状态/更新时间/操作
|
||||
* - "新建课案" 链接(→ /lesson-plans/new)
|
||||
* - "校库" 链接(→ /lesson-plans/library)
|
||||
* - "日历视图" 链接(→ /lesson-plans/calendar)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { LessonPlansQuery } from "@/lib/graphql-p7-insights";
|
||||
import type { LessonPlanItem, LessonPlanStatus } from "@/lib/graphql-p7-insights";
|
||||
|
||||
const SUBJECT_FILTERS = ["全部", "语文", "数学", "英语"] as const;
|
||||
|
||||
const STATUS_LABEL: Record<LessonPlanStatus, string> = {
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return iso.slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default function LessonPlansPage(): JSX.Element {
|
||||
const [subject, setSubject] = useState<string>("全部");
|
||||
|
||||
const [result] = useQuery({
|
||||
query: LessonPlansQuery,
|
||||
variables: subject === "全部" ? {} : { subject },
|
||||
});
|
||||
|
||||
const plans: LessonPlanItem[] = result.data?.lessonPlans ?? [];
|
||||
|
||||
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 LessonPlansQuery · 课案列表与编辑入口(P7-insights,MSW mock)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="mb-6 flex items-baseline justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center 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"
|
||||
>
|
||||
{SUBJECT_FILTERS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/lesson-plans/new"
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建课案
|
||||
</Link>
|
||||
<Link
|
||||
href="/lesson-plans/library"
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
校库
|
||||
</Link>
|
||||
<Link
|
||||
href="/lesson-plans/calendar"
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
日历视图
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : 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>
|
||||
) : plans.length === 0 ? (
|
||||
<Empty
|
||||
title="暂无课案"
|
||||
description="点击右上角「新建课案」开始创建第一份课案"
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">标题</th>
|
||||
<th className="py-3 pr-4 font-normal">学科</th>
|
||||
<th className="py-3 pr-4 font-normal">年级</th>
|
||||
<th className="py-3 pr-4 font-normal">章节</th>
|
||||
<th className="py-3 pr-4 font-normal">状态</th>
|
||||
<th className="py-3 pr-4 font-normal">更新时间</th>
|
||||
<th className="py-3 pr-4 font-normal text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((p) => (
|
||||
<tr key={p.id} className="border-b border-rule">
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={`/lesson-plans/${p.id}/edit`}
|
||||
className="font-serif text-ink hover:text-accent"
|
||||
>
|
||||
{p.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink">{p.subject}</td>
|
||||
<td className="py-3 pr-4 text-ink">{p.grade}</td>
|
||||
<td className="py-3 pr-4 text-ink-muted">{p.chapter}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="px-2 py-0.5 rounded bg-subtle text-tiny text-ink">
|
||||
{STATUS_LABEL[p.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink-muted font-mono text-tiny">
|
||||
{formatDate(p.updatedAt)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
<Link
|
||||
href={`/lesson-plans/${p.id}/edit`}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
编辑
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
apps/teacher-portal/src/app/(app)/notifications/page.tsx
Normal file
260
apps/teacher-portal/src/app/(app)/notifications/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Notifications 页面 - 通知中心(P5)
|
||||
*
|
||||
* 数据来源(02-architecture-design.md §5):
|
||||
* - GraphQL MyNotificationsQuery(持久化通知列表)
|
||||
* - WebSocket 实时推送(push-gateway,use-notifications-websocket hook)
|
||||
*
|
||||
* 功能:
|
||||
* - 通知列表(标题、消息、时间、类型)
|
||||
* - 未读高亮 + 标记已读(乐观更新) + 全部标记已读
|
||||
* - 类型筛选(全部 / 未读 / 作业 / 考试 / 广播)
|
||||
* - WebSocket 连接状态指示器
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "urql";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
MyNotificationsQuery,
|
||||
MarkAsReadMutation,
|
||||
} from "@/lib/graphql-p5";
|
||||
import type { NotificationItem, NotificationType } from "@/lib/graphql-p5";
|
||||
import {
|
||||
useNotificationsWebSocket,
|
||||
NOTIFICATION_TYPE_LABEL,
|
||||
} from "@/hooks/use-notifications-websocket";
|
||||
import type { ConnectionState } from "@/hooks/use-notifications-websocket";
|
||||
|
||||
type FilterKey = "all" | "unread" | NotificationType;
|
||||
|
||||
const FILTERS: { key: FilterKey; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "unread", label: "未读" },
|
||||
{ key: "HOMEWORK_SUBMITTED", label: "作业" },
|
||||
{ key: "EXAM_GRADED", label: "考试" },
|
||||
{ key: "BROADCAST", label: "广播" },
|
||||
];
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (diff < minute) return "刚刚";
|
||||
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`;
|
||||
if (diff < day) return `${Math.floor(diff / hour)} 小时前`;
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)} 天前`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
function ConnectionDot({ state }: { state: ConnectionState }): JSX.Element {
|
||||
const map: Record<ConnectionState, { dot: string; text: string }> = {
|
||||
connected: { dot: "bg-success", text: "已连接" },
|
||||
connecting: { dot: "bg-warning", text: "连接中" },
|
||||
disconnected: { dot: "bg-danger", text: "已断开" },
|
||||
error: { dot: "bg-danger", text: "连接错误" },
|
||||
};
|
||||
const cfg = map[state];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${cfg.dot}`} />
|
||||
<span className="text-tiny text-ink-muted">{cfg.text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsPage(): JSX.Element {
|
||||
const [filter, setFilter] = useState<FilterKey>("all");
|
||||
const [readOverrides, setReadOverrides] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const [result] = useQuery({ query: MyNotificationsQuery });
|
||||
const [, markAsRead] = useMutation(MarkAsReadMutation);
|
||||
const { notifications: wsNotifications, connectionState, reconnect } =
|
||||
useNotificationsWebSocket();
|
||||
|
||||
// 合并:WebSocket 实时推送(新) + GraphQL 持久化列表,按 id 去重
|
||||
const merged = useMemo<NotificationItem[]>(() => {
|
||||
const persisted = (result.data?.myNotifications as NotificationItem[] | undefined) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const list: NotificationItem[] = [];
|
||||
for (const n of [...wsNotifications, ...persisted]) {
|
||||
if (seen.has(n.id)) continue;
|
||||
seen.add(n.id);
|
||||
list.push(n);
|
||||
}
|
||||
return list;
|
||||
}, [result.data, wsNotifications]);
|
||||
|
||||
const displayed = useMemo<NotificationItem[]>(() => {
|
||||
const withOverrides = merged.map((n) => ({
|
||||
...n,
|
||||
read: readOverrides[n.id] ?? n.read,
|
||||
}));
|
||||
switch (filter) {
|
||||
case "all":
|
||||
return withOverrides;
|
||||
case "unread":
|
||||
return withOverrides.filter((n) => !n.read);
|
||||
default:
|
||||
return withOverrides.filter((n) => n.type === filter);
|
||||
}
|
||||
}, [merged, readOverrides, filter]);
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() =>
|
||||
merged.filter((n) => !(readOverrides[n.id] ?? n.read)).length,
|
||||
[merged, readOverrides],
|
||||
);
|
||||
|
||||
const handleMarkAsRead = (id: string): void => {
|
||||
setReadOverrides((prev) => ({ ...prev, [id]: true }));
|
||||
void markAsRead({ id }).then((res) => {
|
||||
if (res.error) {
|
||||
setReadOverrides((prev) => ({ ...prev, [id]: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkAllRead = (): void => {
|
||||
const unreadIds = merged
|
||||
.filter((n) => !(readOverrides[n.id] ?? n.read))
|
||||
.map((n) => n.id);
|
||||
if (unreadIds.length === 0) return;
|
||||
const overrides: Record<string, boolean> = {};
|
||||
for (const id of unreadIds) overrides[id] = true;
|
||||
setReadOverrides((prev) => ({ ...prev, ...overrides }));
|
||||
for (const id of unreadIds) {
|
||||
void markAsRead({ id }).then((res) => {
|
||||
if (res.error) {
|
||||
setReadOverrides((prev) => ({ ...prev, [id]: false }));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">通知中心</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL MyNotificationsQuery + WebSocket 实时推送(P5)
|
||||
</p>
|
||||
</div>
|
||||
<ConnectionDot state={connectionState} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选 + 操作 */}
|
||||
<section className="mb-6 flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
filter === f.key
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-tiny text-ink-muted">
|
||||
未读 <span className="font-mono text-accent">{unreadCount}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={unreadCount === 0}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
全部标记已读
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reconnect}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
重连
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result.fetching && merged.length === 0 ? (
|
||||
<Loading lines={5} />
|
||||
) : result.error && merged.length === 0 ? (
|
||||
<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>
|
||||
) : displayed.length === 0 ? (
|
||||
<Empty title="暂无通知" description="当前筛选条件下没有通知" />
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{displayed.map((n) => {
|
||||
const isRead = readOverrides[n.id] ?? n.read;
|
||||
return (
|
||||
<li
|
||||
key={n.id}
|
||||
className={`py-4 grid grid-cols-12 gap-4 items-baseline border-b border-rule ${
|
||||
isRead ? "" : "bg-subtle"
|
||||
}`}
|
||||
>
|
||||
<div className="col-span-9 pl-2">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-tiny uppercase tracking-wide px-2 py-0.5 rounded-button border border-rule text-ink-muted">
|
||||
{NOTIFICATION_TYPE_LABEL[n.type]}
|
||||
</span>
|
||||
<h3
|
||||
className={`text-lg font-serif ${
|
||||
isRead ? "text-ink-muted" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{n.title}
|
||||
</h3>
|
||||
{!isRead && (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-accent" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-ink-muted">{n.message}</p>
|
||||
<p className="mt-1 text-tiny font-mono text-ink-subtle">
|
||||
{relativeTime(n.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-3 text-right">
|
||||
{isRead ? (
|
||||
<span className="text-tiny text-ink-subtle">已读</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMarkAsRead(n.id)}
|
||||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||||
>
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
apps/teacher-portal/src/app/(app)/practice/page.tsx
Normal file
451
apps/teacher-portal/src/app/(app)/practice/page.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 自适应练习分析页(P7-insights)
|
||||
*
|
||||
* 数据来源:GraphQL PracticeAnalyticsQuery
|
||||
*
|
||||
* 6 个 Widget:
|
||||
* 1. 班级筛选
|
||||
* 2. 5 项统计卡片(总练习数/完成率/平均正确率/薄弱知识点/参与度)
|
||||
* 3. 班级对比表(5 班级 × 5 指标)
|
||||
* 4. 类型分布饼图(5 类型:单选/多选/判断/填空/简答)- 纯 SVG
|
||||
* 5. 知识点薄弱度柱图(10 知识点 × 正确率)- 纯 SVG
|
||||
* 6. 学生排名表 + 未参与提醒
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { PracticeAnalyticsQuery } from "@/lib/graphql-p7-insights";
|
||||
import type {
|
||||
PracticeAnalytics as PracticeAnalyticsType,
|
||||
PracticeTypeBreakdown,
|
||||
PracticeKpWeakness,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
const CLASSES = [
|
||||
{ id: "", name: "全部班级" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
|
||||
];
|
||||
|
||||
/** 饼图色板(使用语义令牌变量,禁止 hex 字面量) */
|
||||
const PIE_COLORS = [
|
||||
"var(--color-accent)",
|
||||
"var(--color-success)",
|
||||
"var(--color-warning)",
|
||||
"var(--color-danger)",
|
||||
"var(--color-ink-muted)",
|
||||
];
|
||||
|
||||
/** 类型分布饼图 */
|
||||
function TypePieChart({
|
||||
data,
|
||||
}: {
|
||||
data: PracticeTypeBreakdown[];
|
||||
}): React.ReactNode {
|
||||
const W = 320;
|
||||
const H = 240;
|
||||
const cx = W / 2 - 30;
|
||||
const cy = H / 2;
|
||||
const r = 80;
|
||||
const total = data.reduce((acc, d) => acc + d.count, 0) || 1;
|
||||
|
||||
function arcPath(
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): string {
|
||||
const x1 = cx + r * Math.cos(startAngle);
|
||||
const y1 = cy + r * Math.sin(startAngle);
|
||||
const x2 = cx + r * Math.cos(endAngle);
|
||||
const y2 = cy + r * Math.sin(endAngle);
|
||||
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
||||
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
|
||||
}
|
||||
|
||||
let accAngle = -Math.PI / 2;
|
||||
const slices = data.map((d, i) => {
|
||||
const angle = (d.count / total) * 2 * Math.PI;
|
||||
const start = accAngle;
|
||||
const end = accAngle + angle;
|
||||
accAngle = end;
|
||||
return { d, path: arcPath(start, end), color: PIE_COLORS[i % PIE_COLORS.length] ?? PIE_COLORS[0] };
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full max-w-md border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="练习类型分布饼图"
|
||||
>
|
||||
{slices.map((s, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={s.path}
|
||||
fill={s.color}
|
||||
fillOpacity={0.85}
|
||||
stroke="var(--bg-paper)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
{/* 图例 */}
|
||||
{slices.map((s, i) => {
|
||||
const ly = 20 + i * 20;
|
||||
return (
|
||||
<g key={`legend-${i}`}>
|
||||
<rect
|
||||
x={W - 100}
|
||||
y={ly - 9}
|
||||
width={12}
|
||||
height={12}
|
||||
fill={s.color}
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={W - 82}
|
||||
y={ly}
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{s.d.type}({Math.round(s.d.ratio * 100)}%)
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 知识点薄弱度柱图(垂直柱) */
|
||||
function KpWeaknessBarChart({
|
||||
data,
|
||||
}: {
|
||||
data: PracticeKpWeakness[];
|
||||
}): React.ReactNode {
|
||||
const W = 540;
|
||||
const H = 240;
|
||||
const PAD = { top: 20, right: 12, bottom: 60, left: 36 };
|
||||
const innerW = W - PAD.left - PAD.right;
|
||||
const innerH = H - PAD.top - PAD.bottom;
|
||||
const barW = innerW / data.length - 6;
|
||||
|
||||
function accuracyColor(rate: number): string {
|
||||
if (rate >= 0.8) return "var(--color-success)";
|
||||
if (rate >= 0.6) return "var(--color-warning)";
|
||||
return "var(--color-danger)";
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full max-w-2xl border border-rule rounded-card bg-surface"
|
||||
role="img"
|
||||
aria-label="知识点正确率柱图"
|
||||
>
|
||||
{/* Y 轴网格 */}
|
||||
{[0, 0.5, 1].map((r, i) => {
|
||||
const y = PAD.top + innerH * r;
|
||||
const v = Math.round(100 * (1 - r));
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={PAD.left}
|
||||
y1={y}
|
||||
x2={W - PAD.right}
|
||||
y2={y}
|
||||
stroke="var(--color-rule)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PAD.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{v}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* 柱子 */}
|
||||
{data.map((d, i) => {
|
||||
const x = PAD.left + i * (barW + 6) + 3;
|
||||
const h = d.accuracy * innerH;
|
||||
const y = PAD.top + innerH - h;
|
||||
const color = accuracyColor(d.accuracy);
|
||||
return (
|
||||
<g key={d.knowledgePoint}>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={barW}
|
||||
height={h}
|
||||
fill={color}
|
||||
fillOpacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={y - 4}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fontFamily="var(--font-family-mono)"
|
||||
fontWeight="600"
|
||||
fill="var(--color-ink)"
|
||||
>
|
||||
{Math.round(d.accuracy * 100)}
|
||||
</text>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={H - PAD.bottom + 14}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fontFamily="var(--font-family-sans)"
|
||||
fill="var(--color-ink-muted)"
|
||||
>
|
||||
{d.knowledgePoint.length > 4
|
||||
? d.knowledgePoint.slice(0, 4) + "…"
|
||||
: d.knowledgePoint}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PracticePage(): JSX.Element {
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
|
||||
const [result] = useQuery({
|
||||
query: PracticeAnalyticsQuery,
|
||||
variables: { classId: classId || null },
|
||||
});
|
||||
|
||||
const analytics: PracticeAnalyticsType | undefined =
|
||||
result.data?.practiceAnalytics;
|
||||
|
||||
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 PracticeAnalyticsQuery · 班级练习统计与未参与提醒(P7-insights)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 班级筛选 */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={classId}
|
||||
onChange={(e) => setClassId(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"
|
||||
>
|
||||
{CLASSES.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
) : !analytics ? (
|
||||
<Empty title="暂无数据" description="该班级暂无练习数据" />
|
||||
) : (
|
||||
<>
|
||||
{/* 5 项统计卡片 */}
|
||||
<section className="mb-8 grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
总练习数
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-ink">
|
||||
{analytics.summary.totalSessions}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
完成率
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-success">
|
||||
{Math.round(analytics.summary.completionRate * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
平均正确率
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-accent">
|
||||
{Math.round(analytics.summary.avgAccuracy * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
薄弱知识点
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-warning">
|
||||
{analytics.summary.weakKpCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 border border-rule rounded-card">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
参与度
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-serif text-ink">
|
||||
{Math.round(analytics.summary.participationRate * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 班级对比表 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">班级对比</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">班级</th>
|
||||
<th className="py-3 pr-4 font-normal">总练习数</th>
|
||||
<th className="py-3 pr-4 font-normal">完成率</th>
|
||||
<th className="py-3 pr-4 font-normal">正确率</th>
|
||||
<th className="py-3 pr-4 font-normal">参与度</th>
|
||||
<th className="py-3 pr-4 font-normal">薄弱知识点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analytics.classComparison.map((c) => (
|
||||
<tr key={c.classId} className="border-b border-rule">
|
||||
<td className="py-3 pr-4 text-ink">{c.className}</td>
|
||||
<td className="py-3 pr-4 font-mono text-ink">
|
||||
{c.totalSessions}
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-success">
|
||||
{Math.round(c.completionRate * 100)}%
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-accent">
|
||||
{Math.round(c.avgAccuracy * 100)}%
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-ink-muted">
|
||||
{Math.round(c.participationRate * 100)}%
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-warning">
|
||||
{c.weakKpCount}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* 类型分布饼图 + 知识点薄弱度柱图 */}
|
||||
<section className="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
练习类型分布
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<TypePieChart data={analytics.typeBreakdown} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
知识点正确率
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<KpWeaknessBarChart data={analytics.kpWeakness} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 学生排名表 */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-xl font-serif text-ink mb-4">学生排名</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-tiny uppercase tracking-wide text-ink-muted border-b border-rule">
|
||||
<th className="py-3 pr-4 font-normal">排名</th>
|
||||
<th className="py-3 pr-4 font-normal">学号</th>
|
||||
<th className="py-3 pr-4 font-normal">姓名</th>
|
||||
<th className="py-3 pr-4 font-normal">总练习数</th>
|
||||
<th className="py-3 pr-4 font-normal">正确率</th>
|
||||
<th className="py-3 pr-4 font-normal">薄弱知识点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analytics.rankings.map((r) => (
|
||||
<tr key={r.studentId} className="border-b border-rule">
|
||||
<td className="py-3 pr-4 font-serif text-ink-muted">
|
||||
{r.rank}
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-ink-muted text-tiny">
|
||||
{r.studentNo}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink">{r.studentName}</td>
|
||||
<td className="py-3 pr-4 font-mono text-ink">
|
||||
{r.totalSessions}
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-accent">
|
||||
{Math.round(r.accuracy * 100)}%
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-ink-muted">
|
||||
{r.weakKp}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{/* 未参与提醒 */}
|
||||
<section>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
未参与提醒(最近 7 天)
|
||||
</h2>
|
||||
<div className="rule-thin mb-6" />
|
||||
{analytics.inactiveStudents.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">所有学生均积极参与练习 ✓</p>
|
||||
) : (
|
||||
<div className="p-4 border border-warning rounded-card bg-warning/10">
|
||||
<ul className="space-y-2">
|
||||
{analytics.inactiveStudents.map((s) => (
|
||||
<li
|
||||
key={s.studentId}
|
||||
className="flex items-baseline justify-between text-sm"
|
||||
>
|
||||
<span className="text-ink">{s.studentName}</span>
|
||||
<span className="font-mono text-tiny text-warning">
|
||||
最近 {s.lastActiveDays} 天未练习
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
886
apps/teacher-portal/src/app/(app)/questions/page.tsx
Normal file
886
apps/teacher-portal/src/app/(app)/questions/page.tsx
Normal file
@@ -0,0 +1,886 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 题库主页 - 六维筛选 + CRUD + 批量导入导出
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL QuestionsLibraryQuery:题库列表(六维筛选 + 分页 + 总数)
|
||||
* - GraphQL QuestionCreate/Update/Delete:题目 CRUD
|
||||
* - GraphQL QuestionBatchImport:批量导入
|
||||
* - GraphQL QuestionBatchExport:批量导出(查询后用 Blob 下载 JSON)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import {
|
||||
QuestionsLibraryQuery,
|
||||
QuestionCreateMutation,
|
||||
QuestionUpdateMutation,
|
||||
QuestionDeleteMutation,
|
||||
QuestionBatchImportMutation,
|
||||
QuestionBatchExportQuery,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import type {
|
||||
QuestionItem,
|
||||
QuestionType,
|
||||
QuestionDifficulty,
|
||||
QuestionsLibraryFilter,
|
||||
QuestionCreateInput,
|
||||
QuestionUpdateInput,
|
||||
QuestionBatchImportItem,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import { MOCK_TEXTBOOKS, MOCK_TEXTBOOK_DETAILS } from "@/mocks/fixtures/textbooks";
|
||||
import {
|
||||
QUESTION_TYPE_OPTIONS,
|
||||
QUESTION_DIFFICULTY_OPTIONS,
|
||||
QUESTION_TYPE_LABEL,
|
||||
QUESTION_DIFFICULTY_LABEL,
|
||||
} from "@/mocks/fixtures/questions";
|
||||
|
||||
/** 类型中文 */
|
||||
const TYPE_LABEL: Record<QuestionType, string> = QUESTION_TYPE_LABEL;
|
||||
/** 难度中文 */
|
||||
const DIFFICULTY_LABEL: Record<QuestionDifficulty, string> =
|
||||
QUESTION_DIFFICULTY_LABEL;
|
||||
|
||||
/** 编辑/新建弹窗的表单状态 */
|
||||
interface QuestionFormState {
|
||||
questionId: string | null; // null 表示新建
|
||||
content: string;
|
||||
type: QuestionType;
|
||||
difficulty: QuestionDifficulty;
|
||||
textbookId: string;
|
||||
chapterId: string;
|
||||
kpId: string;
|
||||
score: string;
|
||||
answer: string;
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
/** 初始空表单 */
|
||||
function emptyForm(): QuestionFormState {
|
||||
return {
|
||||
questionId: null,
|
||||
content: "",
|
||||
type: "SINGLE_CHOICE",
|
||||
difficulty: "MEDIUM",
|
||||
textbookId: "",
|
||||
chapterId: "",
|
||||
kpId: "",
|
||||
score: "5",
|
||||
answer: "",
|
||||
analysis: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 QuestionItem 填充表单 */
|
||||
function fillForm(q: QuestionItem): QuestionFormState {
|
||||
return {
|
||||
questionId: q.questionId,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
textbookId: q.textbookId ?? "",
|
||||
chapterId: q.chapterId ?? "",
|
||||
kpId: q.kpId ?? "",
|
||||
score: String(q.score),
|
||||
answer: q.answer,
|
||||
analysis: q.analysis,
|
||||
};
|
||||
}
|
||||
|
||||
export default function QuestionsPage(): React.ReactNode {
|
||||
// === 筛选条件 ===
|
||||
const [filterQ, setFilterQ] = useState("");
|
||||
const [filterType, setFilterType] = useState<QuestionType | "ALL">("ALL");
|
||||
const [filterDifficulty, setFilterDifficulty] = useState<
|
||||
QuestionDifficulty | "ALL"
|
||||
>("ALL");
|
||||
const [filterTextbook, setFilterTextbook] = useState("all");
|
||||
const [filterChapter, setFilterChapter] = useState("all");
|
||||
const [filterKp, setFilterKp] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// === 编辑弹窗状态 ===
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<QuestionFormState>(emptyForm());
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [dialogSaving, setDialogSaving] = useState(false);
|
||||
|
||||
// === 导入导出状态 ===
|
||||
const [importMsg, setImportMsg] = useState<string | null>(null);
|
||||
const [exportMsg, setExportMsg] = useState<string | null>(null);
|
||||
const [exportTriggered, setExportTriggered] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// 当前选中教材 → 章节列表
|
||||
const dialogChapters = useMemo(() => {
|
||||
if (!form.textbookId) return [];
|
||||
const tb = MOCK_TEXTBOOK_DETAILS.find((t) => t.id === form.textbookId);
|
||||
return tb?.chapters ?? [];
|
||||
}, [form.textbookId]);
|
||||
|
||||
const dialogKps = useMemo(() => {
|
||||
if (!form.chapterId) return [];
|
||||
const ch = dialogChapters.find((c) => c.id === form.chapterId);
|
||||
return ch?.knowledgePoints ?? [];
|
||||
}, [form.chapterId, dialogChapters]);
|
||||
|
||||
// 筛选栏的章节列表(按筛选教材联动)
|
||||
const filterChapterOptions = useMemo(() => {
|
||||
if (filterTextbook === "all") return [];
|
||||
const tb = MOCK_TEXTBOOK_DETAILS.find((t) => t.id === filterTextbook);
|
||||
return tb?.chapters ?? [];
|
||||
}, [filterTextbook]);
|
||||
|
||||
const filterKpOptions = useMemo(() => {
|
||||
if (filterChapter === "all") return [];
|
||||
const ch = filterChapterOptions.find((c) => c.id === filterChapter);
|
||||
return ch?.knowledgePoints ?? [];
|
||||
}, [filterChapter, filterChapterOptions]);
|
||||
|
||||
// === GraphQL 查询 ===
|
||||
const filter: QuestionsLibraryFilter = useMemo(
|
||||
() => ({
|
||||
q: filterQ || null,
|
||||
type: filterType === "ALL" ? null : filterType,
|
||||
difficulty: filterDifficulty === "ALL" ? null : filterDifficulty,
|
||||
textbook: filterTextbook === "all" ? null : filterTextbook,
|
||||
chapter: filterChapter === "all" ? null : filterChapter,
|
||||
kp: filterKp === "all" ? null : filterKp,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
[
|
||||
filterQ,
|
||||
filterType,
|
||||
filterDifficulty,
|
||||
filterTextbook,
|
||||
filterChapter,
|
||||
filterKp,
|
||||
page,
|
||||
],
|
||||
);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: QuestionsLibraryQuery,
|
||||
variables: { filter },
|
||||
});
|
||||
|
||||
// === Mutations ===
|
||||
const [, createQuestion] = useMutation(QuestionCreateMutation);
|
||||
const [, updateQuestion] = useMutation(QuestionUpdateMutation);
|
||||
const [, deleteQuestion] = useMutation(QuestionDeleteMutation);
|
||||
const [, batchImport] = useMutation(QuestionBatchImportMutation);
|
||||
const [exportResult, batchExport] = useQuery({
|
||||
query: QuestionBatchExportQuery,
|
||||
variables: { filter },
|
||||
pause: true, // 按需触发
|
||||
});
|
||||
|
||||
const items: QuestionItem[] =
|
||||
(result.data?.questionsLibrary.items as QuestionItem[] | undefined) ?? [];
|
||||
const total = result.data?.questionsLibrary.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
// === 操作 ===
|
||||
const openCreate = useCallback(() => {
|
||||
setForm(emptyForm());
|
||||
setDialogError(null);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((q: QuestionItem) => {
|
||||
setForm(fillForm(q));
|
||||
setDialogError(null);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (q: QuestionItem) => {
|
||||
if (!window.confirm(`确认删除题目 ${q.questionId}?`)) return;
|
||||
const res = await deleteQuestion({ questionId: q.questionId });
|
||||
if (res.error) {
|
||||
window.alert(`删除失败:${res.error.message}`);
|
||||
}
|
||||
},
|
||||
[deleteQuestion],
|
||||
);
|
||||
|
||||
const submitForm = async () => {
|
||||
setDialogError(null);
|
||||
if (!form.content.trim()) {
|
||||
setDialogError("请填写题干");
|
||||
return;
|
||||
}
|
||||
if (!form.answer.trim()) {
|
||||
setDialogError("请填写答案");
|
||||
return;
|
||||
}
|
||||
setDialogSaving(true);
|
||||
if (form.questionId) {
|
||||
// 更新
|
||||
const input: QuestionUpdateInput = {
|
||||
questionId: form.questionId,
|
||||
content: form.content.trim(),
|
||||
type: form.type,
|
||||
difficulty: form.difficulty,
|
||||
textbookId: form.textbookId || null,
|
||||
chapterId: form.chapterId || null,
|
||||
kpId: form.kpId || null,
|
||||
score: Number(form.score) || 0,
|
||||
answer: form.answer.trim(),
|
||||
analysis: form.analysis.trim() || null,
|
||||
};
|
||||
const res = await updateQuestion({ input });
|
||||
setDialogSaving(false);
|
||||
if (res.error) {
|
||||
setDialogError(res.error.message);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 新建
|
||||
const input: QuestionCreateInput = {
|
||||
content: form.content.trim(),
|
||||
type: form.type,
|
||||
difficulty: form.difficulty,
|
||||
textbookId: form.textbookId || null,
|
||||
chapterId: form.chapterId || null,
|
||||
kpId: form.kpId || null,
|
||||
score: Number(form.score) || 0,
|
||||
answer: form.answer.trim(),
|
||||
analysis: form.analysis.trim() || null,
|
||||
};
|
||||
const res = await createQuestion({ input });
|
||||
setDialogSaving(false);
|
||||
if (res.error) {
|
||||
setDialogError(res.error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 批量导入:解析 JSON 文件
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImportMsg(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
setImportMsg("导入失败:JSON 根节点必须是数组");
|
||||
return;
|
||||
}
|
||||
const items: QuestionBatchImportItem[] = parsed
|
||||
.filter(
|
||||
(it): it is QuestionBatchImportItem =>
|
||||
typeof it === "object" && it !== null,
|
||||
)
|
||||
.map((it) => ({
|
||||
content: String(it.content ?? ""),
|
||||
type: (it.type as QuestionType) ?? "SINGLE_CHOICE",
|
||||
difficulty: (it.difficulty as QuestionDifficulty) ?? "MEDIUM",
|
||||
textbookId: it.textbookId ?? null,
|
||||
chapterId: it.chapterId ?? null,
|
||||
kpId: it.kpId ?? null,
|
||||
score: Number(it.score) || 0,
|
||||
answer: String(it.answer ?? ""),
|
||||
analysis: it.analysis ?? null,
|
||||
}));
|
||||
if (items.length === 0) {
|
||||
setImportMsg("导入失败:未解析到有效题目");
|
||||
return;
|
||||
}
|
||||
const res = await batchImport({ input: { items } });
|
||||
if (res.error) {
|
||||
setImportMsg(`导入失败:${res.error.message}`);
|
||||
return;
|
||||
}
|
||||
const data = res.data?.questionBatchImport;
|
||||
if (data) {
|
||||
setImportMsg(
|
||||
`导入完成:成功 ${data.importedCount} 题,失败 ${data.failedCount} 题`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setImportMsg(
|
||||
`导入失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
// 重置 input 以便重复选择同一文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 批量导出:触发查询后由 useEffect 监听结果变化下载 JSON
|
||||
const handleExport = () => {
|
||||
setExportMsg(null);
|
||||
setExportTriggered(true);
|
||||
batchExport();
|
||||
};
|
||||
|
||||
// 监听导出查询结果:数据到达后生成 Blob 下载
|
||||
useEffect(() => {
|
||||
if (!exportTriggered) return;
|
||||
const data = exportResult.data?.questionBatchExport;
|
||||
if (data) {
|
||||
const json = JSON.stringify(
|
||||
{
|
||||
exportedAt: data.exportedAt,
|
||||
count: data.items.length,
|
||||
items: data.items,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `questions-export-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setExportMsg(`已导出 ${data.items.length} 题`);
|
||||
setExportTriggered(false);
|
||||
}
|
||||
if (exportResult.error) {
|
||||
setExportMsg(`导出失败:${exportResult.error.message}`);
|
||||
setExportTriggered(false);
|
||||
}
|
||||
}, [exportTriggered, exportResult.data, exportResult.error]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilterQ("");
|
||||
setFilterType("ALL");
|
||||
setFilterDifficulty("ALL");
|
||||
setFilterTextbook("all");
|
||||
setFilterChapter("all");
|
||||
setFilterKp("all");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">题库</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL QuestionsLibraryQuery · content 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportClick}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
批量导入
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle"
|
||||
>
|
||||
批量导出
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建题目
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={filterQ}
|
||||
onChange={(e) => {
|
||||
setFilterQ(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="关键词(题干/知识点)"
|
||||
className="px-3 py-1.5 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => {
|
||||
setFilterType(e.target.value as QuestionType | "ALL");
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="ALL">全部类型</option>
|
||||
{QUESTION_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterDifficulty}
|
||||
onChange={(e) => {
|
||||
setFilterDifficulty(e.target.value as QuestionDifficulty | "ALL");
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="ALL">全部难度</option>
|
||||
{QUESTION_DIFFICULTY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterTextbook}
|
||||
onChange={(e) => {
|
||||
setFilterTextbook(e.target.value);
|
||||
setFilterChapter("all");
|
||||
setFilterKp("all");
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="all">全部教材</option>
|
||||
{MOCK_TEXTBOOKS.map((tb) => (
|
||||
<option key={tb.id} value={tb.id}>
|
||||
{tb.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterChapter}
|
||||
onChange={(e) => {
|
||||
setFilterChapter(e.target.value);
|
||||
setFilterKp("all");
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={filterChapterOptions.length === 0}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="all">全部章节</option>
|
||||
{filterChapterOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterKp}
|
||||
onChange={(e) => {
|
||||
setFilterKp(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={filterKpOptions.length === 0}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="all">全部知识点</option>
|
||||
{filterKpOptions.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
{importMsg && <span className="text-tiny text-ink-muted">{importMsg}</span>}
|
||||
{exportMsg && <span className="text-tiny text-success">{exportMsg}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 数据表 */}
|
||||
{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>
|
||||
) : items.length === 0 ? (
|
||||
<Empty
|
||||
title="暂无题目"
|
||||
description="调整筛选条件或新建题目"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建第一道题
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr className="border-b border-rule">
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
题目
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
类型
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
难度
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
教材-章节
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
分值
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((q) => (
|
||||
<tr
|
||||
key={q.questionId}
|
||||
className="border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<td className="py-2 px-3 text-ink max-w-md">
|
||||
<p className="line-clamp-2 whitespace-pre-line">
|
||||
{q.content.slice(0, 100)}
|
||||
{q.content.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-ink-muted">
|
||||
{TYPE_LABEL[q.type]}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-ink-muted">
|
||||
{DIFFICULTY_LABEL[q.difficulty]}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-ink-muted text-tiny">
|
||||
{q.textbookName ?? "-"}
|
||||
<br />
|
||||
{q.chapterName ?? "-"}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-ink">
|
||||
{q.score}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(q)}
|
||||
className="text-tiny font-mono text-accent hover:opacity-70 mr-3"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(q)}
|
||||
className="text-tiny font-mono text-danger hover:opacity-70"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-tiny text-ink-muted">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页(共 {total} 题)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑/新建弹窗 */}
|
||||
{dialogOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-paper/80"
|
||||
onClick={() => !dialogSaving && setDialogOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-paper border border-rule rounded-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">
|
||||
{form.questionId ? "编辑题目" : "新建题目"}
|
||||
</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="space-y-4">
|
||||
{/* 题干 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
题干 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.content}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, content: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="支持换行、选项等"
|
||||
/>
|
||||
</div>
|
||||
{/* 类型 + 难度 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
类型 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
type: e.target.value as QuestionType,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{QUESTION_TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
难度 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.difficulty}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
difficulty: e.target.value as QuestionDifficulty,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
{QUESTION_DIFFICULTY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 教材 / 章节 / 知识点 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
教材
|
||||
</label>
|
||||
<select
|
||||
value={form.textbookId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
textbookId: e.target.value,
|
||||
chapterId: "",
|
||||
kpId: "",
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{MOCK_TEXTBOOKS.map((tb) => (
|
||||
<option key={tb.id} value={tb.id}>
|
||||
{tb.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
章节
|
||||
</label>
|
||||
<select
|
||||
value={form.chapterId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
chapterId: e.target.value,
|
||||
kpId: "",
|
||||
}))
|
||||
}
|
||||
disabled={dialogChapters.length === 0}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{dialogChapters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
知识点
|
||||
</label>
|
||||
<select
|
||||
value={form.kpId}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, kpId: e.target.value }))
|
||||
}
|
||||
disabled={dialogKps.length === 0}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="">不关联</option>
|
||||
{dialogKps.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 分值 */}
|
||||
<div className="w-32">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
分值 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.score}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, score: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink font-mono focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* 答案 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
答案 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.answer}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, answer: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* 解析 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
解析
|
||||
</label>
|
||||
<textarea
|
||||
value={form.analysis}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, analysis: e.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* 错误提示 */}
|
||||
{dialogError && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{dialogError}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitForm}
|
||||
disabled={dialogSaving}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{dialogSaving
|
||||
? "保存中..."
|
||||
: form.questionId
|
||||
? "保存修改"
|
||||
: "创建题目"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={dialogSaving}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-tiny text-ink-muted">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回仪表盘
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
629
apps/teacher-portal/src/app/(app)/schedule-changes/page.tsx
Normal file
629
apps/teacher-portal/src/app/(app)/schedule-changes/page.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 调课申请页 - 教师调课申请管理
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL ScheduleChangesQuery:调课申请列表 + 分页
|
||||
* - GraphQL SubmitScheduleChangeMutation:提交调课/代课申请
|
||||
*
|
||||
* 功能:
|
||||
* - 状态筛选(全部/待审批/已批准/已拒绝)
|
||||
* - 类型筛选(全部/调课/代课)
|
||||
* - 提交调课申请模态框(原班级/原日期/原节次/调整后日期/类型/代课教师/原因)
|
||||
* - 状态徽章(待审批黄/已批准绿/已拒绝红)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { ClassesQuery } from "@/lib/graphql";
|
||||
import type { Class } from "@/lib/graphql";
|
||||
import {
|
||||
ScheduleChangesQuery,
|
||||
SubmitScheduleChangeMutation,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type {
|
||||
ScheduleChange,
|
||||
ScheduleChangeType,
|
||||
ScheduleChangeStatus,
|
||||
SubmitScheduleChangeInput,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import {
|
||||
SCHEDULE_CHANGE_TYPE_LABEL,
|
||||
SCHEDULE_CHANGE_STATUS_LABEL,
|
||||
} from "@/mocks/fixtures/leave-requests";
|
||||
|
||||
const TYPE_FILTERS: { value: ScheduleChangeType | "ALL"; label: string }[] = [
|
||||
{ value: "ALL", label: "全部" },
|
||||
{ value: "ADJUST", label: "调课" },
|
||||
{ value: "SUBSTITUTE", label: "代课" },
|
||||
];
|
||||
|
||||
const STATUS_FILTERS: {
|
||||
value: ScheduleChangeStatus | "ALL";
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "ALL", label: "全部" },
|
||||
{ value: "PENDING", label: "待审批" },
|
||||
{ value: "APPROVED", label: "已批准" },
|
||||
{ value: "REJECTED", label: "已拒绝" },
|
||||
];
|
||||
|
||||
const PERIOD_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
const SUBJECT_OPTIONS = [
|
||||
"数学",
|
||||
"语文",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
] as const;
|
||||
|
||||
function statusBadgeClass(status: ScheduleChangeStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-warning border-warning";
|
||||
case "APPROVED":
|
||||
return "text-success border-success";
|
||||
case "REJECTED":
|
||||
return "text-danger border-danger";
|
||||
default:
|
||||
return "text-ink-muted border-rule";
|
||||
}
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function dateOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function ScheduleChangesPage(): React.ReactNode {
|
||||
const [typeFilter, setTypeFilter] = useState<ScheduleChangeType | "ALL">(
|
||||
"ALL",
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState<
|
||||
ScheduleChangeStatus | "ALL"
|
||||
>("ALL");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// 提交对话框状态
|
||||
const [showSubmit, setShowSubmit] = useState(false);
|
||||
const [formClassId, setFormClassId] = useState("");
|
||||
const [formOriginalDate, setFormOriginalDate] = useState(todayStr());
|
||||
const [formOriginalPeriod, setFormOriginalPeriod] = useState(1);
|
||||
const [formOriginalSubject, setFormOriginalSubject] = useState<string>(
|
||||
SUBJECT_OPTIONS[0] ?? "数学",
|
||||
);
|
||||
const [formAdjustedDate, setFormAdjustedDate] = useState(dateOffset(1));
|
||||
const [formAdjustedPeriod, setFormAdjustedPeriod] = useState(1);
|
||||
const [formAdjustedSubject, setFormAdjustedSubject] = useState<string>(
|
||||
SUBJECT_OPTIONS[0] ?? "数学",
|
||||
);
|
||||
const [formType, setFormType] = useState<ScheduleChangeType>("ADJUST");
|
||||
const [formSubstituteTeacherId, setFormSubstituteTeacherId] = useState("");
|
||||
const [formReason, setFormReason] = useState("");
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSuccess, setFormSuccess] = useState<string | null>(null);
|
||||
|
||||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||||
const classes: Class[] = classesResult.data?.classes ?? [];
|
||||
const firstClassId = classes[0]?.id ?? "";
|
||||
|
||||
const [result, reexecute] = useQuery({
|
||||
query: ScheduleChangesQuery,
|
||||
variables: { page, pageSize },
|
||||
});
|
||||
|
||||
const [, submitChange] = useMutation(SubmitScheduleChangeMutation);
|
||||
|
||||
const data = result.data?.scheduleChanges;
|
||||
const allItems: ScheduleChange[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
// 客户端二次筛选(type/status)
|
||||
const filteredItems = useMemo(() => {
|
||||
return allItems.filter((item) => {
|
||||
if (typeFilter !== "ALL" && item.type !== typeFilter) return false;
|
||||
if (statusFilter !== "ALL" && item.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [allItems, typeFilter, statusFilter]);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
const targetClassId = formClassId || firstClassId;
|
||||
if (!targetClassId) {
|
||||
setFormError("请选择班级");
|
||||
return;
|
||||
}
|
||||
if (!formOriginalDate || !formAdjustedDate) {
|
||||
setFormError("请填写原日期和调整后日期");
|
||||
return;
|
||||
}
|
||||
if (!formReason.trim()) {
|
||||
setFormError("请填写调课原因");
|
||||
return;
|
||||
}
|
||||
setFormSubmitting(true);
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
const input: SubmitScheduleChangeInput = {
|
||||
classId: targetClassId,
|
||||
originalDate: formOriginalDate,
|
||||
originalPeriod: formOriginalPeriod,
|
||||
originalSubject: formOriginalSubject,
|
||||
adjustedDate: formAdjustedDate,
|
||||
adjustedPeriod: formAdjustedPeriod,
|
||||
adjustedSubject: formAdjustedSubject,
|
||||
substituteTeacherId: formSubstituteTeacherId.trim() || null,
|
||||
type: formType,
|
||||
reason: formReason.trim(),
|
||||
};
|
||||
const res = await submitChange({ input });
|
||||
setFormSubmitting(false);
|
||||
if (res.error) {
|
||||
setFormError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setFormSuccess(
|
||||
`已提交:${res.data?.submitScheduleChange?.requestId ?? "—"},等待审批`,
|
||||
);
|
||||
// 重置表单
|
||||
setFormReason("");
|
||||
reexecute({ requestPolicy: "network-only" });
|
||||
};
|
||||
|
||||
const closeSubmit = (): void => {
|
||||
setShowSubmit(false);
|
||||
setFormError(null);
|
||||
setFormSuccess(null);
|
||||
setFormReason("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">调课申请</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL ScheduleChangesQuery + SubmitScheduleChangeMutation(P7
|
||||
扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowSubmit(true);
|
||||
setFormSuccess(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
提交调课申请
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
类型:
|
||||
</label>
|
||||
{TYPE_FILTERS.map((t) => {
|
||||
const active = typeFilter === t.value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(t.value)}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
active
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态:
|
||||
</label>
|
||||
{STATUS_FILTERS.map((s) => {
|
||||
const active = statusFilter === s.value;
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s.value)}
|
||||
className={`px-3 py-1 text-tiny uppercase tracking-wide rounded-button border transition-colors ${
|
||||
active
|
||||
? "bg-accent text-ink-on-accent border-accent"
|
||||
: "bg-transparent text-ink-muted border-rule hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result.fetching ? (
|
||||
<Loading lines={5} />
|
||||
) : 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>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<Empty title="暂无调课申请" description="当前筛选条件下没有记录" />
|
||||
) : (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h2 className="text-xl font-serif text-ink">
|
||||
申请列表
|
||||
<span className="ml-2 text-sm font-sans text-ink-muted">
|
||||
共 {filteredItems.length} 条(总计 {total})
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
申请人
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
班级
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
原课程
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
调整后
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
类型
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
原因
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-tiny uppercase tracking-wide text-ink-muted">
|
||||
状态
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((r) => (
|
||||
<tr key={r.id} className="border-t border-rule">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-serif text-ink">{r.requesterName}</p>
|
||||
{r.substituteTeacherName && (
|
||||
<p className="text-tiny text-ink-muted">
|
||||
代课:{r.substituteTeacherName}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-ink">
|
||||
{r.className}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
<p>{r.originalDate}</p>
|
||||
<p>
|
||||
第 {r.originalPeriod} 节 · {r.originalSubject}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-tiny text-ink-muted">
|
||||
<p>{r.adjustedDate}</p>
|
||||
<p>
|
||||
第 {r.adjustedPeriod} 节 · {r.adjustedSubject}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-ink">
|
||||
{SCHEDULE_CHANGE_TYPE_LABEL[r.type]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-tiny text-ink-muted max-w-xs">
|
||||
<p className="truncate">{r.reason}</p>
|
||||
{r.reviewComment && (
|
||||
<p className="mt-1 text-tiny text-ink-subtle">
|
||||
审批:{r.reviewComment}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-button text-tiny font-mono border ${statusBadgeClass(
|
||||
r.status,
|
||||
)}`}
|
||||
>
|
||||
{SCHEDULE_CHANGE_STATUS_LABEL[r.status]}
|
||||
</span>
|
||||
{r.reviewedAt && (
|
||||
<p className="mt-1 text-tiny font-mono text-ink-muted">
|
||||
{r.reviewedAt.slice(0, 10)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
第 {page} 页(每页 {pageSize} 条)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={allItems.length < pageSize}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 提交调课对话框 */}
|
||||
{showSubmit && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "hsl(var(--color-ink) / 0.3)" }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 bg-paper border border-rule rounded-card max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<h3 className="text-xl font-serif text-ink">提交调课申请</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSubmit}
|
||||
className="text-tiny text-ink-muted hover:opacity-70"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-thin mb-4" />
|
||||
{formSuccess ? (
|
||||
<div className="mb-4 p-4 border border-rule rounded-card bg-subtle">
|
||||
<p className="text-sm text-success">{formSuccess}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormSuccess(null)}
|
||||
className="mt-2 text-tiny text-accent hover:opacity-70"
|
||||
>
|
||||
再提交一条
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 班级 + 类型 */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
班级
|
||||
</label>
|
||||
<select
|
||||
value={formClassId || firstClassId}
|
||||
onChange={(e) => setFormClassId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{classes.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
类型
|
||||
</label>
|
||||
<select
|
||||
value={formType}
|
||||
onChange={(e) =>
|
||||
setFormType(e.target.value as ScheduleChangeType)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="ADJUST">调课</option>
|
||||
<option value="SUBSTITUTE">代课</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 原课程 */}
|
||||
<div className="mb-4 p-4 border border-rule rounded-card bg-surface">
|
||||
<p className="mb-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
原课程
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formOriginalDate}
|
||||
onChange={(e) => setFormOriginalDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
节次
|
||||
</label>
|
||||
<select
|
||||
value={formOriginalPeriod}
|
||||
onChange={(e) =>
|
||||
setFormOriginalPeriod(Number(e.target.value))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{PERIOD_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
第 {p} 节
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
学科
|
||||
</label>
|
||||
<select
|
||||
value={formOriginalSubject}
|
||||
onChange={(e) => setFormOriginalSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 调整后 */}
|
||||
<div className="mb-4 p-4 border border-rule rounded-card bg-surface">
|
||||
<p className="mb-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
调整后
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formAdjustedDate}
|
||||
onChange={(e) => setFormAdjustedDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
节次
|
||||
</label>
|
||||
<select
|
||||
value={formAdjustedPeriod}
|
||||
onChange={(e) =>
|
||||
setFormAdjustedPeriod(Number(e.target.value))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{PERIOD_OPTIONS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
第 {p} 节
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
学科
|
||||
</label>
|
||||
<select
|
||||
value={formAdjustedSubject}
|
||||
onChange={(e) => setFormAdjustedSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-sans text-ink focus:outline-none focus:border-accent"
|
||||
>
|
||||
{SUBJECT_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{formType === "SUBSTITUTE" && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-tiny text-ink-muted mb-1">
|
||||
代课教师 ID(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formSubstituteTeacherId}
|
||||
onChange={(e) =>
|
||||
setFormSubstituteTeacherId(e.target.value)
|
||||
}
|
||||
placeholder="例如:u-002"
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm font-mono text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 原因 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
调课原因
|
||||
</label>
|
||||
<textarea
|
||||
value={formReason}
|
||||
onChange={(e) => setFormReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="请填写调课原因"
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{formError && (
|
||||
<p className="mb-3 text-tiny text-danger">{formError}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeSubmit}
|
||||
disabled={formSubmitting}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button hover:bg-subtle disabled:opacity-50"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={formSubmitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{formSubmitting ? "提交中..." : "提交申请"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,97 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings 页面 - 个人设置
|
||||
* Settings 页面 - 个人设置(P3 启用编辑模式)
|
||||
*
|
||||
* 数据来源(ARB-001 §1.2):GraphQL MeQuery(只读展示)
|
||||
* - P2 阶段仅展示当前用户信息(P2 纯读,无 Mutation)
|
||||
* - P3+ 启用 UpdateUserMutation 后开启编辑功能
|
||||
* 数据来源(ARB-001 §1.2):
|
||||
* - GraphQL MeQuery(读取当前用户信息)
|
||||
* - GraphQL UpdateUserMutation(P3 扩展,MSW mock)更新姓名/邮箱
|
||||
* - 编辑按钮切换只读/编辑模式
|
||||
* - 乐观更新:本地立即展示新值;失败回滚由错误提示
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useQuery } from "urql";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import { Loading } from "@edu/ui-components";
|
||||
import { MeQuery } from "@/lib/graphql";
|
||||
import { MeQuery, UpdateUserMutation } from "@/lib/graphql";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [result] = useQuery({ query: MeQuery });
|
||||
const [result, reexecuteQuery] = useQuery({ query: MeQuery });
|
||||
const [updateResult, updateUser] = useMutation(UpdateUserMutation);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 进入编辑模式时用当前用户数据预填表单
|
||||
useEffect(() => {
|
||||
if (editing && result.data?.me) {
|
||||
setName(result.data.me.name);
|
||||
setEmail(result.data.me.email);
|
||||
setError(null);
|
||||
}
|
||||
}, [editing, result.data?.me]);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("姓名不能为空");
|
||||
return;
|
||||
}
|
||||
if (!email.trim() || !email.includes("@")) {
|
||||
setError("邮箱格式不正确");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
// 乐观更新:本地立即显示新值(更新成功后 reexecuteQuery 拉取最新数据)
|
||||
const res = await updateUser({
|
||||
input: { name: name.trim(), email: email.trim() },
|
||||
});
|
||||
setSubmitting(false);
|
||||
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
// 成功后重新拉取 me 刷新展示
|
||||
reexecuteQuery({ requestPolicy: "network-only" });
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
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 MeQuery · P2 只读展示(P3 启用编辑)
|
||||
</p>
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">个人设置</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL MeQuery + UpdateUserMutation · P3 编辑模式
|
||||
</p>
|
||||
</div>
|
||||
{!editing && result.data?.me && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartEdit}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
@@ -38,6 +106,66 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
) : !result.data?.me ? (
|
||||
<p className="text-sm italic text-ink-muted">暂无用户信息</p>
|
||||
) : editing ? (
|
||||
<section className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-serif text-ink mb-2">编辑个人信息</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
姓名 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
邮箱 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border-b border-rule text-sm text-ink focus:outline-none focus:border-b-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "保存中..." : "保存"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{updateResult.data && !error && !submitting && (
|
||||
<span className="text-tiny text-success">保存成功</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
) : (
|
||||
<section className="max-w-2xl">
|
||||
<div className="mb-6">
|
||||
@@ -89,7 +217,7 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="mt-8 p-4 border border-rule rounded-card bg-subtle">
|
||||
<p className="text-sm text-ink-muted">
|
||||
P2 阶段为只读模式,编辑功能(updateUser Mutation)将在 P3 启用。
|
||||
P3 已启用编辑功能(UpdateUserMutation)。点击右上角"编辑"按钮修改姓名与邮箱。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
454
apps/teacher-portal/src/app/(app)/textbooks/[id]/page.tsx
Normal file
454
apps/teacher-portal/src/app/(app)/textbooks/[id]/page.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材阅读器 - 章节树 + 内容 + 阅读设置
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL TextbookDetailQuery:教材详情(章节树 + 知识点列表)
|
||||
*
|
||||
* 布局:左侧章节树(可展开/折叠)+ 中间章节内容(简单 markdown 渲染)+ 右侧设置面板
|
||||
* 导航:"上一章"/"下一章" 按钮切换章节
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useQuery } from "urql";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { TextbookDetailQuery } from "@/lib/graphql-p7-exams";
|
||||
import type { TextbookChapter, TextbookDetail } from "@/lib/graphql-p7-exams";
|
||||
|
||||
/** 阅读设置 */
|
||||
interface ReaderSettings {
|
||||
fontSize: "small" | "medium" | "large";
|
||||
lineHeight: "compact" | "normal" | "loose";
|
||||
theme: "default" | "warm";
|
||||
}
|
||||
|
||||
/** 字号映射到 Tailwind class */
|
||||
const FONT_SIZE_CLASS: Record<ReaderSettings["fontSize"], string> = {
|
||||
small: "text-sm",
|
||||
medium: "text-base",
|
||||
large: "text-lg",
|
||||
};
|
||||
|
||||
/** 行高映射到 Tailwind class */
|
||||
const LINE_HEIGHT_CLASS: Record<ReaderSettings["lineHeight"], string> = {
|
||||
compact: "leading-tight",
|
||||
normal: "leading-normal",
|
||||
loose: "leading-loose",
|
||||
};
|
||||
|
||||
/** 主题映射到 Tailwind class */
|
||||
const THEME_CLASS: Record<ReaderSettings["theme"], string> = {
|
||||
default: "bg-paper text-ink",
|
||||
warm: "bg-subtle text-ink",
|
||||
};
|
||||
|
||||
/** 默认设置 */
|
||||
const DEFAULT_SETTINGS: ReaderSettings = {
|
||||
fontSize: "medium",
|
||||
lineHeight: "normal",
|
||||
theme: "default",
|
||||
};
|
||||
|
||||
/** 渲染简单 markdown:## 标题 + 段落 */
|
||||
function renderChapterContent(
|
||||
content: string,
|
||||
fontSizeClass: string,
|
||||
lineHeightClass: string,
|
||||
): React.ReactNode {
|
||||
const lines = content.split("\n");
|
||||
const blocks: React.ReactNode[] = [];
|
||||
let currentHeading: string | null = null;
|
||||
let currentParagraph: string[] = [];
|
||||
|
||||
const flushParagraph = (key: string) => {
|
||||
if (currentParagraph.length > 0) {
|
||||
blocks.push(
|
||||
<p
|
||||
key={`p-${key}`}
|
||||
className={`mb-4 ${fontSizeClass} ${lineHeightClass}`}
|
||||
>
|
||||
{currentParagraph.join(" ")}
|
||||
</p>,
|
||||
);
|
||||
currentParagraph = [];
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("# ")) {
|
||||
// H1:章节主标题(已在 header 展示,跳过)
|
||||
flushParagraph(`f${i}`);
|
||||
} else if (trimmed.startsWith("## ")) {
|
||||
// H2:小节标题
|
||||
flushParagraph(`f${i}`);
|
||||
currentHeading = trimmed.slice(3);
|
||||
blocks.push(
|
||||
<h3
|
||||
key={`h-${i}`}
|
||||
className={`mt-6 mb-3 font-serif text-ink ${fontSizeClass === "text-sm" ? "text-base" : fontSizeClass === "text-base" ? "text-lg" : "text-xl"}`}
|
||||
>
|
||||
{currentHeading}
|
||||
</h3>,
|
||||
);
|
||||
currentHeading = null;
|
||||
} else if (trimmed === "") {
|
||||
// 空行:段落分隔
|
||||
flushParagraph(`f${i}`);
|
||||
} else {
|
||||
currentParagraph.push(trimmed);
|
||||
}
|
||||
});
|
||||
flushParagraph("f-end");
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export default function TextbookReaderPage(): React.ReactNode {
|
||||
const params = useParams<{ id: string }>();
|
||||
const textbookId = params?.id ?? "";
|
||||
|
||||
const [result] = useQuery({
|
||||
query: TextbookDetailQuery,
|
||||
variables: { id: textbookId },
|
||||
pause: !textbookId,
|
||||
});
|
||||
|
||||
const textbook: TextbookDetail | undefined =
|
||||
(result.data?.textbookDetail as TextbookDetail | undefined) ?? undefined;
|
||||
|
||||
// === 阅读状态 ===
|
||||
const [currentChapterIdx, setCurrentChapterIdx] = useState(0);
|
||||
const [expandedChapters, setExpandedChapters] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const chapters: TextbookChapter[] = textbook?.chapters ?? [];
|
||||
|
||||
// 当前章节(安全访问)
|
||||
const currentChapter: TextbookChapter | null =
|
||||
chapters[currentChapterIdx] ?? null;
|
||||
|
||||
// 章节切换
|
||||
const goToChapter = useCallback(
|
||||
(idx: number) => {
|
||||
if (idx >= 0 && idx < chapters.length) {
|
||||
setCurrentChapterIdx(idx);
|
||||
}
|
||||
},
|
||||
[chapters.length],
|
||||
);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentChapterIdx((i) => Math.max(0, i - 1));
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentChapterIdx((i) => Math.min(chapters.length - 1, i + 1));
|
||||
}, [chapters.length]);
|
||||
|
||||
// 展开/折叠章节
|
||||
const toggleChapter = useCallback((chapterId: string) => {
|
||||
setExpandedChapters((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(chapterId)) {
|
||||
next.delete(chapterId);
|
||||
} else {
|
||||
next.add(chapterId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 当前章节内容渲染
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!currentChapter) return null;
|
||||
return renderChapterContent(
|
||||
currentChapter.content,
|
||||
FONT_SIZE_CLASS[settings.fontSize],
|
||||
LINE_HEIGHT_CLASS[settings.lineHeight],
|
||||
);
|
||||
}, [currentChapter, settings.fontSize, settings.lineHeight]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-paper">
|
||||
{/* 左侧:章节树 */}
|
||||
<aside className="w-64 flex-shrink-0 border-r border-rule bg-paper flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-6 flex-shrink-0">
|
||||
<Link
|
||||
href="/textbooks"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回教材列表
|
||||
</Link>
|
||||
<h1 className="mt-2 text-lg font-serif text-ink">
|
||||
{textbook?.title ?? "加载中..."}
|
||||
</h1>
|
||||
<p className="mt-1 text-tiny text-ink-muted">
|
||||
{textbook?.subject} · {textbook?.grade} · {textbook?.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rule-thin mx-6" />
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{result.fetching ? (
|
||||
<Loading lines={4} />
|
||||
) : chapters.length === 0 ? (
|
||||
<p className="px-3 py-2 text-tiny text-ink-muted">暂无章节</p>
|
||||
) : (
|
||||
chapters.map((ch, idx) => {
|
||||
const isExpanded = expandedChapters.has(ch.id);
|
||||
const isActive = idx === currentChapterIdx;
|
||||
return (
|
||||
<div key={ch.id} className="mb-1">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleChapter(ch.id)}
|
||||
className="flex-shrink-0 px-1 py-1 text-tiny text-ink-muted hover:opacity-70"
|
||||
aria-label={isExpanded ? "折叠" : "展开"}
|
||||
>
|
||||
{isExpanded ? "▾" : "▸"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToChapter(idx)}
|
||||
className={`flex-1 text-left px-2 py-1.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "text-accent border-l-2 border-accent font-serif"
|
||||
: "text-ink border-l-2 border-transparent font-sans hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{ch.title}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && ch.knowledgePoints.length > 0 && (
|
||||
<ul className="ml-6 mt-1 mb-2 space-y-1">
|
||||
{ch.knowledgePoints.map((kp) => (
|
||||
<li
|
||||
key={kp.id}
|
||||
className="text-tiny text-ink-muted px-2 py-0.5"
|
||||
>
|
||||
{kp.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 中间:章节内容 */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{result.fetching ? (
|
||||
<div className="px-10 py-10">
|
||||
<Loading lines={8} />
|
||||
</div>
|
||||
) : result.error ? (
|
||||
<div className="px-10 py-10">
|
||||
<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>
|
||||
</div>
|
||||
) : !textbook ? (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="教材不存在" description="可能已被删除或链接无效" />
|
||||
</div>
|
||||
) : !currentChapter ? (
|
||||
<div className="px-10 py-10">
|
||||
<Empty title="暂无章节" description="该教材尚未添加章节内容" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-10 py-10 max-w-3xl mx-auto">
|
||||
{/* 章节标题 */}
|
||||
<header className="mb-8">
|
||||
<p className="text-tiny uppercase tracking-wide text-ink-muted">
|
||||
第 {currentChapterIdx + 1} / {chapters.length} 章
|
||||
</p>
|
||||
<h2 className="mt-1 text-3xl font-serif text-ink">
|
||||
{currentChapter.title}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 章节正文 */}
|
||||
<article
|
||||
className={`prose-sm rounded-card p-6 ${THEME_CLASS[settings.theme]}`}
|
||||
>
|
||||
{renderedContent}
|
||||
</article>
|
||||
|
||||
{/* 知识点列表 */}
|
||||
{currentChapter.knowledgePoints.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<h3 className="text-base font-serif text-ink mb-3">
|
||||
本章知识点
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{currentChapter.knowledgePoints.map((kp, i) => (
|
||||
<li
|
||||
key={kp.id}
|
||||
className="flex items-baseline gap-2 text-sm text-ink"
|
||||
>
|
||||
<span className="font-mono text-tiny text-ink-muted">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span>{kp.name}</span>
|
||||
{kp.description && (
|
||||
<span className="text-tiny text-ink-muted">
|
||||
— {kp.description}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<nav className="mt-10 flex items-center justify-between border-t border-rule pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={currentChapterIdx <= 0}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
|
||||
>
|
||||
← 上一章
|
||||
</button>
|
||||
<span className="text-tiny text-ink-muted">
|
||||
{currentChapterIdx + 1} / {chapters.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={currentChapterIdx >= chapters.length - 1}
|
||||
className="px-4 py-2 text-sm text-ink border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
|
||||
>
|
||||
下一章 →
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 右侧:设置面板(可切换显示/隐藏) */}
|
||||
<aside
|
||||
className={`w-56 flex-shrink-0 border-l border-rule bg-paper flex flex-col transition-all ${
|
||||
settingsOpen ? "opacity-100" : "opacity-0 pointer-events-none w-0"
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-6 flex-shrink-0">
|
||||
<h2 className="text-base font-serif text-ink">阅读设置</h2>
|
||||
</div>
|
||||
<div className="rule-thin mx-6" />
|
||||
<div className="flex-1 px-6 py-4 space-y-6 overflow-y-auto">
|
||||
{/* 字体大小 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
|
||||
字体大小
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["small", "medium", "large"] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSettings((s) => ({ ...s, fontSize: size }))
|
||||
}
|
||||
className={`px-3 py-1 text-tiny rounded-button border ${
|
||||
settings.fontSize === size
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-muted hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{size === "small" ? "小" : size === "medium" ? "中" : "大"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行高 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
|
||||
行高
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["compact", "normal", "loose"] as const).map((lh) => (
|
||||
<button
|
||||
key={lh}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSettings((s) => ({ ...s, lineHeight: lh }))
|
||||
}
|
||||
className={`px-3 py-1 text-tiny rounded-button border ${
|
||||
settings.lineHeight === lh
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-muted hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{lh === "compact" ? "紧凑" : lh === "normal" ? "正常" : "宽松"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主题 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-2">
|
||||
主题
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["default", "warm"] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
onClick={() => setSettings((s) => ({ ...s, theme }))}
|
||||
className={`px-3 py-1 text-tiny rounded-button border ${
|
||||
settings.theme === theme
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-muted hover:bg-subtle"
|
||||
}`}
|
||||
>
|
||||
{theme === "default" ? "默认" : "护眼"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重置 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettings(DEFAULT_SETTINGS)}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
重置为默认
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 设置开关按钮(固定在右边缘) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen((v) => !v)}
|
||||
className="fixed right-0 top-1/2 -translate-y-1/2 px-2 py-3 bg-accent text-ink-on-accent text-tiny writing-mode-vertical rounded-l-button hover:bg-accent-hover"
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
aria-label="阅读设置"
|
||||
>
|
||||
{settingsOpen ? "收起设置" : "展开设置"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
478
apps/teacher-portal/src/app/(app)/textbooks/page.tsx
Normal file
478
apps/teacher-portal/src/app/(app)/textbooks/page.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 教材列表 - 筛选 + 新建 + 表格 + 分页
|
||||
*
|
||||
* 数据来源(P7 扩展,MSW mock):
|
||||
* - GraphQL TextbooksQuery:教材列表(q/subject/grade 筛选 + 分页 + 总数)
|
||||
* - GraphQL TextbookCreateMutation:新建教材
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useQuery, useMutation } from "urql";
|
||||
import Link from "next/link";
|
||||
import { Loading, Empty } from "@edu/ui-components";
|
||||
import { TextbooksQuery, TextbookCreateMutation } from "@/lib/graphql-p7-exams";
|
||||
import type {
|
||||
TextbookItem,
|
||||
TextbooksFilter,
|
||||
TextbookCreateInput,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import {
|
||||
TEXTBOOK_SUBJECTS,
|
||||
TEXTBOOK_GRADES,
|
||||
TEXTBOOK_VERSIONS,
|
||||
} from "@/mocks/fixtures/textbooks";
|
||||
|
||||
/** 新建教材弹窗的表单状态 */
|
||||
interface TextbookFormState {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
version: string;
|
||||
coverUrl: string;
|
||||
}
|
||||
|
||||
/** 初始空表单 */
|
||||
function emptyForm(): TextbookFormState {
|
||||
return {
|
||||
title: "",
|
||||
subject: "",
|
||||
grade: "",
|
||||
version: "",
|
||||
coverUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function TextbooksPage(): React.ReactNode {
|
||||
// === 筛选条件 ===
|
||||
const [filterQ, setFilterQ] = useState("");
|
||||
const [filterSubject, setFilterSubject] = useState("all");
|
||||
const [filterGrade, setFilterGrade] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
// === 新建弹窗状态 ===
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<TextbookFormState>(emptyForm());
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
const [dialogSaving, setDialogSaving] = useState(false);
|
||||
|
||||
// === GraphQL 查询 ===
|
||||
const filter: TextbooksFilter = useMemo(
|
||||
() => ({
|
||||
q: filterQ || null,
|
||||
subject: filterSubject === "all" ? null : filterSubject,
|
||||
grade: filterGrade === "all" ? null : filterGrade,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
[filterQ, filterSubject, filterGrade, page],
|
||||
);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: TextbooksQuery,
|
||||
variables: { filter },
|
||||
});
|
||||
|
||||
// === Mutations ===
|
||||
const [, createTextbook] = useMutation(TextbookCreateMutation);
|
||||
|
||||
const items: TextbookItem[] =
|
||||
(result.data?.textbooks.items as TextbookItem[] | undefined) ?? [];
|
||||
const total = result.data?.textbooks.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
// === 操作 ===
|
||||
const openCreate = useCallback(() => {
|
||||
setForm(emptyForm());
|
||||
setDialogError(null);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const submitForm = async () => {
|
||||
setDialogError(null);
|
||||
if (!form.title.trim()) {
|
||||
setDialogError("请填写教材名称");
|
||||
return;
|
||||
}
|
||||
if (!form.subject) {
|
||||
setDialogError("请选择学科");
|
||||
return;
|
||||
}
|
||||
if (!form.grade) {
|
||||
setDialogError("请选择年级");
|
||||
return;
|
||||
}
|
||||
if (!form.version) {
|
||||
setDialogError("请选择版本");
|
||||
return;
|
||||
}
|
||||
setDialogSaving(true);
|
||||
const input: TextbookCreateInput = {
|
||||
title: form.title.trim(),
|
||||
subject: form.subject,
|
||||
grade: form.grade,
|
||||
version: form.version,
|
||||
coverUrl: form.coverUrl.trim() || null,
|
||||
};
|
||||
const res = await createTextbook({ input });
|
||||
setDialogSaving(false);
|
||||
if (res.error) {
|
||||
setDialogError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilterQ("");
|
||||
setFilterSubject("all");
|
||||
setFilterGrade("all");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-10 py-10">
|
||||
<header className="mb-8 flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-serif text-ink">教材</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">
|
||||
GraphQL TextbooksQuery · content 域(P7 扩展,MSW mock)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建教材
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="rule-thin mb-8" />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<section className="mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={filterQ}
|
||||
onChange={(e) => {
|
||||
setFilterQ(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="教材名称关键词"
|
||||
className="px-3 py-1.5 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={filterSubject}
|
||||
onChange={(e) => {
|
||||
setFilterSubject(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="all">全部学科</option>
|
||||
{TEXTBOOK_SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterGrade}
|
||||
onChange={(e) => {
|
||||
setFilterGrade(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-2 py-1.5 bg-transparent border border-rule rounded-button text-tiny text-ink focus:outline-none"
|
||||
>
|
||||
<option value="all">全部年级</option>
|
||||
{TEXTBOOK_GRADES.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70 text-left"
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 数据表 */}
|
||||
{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>
|
||||
) : items.length === 0 ? (
|
||||
<Empty
|
||||
title="暂无教材"
|
||||
description="调整筛选条件或新建教材"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
新建第一本教材
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="border border-rule rounded-card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr className="border-b border-rule">
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
教材名
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
学科
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
年级
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
版本
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
章节数
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((tb) => (
|
||||
<tr
|
||||
key={tb.id}
|
||||
className="border-b border-rule hover:bg-subtle"
|
||||
>
|
||||
<td className="py-2 px-3 text-ink">
|
||||
{tb.title}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-ink-muted">{tb.subject}</td>
|
||||
<td className="py-2 px-3 text-ink-muted">{tb.grade}</td>
|
||||
<td className="py-2 px-3 text-ink-muted">{tb.version}</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-ink">
|
||||
{tb.chapterCount}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<Link
|
||||
href={`/textbooks/${tb.id}`}
|
||||
className="text-tiny font-mono text-accent hover:opacity-70 mr-3"
|
||||
>
|
||||
阅读
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.alert("教材编辑暂未实现(P7 mock)")
|
||||
}
|
||||
className="text-tiny font-mono text-ink-muted hover:opacity-70 mr-3"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.alert("教材删除暂未实现(P7 mock)")
|
||||
}
|
||||
className="text-tiny font-mono text-danger hover:opacity-70"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-tiny text-ink-muted">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页(共 {total} 本)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 border border-rule rounded-button disabled:opacity-30 hover:bg-subtle"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建教材弹窗 */}
|
||||
{dialogOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-paper/80"
|
||||
onClick={() => !dialogSaving && setDialogOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-paper border border-rule rounded-card p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-serif text-ink mb-4">新建教材</h2>
|
||||
<div className="rule-thin mb-4" />
|
||||
<div className="space-y-4">
|
||||
{/* 教材名称 */}
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
教材名称 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, title: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="如:高中数学必修一"
|
||||
/>
|
||||
</div>
|
||||
{/* 学科 + 年级 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
学科 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.subject}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, subject: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{TEXTBOOK_SUBJECTS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
年级 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.grade}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, grade: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{TEXTBOOK_GRADES.map((g) => (
|
||||
<option key={g} value={g}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 版本 + 封面 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
版本 <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.version}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, version: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{TEXTBOOK_VERSIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-tiny uppercase tracking-wide text-ink-muted mb-1">
|
||||
封面 URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.coverUrl}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, coverUrl: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-transparent border border-rule rounded-button text-sm text-ink focus:outline-none"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 错误提示 */}
|
||||
{dialogError && (
|
||||
<div className="mark-left py-2 border-l-2 border-danger pl-md">
|
||||
<p className="text-sm px-3 text-danger">{dialogError}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitForm}
|
||||
disabled={dialogSaving}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{dialogSaving ? "保存中..." : "创建教材"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={dialogSaving}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-tiny text-ink-muted">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
← 返回仪表盘
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
|
||||
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import { GraphQLProvider } from "./providers";
|
||||
import { ErrorBoundary } from "@edu/ui-components";
|
||||
import { ObservabilityProvider } from "@/components/observability-provider";
|
||||
|
||||
/**
|
||||
* 字体加载(next/font/google self-host)
|
||||
@@ -48,9 +49,11 @@ export default function RootLayout({
|
||||
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
|
||||
>
|
||||
<body>
|
||||
<ErrorBoundary>
|
||||
<GraphQLProvider>{children}</GraphQLProvider>
|
||||
</ErrorBoundary>
|
||||
<ObservabilityProvider>
|
||||
<ErrorBoundary>
|
||||
<GraphQLProvider>{children}</GraphQLProvider>
|
||||
</ErrorBoundary>
|
||||
</ObservabilityProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,10 @@ import { usePermission } from "@edu/hooks";
|
||||
import { ViewportsQuery, MeQuery } from "@/lib/graphql";
|
||||
import type { ViewportItem, User } from "@/lib/graphql";
|
||||
import { getToken, getUser, logout } from "@/lib/auth";
|
||||
import {
|
||||
useCrossTabSync,
|
||||
broadcastCrossTabEvent,
|
||||
} from "@/hooks/use-cross-tab-sync";
|
||||
|
||||
/**
|
||||
* 权限上下文:从 localStorage 读取(登录时由 iam 返回并存储)。
|
||||
@@ -54,6 +58,9 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// 多 Tab 会话同步:监听 logout / role-change / token-refresh 事件
|
||||
useCrossTabSync();
|
||||
|
||||
// GraphQL: viewports query(替代 REST /api/v1/teacher/viewports)
|
||||
const [viewportsResult] = useQuery({
|
||||
query: ViewportsQuery,
|
||||
@@ -95,6 +102,12 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
// 退出登录:先广播给其他 Tab,再执行本地登出
|
||||
const handleLogout = () => {
|
||||
broadcastCrossTabEvent("logout");
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-paper">
|
||||
{/* 左侧栏:导航树 */}
|
||||
@@ -140,7 +153,7 @@ export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
onClick={handleLogout}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
>
|
||||
退出登录
|
||||
|
||||
90
apps/teacher-portal/src/components/ParentPortalRemote.tsx
Normal file
90
apps/teacher-portal/src/components/ParentPortalRemote.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ParentPortalRemote - parent-portal MF Remote 接入组件
|
||||
*
|
||||
* 职责(ARB-002 §2.3):
|
||||
* - NEXT_PUBLIC_MF_ENABLED=true:通过 Module Federation 动态加载 parent-portal Remote
|
||||
* - NEXT_PUBLIC_MF_ENABLED=false:展示"需启用微前端模式"提示
|
||||
* - 使用 next/dynamic 懒加载(ssr: false,Remote 仅 CSR)
|
||||
* - ErrorBoundary 兜底:Remote 加载/渲染失败时降级展示
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { ErrorBoundary } from "@edu/ui-components";
|
||||
|
||||
const MF_ENABLED = process.env.NEXT_PUBLIC_MF_ENABLED === "true";
|
||||
|
||||
/** 动态加载 parent-portal Remote(仅 CSR,加载失败返回降级组件) */
|
||||
const ParentRemote = dynamic(
|
||||
() =>
|
||||
import("parent/ParentApp")
|
||||
.then((mod) => mod.default)
|
||||
.catch(() => FallbackRemote),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<p className="text-sm text-ink-muted p-6">正在加载家长端视图…</p>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/** Remote 加载失败时的降级组件 */
|
||||
function FallbackRemote(): React.ReactNode {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-lg font-serif text-ink">家长端视图加载失败</p>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
请确认 parent-portal 服务已启动(端口 4002)并已暴露 ParentApp
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MF 未启用时的提示 */
|
||||
function MfDisabledNotice(): React.ReactNode {
|
||||
return (
|
||||
<div className="p-8 border border-rule rounded-card bg-surface text-center">
|
||||
<p className="text-lg font-serif text-ink">
|
||||
家长端视图需启用微前端模式
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-ink-muted">
|
||||
设置环境变量 NEXT_PUBLIC_MF_ENABLED=true 后重启服务,
|
||||
并确保 parent-portal 已在 next.config.js remotes 中注册。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ParentPortalRemote(): React.ReactNode {
|
||||
if (!MF_ENABLED) {
|
||||
return <MfDisabledNotice />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-4 p-8"
|
||||
>
|
||||
<h2 className="text-lg font-serif text-ink">
|
||||
家长端视图渲染异常
|
||||
</h2>
|
||||
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="px-4 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ParentRemote />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 可观测性 Provider 组件(P6 硬化)
|
||||
*
|
||||
* 整合 Sentry + Web Vitals + OTel 初始化,在应用启动时调用各 init 函数。
|
||||
* - "use client" 客户端组件
|
||||
* - 在 useEffect 中按需初始化各可观测性模块(动态 import 避免未安装包问题)
|
||||
* - 不渲染任何可见 UI(返回 children)
|
||||
* - 条件初始化:根据环境变量决定是否启用各模块
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / project_rules §12 可观测性规范
|
||||
*/
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* 可观测性 Provider。
|
||||
*
|
||||
* 在客户端挂载时初始化 Sentry / Web Vitals / OTel。
|
||||
* 所有初始化均为条件性:未配置对应环境变量时跳过,未安装包时降级。
|
||||
*
|
||||
* 不渲染任何可见 UI,仅透传 children。
|
||||
*/
|
||||
export function ObservabilityProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
useEffect(() => {
|
||||
// 并行初始化各可观测性模块(互不依赖)
|
||||
const initObservability = async (): Promise<void> => {
|
||||
// 1. Sentry 错误追踪(条件:NEXT_PUBLIC_SENTRY_DSN 配置时)
|
||||
try {
|
||||
const { initSentry } = await import(
|
||||
"@/lib/observability/sentry"
|
||||
);
|
||||
await initSentry();
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] Sentry 初始化失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Web Vitals RUM 采集(生产环境启用)
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
try {
|
||||
const { initWebVitals } = await import(
|
||||
"@/lib/observability/web-vitals"
|
||||
);
|
||||
await initWebVitals();
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] Web Vitals 初始化失败", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. OTel browser SDK(条件:NEXT_PUBLIC_OTEL_ENABLED=true 时)
|
||||
try {
|
||||
const { initOTel } = await import("@/lib/observability/otel");
|
||||
await initOTel();
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] OTel 初始化失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Cookie 迁移(条件:NEXT_PUBLIC_COOKIE_MIGRATION_ENABLED=true 时)
|
||||
// 实际迁移在 iam refresh cookie 端点就绪后启用,此处仅做准备
|
||||
try {
|
||||
const { migrateTokenToCookie } = await import(
|
||||
"@/lib/observability/cookie-migration"
|
||||
);
|
||||
// 后台执行迁移,不阻塞应用渲染
|
||||
void migrateTokenToCookie();
|
||||
} catch {
|
||||
// 迁移失败不影响应用功能,静默忽略
|
||||
}
|
||||
};
|
||||
|
||||
// 后台初始化,不阻塞渲染
|
||||
void initObservability();
|
||||
}, []);
|
||||
|
||||
// 不渲染任何可见 UI,仅透传 children
|
||||
return children;
|
||||
}
|
||||
|
||||
export default ObservabilityProvider;
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Web Vitals 采集初始化组件(P6 硬化)
|
||||
*
|
||||
* 参考 admin-portal/src/components/web-vitals-initializer.tsx 实现。
|
||||
* - "use client" 客户端组件
|
||||
* - 在 useEffect 中调用 initWebVitals()(动态 import 避免未安装包问题)
|
||||
* - 不渲染任何可见 UI
|
||||
*
|
||||
* 仲裁:所有前端应用必须采集 Web Vitals
|
||||
* 关联:02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Web Vitals 采集初始化组件。
|
||||
*
|
||||
* 仅在生产环境启用采集,开发环境跳过以避免控制台噪音。
|
||||
*/
|
||||
export function WebVitalsInitializer(): null {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
// 动态 import 避免未安装包导致构建失败
|
||||
void import("@/lib/observability/web-vitals").then(
|
||||
({ initWebVitals }) => {
|
||||
void initWebVitals();
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default WebVitalsInitializer;
|
||||
105
apps/teacher-portal/src/hooks/use-cross-tab-sync.ts
Normal file
105
apps/teacher-portal/src/hooks/use-cross-tab-sync.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useCrossTabSync - 多 Tab 会话同步 Hook
|
||||
*
|
||||
* 职责:
|
||||
* - 通过 BroadcastChannel('edu-session') 监听跨 Tab 事件
|
||||
* - 事件类型:logout / role-change / token-refresh
|
||||
* - logout:调用 logout() 跳转登录页
|
||||
* - role-change:刷新页面重新获取权限
|
||||
* - token-refresh:更新 localStorage 中的 token
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §2.1 会话状态
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { setToken } from "@/lib/auth";
|
||||
|
||||
/** 跨 Tab 同步事件类型 */
|
||||
export type CrossTabEventType = "logout" | "role-change" | "token-refresh";
|
||||
|
||||
/** 跨 Tab 事件消息体 */
|
||||
export interface CrossTabMessage {
|
||||
type: CrossTabEventType;
|
||||
payload?: {
|
||||
token?: string;
|
||||
};
|
||||
/** 触发时间戳(用于去重/排序) */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** BroadcastChannel 名称 */
|
||||
const CHANNEL_NAME = "edu-session";
|
||||
|
||||
/**
|
||||
* 广播跨 Tab 事件给其他 Tab
|
||||
*
|
||||
* 当前 Tab 主动触发(如点击登出按钮),其他 Tab 通过此 hook 接收并响应。
|
||||
*/
|
||||
export function broadcastCrossTabEvent(
|
||||
type: CrossTabEventType,
|
||||
payload?: CrossTabMessage["payload"],
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
const message: CrossTabMessage = {
|
||||
type,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
} catch (err) {
|
||||
// BroadcastChannel 不可用时静默降级(单 Tab 模式不受影响)
|
||||
console.warn("[useCrossTabSync] broadcast failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅跨 Tab 事件并响应
|
||||
*
|
||||
* 必须在客户端组件中使用(依赖 window / BroadcastChannel)。
|
||||
*/
|
||||
export function useCrossTabSync(): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof BroadcastChannel === "undefined") return;
|
||||
|
||||
const channel = new BroadcastChannel(CHANNEL_NAME);
|
||||
|
||||
const handleMessage = (event: MessageEvent<CrossTabMessage>) => {
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
|
||||
switch (message.type) {
|
||||
case "logout":
|
||||
// 收到登出事件:清空本地状态并跳转登录页
|
||||
// 注:不调用 broadcastCrossTabEvent 避免循环
|
||||
window.location.href = "/login";
|
||||
break;
|
||||
|
||||
case "role-change":
|
||||
// 收到角色变更事件:刷新页面以重新获取权限
|
||||
window.location.reload();
|
||||
break;
|
||||
|
||||
case "token-refresh":
|
||||
// 收到 token 刷新事件:更新本地 token
|
||||
if (message.payload?.token) {
|
||||
setToken(message.payload.token);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener("message", handleMessage);
|
||||
channel.close();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
175
apps/teacher-portal/src/hooks/use-notifications-websocket.ts
Normal file
175
apps/teacher-portal/src/hooks/use-notifications-websocket.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 通知 WebSocket Hook(P5)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送(push-gateway)
|
||||
*
|
||||
* - 连接 push-gateway:ws://localhost:8081/ws
|
||||
* - useEffect 建立连接,useRef 持有 WebSocket 实例
|
||||
* - 监听消息事件,解析 JSON 后通过 state 通知
|
||||
* - 自动重连(指数退避,最多 5 次)
|
||||
* - 连接状态枚举:connecting / connected / disconnected / error
|
||||
* - Mock 模式(NEXT_PUBLIC_API_MOCKING=enabled):用 setInterval 每 30s 推送 1 条 mock 通知
|
||||
* (不依赖 mock-socket,避免新增 npm 依赖)
|
||||
* - 返回:{ notifications, connectionState, reconnect }
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { NotificationItem, NotificationType } from "@/lib/graphql-p5";
|
||||
import { mockNotifications } from "@/mocks/fixtures/notifications";
|
||||
|
||||
export type ConnectionState =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error";
|
||||
|
||||
export interface UseNotificationsWebSocketResult {
|
||||
notifications: NotificationItem[];
|
||||
connectionState: ConnectionState;
|
||||
reconnect: () => void;
|
||||
}
|
||||
|
||||
const WS_URL = "ws://localhost:8081/ws";
|
||||
const MAX_RETRIES = 5;
|
||||
const MAX_DELAY_MS = 30000;
|
||||
const MOCK_INTERVAL_MS = 30000;
|
||||
|
||||
export function useNotificationsWebSocket(): UseNotificationsWebSocketResult {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected");
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const reconnectFnRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let mockTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let mockIndex = 0;
|
||||
let disposed = false;
|
||||
|
||||
const pushNotification = (item: NotificationItem): void => {
|
||||
setNotifications((prev) => {
|
||||
if (prev.some((n) => n.id === item.id)) return prev;
|
||||
return [item, ...prev];
|
||||
});
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (disposed) return;
|
||||
setConnectionState("connecting");
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
} catch {
|
||||
setConnectionState("error");
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
retryRef.current = 0;
|
||||
setConnectionState("connected");
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
const raw = typeof event.data === "string" ? event.data : "";
|
||||
if (!raw) return;
|
||||
try {
|
||||
const msg = JSON.parse(raw) as {
|
||||
type?: string;
|
||||
payload?: NotificationItem;
|
||||
};
|
||||
const item = msg.payload;
|
||||
if (item && item.id) {
|
||||
pushNotification(item);
|
||||
}
|
||||
} catch {
|
||||
// 忽略非 JSON 消息
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionState("error");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (disposed) return;
|
||||
setConnectionState("disconnected");
|
||||
scheduleReconnect();
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (disposed) return;
|
||||
if (retryRef.current >= MAX_RETRIES) return;
|
||||
const delay = Math.min(1000 * 2 ** retryRef.current, MAX_DELAY_MS);
|
||||
retryRef.current += 1;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
const startMock = (): void => {
|
||||
setConnectionState("connected");
|
||||
mockTimer = setInterval(() => {
|
||||
if (disposed) return;
|
||||
const base =
|
||||
mockNotifications[mockIndex % mockNotifications.length];
|
||||
mockIndex += 1;
|
||||
if (!base) return;
|
||||
pushNotification({
|
||||
...base,
|
||||
id: `${base.id}-ws-${Date.now()}`,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}, MOCK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const start = (): void => {
|
||||
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
|
||||
startMock();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
reconnectFnRef.current = (): void => {
|
||||
retryRef.current = 0;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (mockTimer) clearInterval(mockTimer);
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
start();
|
||||
};
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (mockTimer) clearInterval(mockTimer);
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback((): void => {
|
||||
reconnectFnRef.current();
|
||||
}, []);
|
||||
|
||||
return { notifications, connectionState, reconnect };
|
||||
}
|
||||
|
||||
/** 通知类型标签映射(供页面渲染图标/文案时复用) */
|
||||
export const NOTIFICATION_TYPE_LABEL: Record<NotificationType, string> = {
|
||||
HOMEWORK_SUBMITTED: "作业",
|
||||
EXAM_GRADED: "考试",
|
||||
BROADCAST: "广播",
|
||||
ROLE_CHANGED: "角色",
|
||||
};
|
||||
122
apps/teacher-portal/src/lib/graphql-p4.ts
Normal file
122
apps/teacher-portal/src/lib/graphql-p4.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GraphQL P4 扩展 Query - 知识图谱 + 学情分析
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql.ts P2/P3 扩展、workline.md §3 P4 交付物
|
||||
*
|
||||
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
|
||||
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
|
||||
*
|
||||
* 查询范围(P4):
|
||||
* - KnowledgeGraph:班级/科目知识点图谱(节点 + 前置依赖边)
|
||||
* - ClassAnalytics:班级学情分析(均分/及格率/趋势/Top 学生/弱项)
|
||||
* - StudentAnalytics:单生学情分析(趋势/弱项/强项/掌握度)
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
|
||||
// ============ Types(对齐 P4 schema 设计) ============
|
||||
|
||||
/** 知识图谱节点(知识点) */
|
||||
export interface KnowledgeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
masteryLevel: number; // 0-100
|
||||
}
|
||||
|
||||
/** 知识图谱边(前置依赖关系) */
|
||||
export interface KnowledgeEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
type: "PREREQUISITE" | "RELATED";
|
||||
}
|
||||
|
||||
/** 知识图谱完整结构 */
|
||||
export interface KnowledgeGraphData {
|
||||
nodes: KnowledgeNode[];
|
||||
edges: KnowledgeEdge[];
|
||||
}
|
||||
|
||||
/** 单生学情分析 */
|
||||
export interface StudentAnalytics {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
avgScore: number;
|
||||
trend: number[]; // 最近 5 次成绩趋势
|
||||
weakPoints: string[]; // 弱项知识点名称
|
||||
strongPoints: string[];
|
||||
masteryRate: number; // 掌握度 0-100
|
||||
}
|
||||
|
||||
/** 班级学情分析 */
|
||||
export interface ClassAnalytics {
|
||||
classId: string;
|
||||
className: string;
|
||||
avgScore: number;
|
||||
passRate: number;
|
||||
avgTrend: number[];
|
||||
topStudents: StudentAnalytics[];
|
||||
weakPoints: string[];
|
||||
}
|
||||
|
||||
// ============ P4 Query ============
|
||||
|
||||
/** 知识图谱查询(按班级 + 科目过滤) */
|
||||
export const KnowledgeGraphQuery = gql`
|
||||
query KnowledgeGraph($classId: ID, $subject: String) {
|
||||
knowledgeGraph(classId: $classId, subject: $subject) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
subject
|
||||
description
|
||||
masteryLevel
|
||||
}
|
||||
edges {
|
||||
from
|
||||
to
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 班级学情分析查询 */
|
||||
export const ClassAnalyticsQuery = gql`
|
||||
query ClassAnalytics($classId: ID!) {
|
||||
classAnalytics(classId: $classId) {
|
||||
classId
|
||||
className
|
||||
avgScore
|
||||
passRate
|
||||
avgTrend
|
||||
topStudents {
|
||||
studentId
|
||||
studentName
|
||||
avgScore
|
||||
trend
|
||||
weakPoints
|
||||
strongPoints
|
||||
masteryRate
|
||||
}
|
||||
weakPoints
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 单生学情分析查询 */
|
||||
export const StudentAnalyticsQuery = gql`
|
||||
query StudentAnalytics($studentId: ID!) {
|
||||
studentAnalytics(studentId: $studentId) {
|
||||
studentId
|
||||
studentName
|
||||
avgScore
|
||||
trend
|
||||
weakPoints
|
||||
strongPoints
|
||||
masteryRate
|
||||
}
|
||||
}
|
||||
`;
|
||||
137
apps/teacher-portal/src/lib/graphql-p5.ts
Normal file
137
apps/teacher-portal/src/lib/graphql-p5.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* GraphQL operations - P5 扩展(通知中心 + AI 辅助)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送、§6 AI 辅助
|
||||
*
|
||||
* schema 来源:teacher-bff.graphql(P5 扩展,MSW mock,待 ai03 在上游补 schema)
|
||||
* - 通知:myNotifications / markAsRead(push-gateway 实时推送 + GraphQL 持久化)
|
||||
* - AI:generateQuestion / generateLessonPlan / generateReport
|
||||
*
|
||||
* 注意:本文件独立于 graphql.ts(P2/P3),不修改共享文件,最后统一合并。
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
|
||||
// ============ Types(通知中心) ============
|
||||
|
||||
export type NotificationType =
|
||||
| "HOMEWORK_SUBMITTED"
|
||||
| "EXAM_GRADED"
|
||||
| "BROADCAST"
|
||||
| "ROLE_CHANGED";
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(AI 辅助) ============
|
||||
|
||||
export interface GenerateQuestionInput {
|
||||
subject: string;
|
||||
questionType: string;
|
||||
difficulty: string;
|
||||
knowledgePoints: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GeneratedQuestion {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
answer: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface GenerateLessonPlanInput {
|
||||
classId: string;
|
||||
subject: string;
|
||||
topic: string;
|
||||
objectives: string;
|
||||
}
|
||||
|
||||
export interface GeneratedLessonPlan {
|
||||
id: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface GenerateReportInput {
|
||||
classId: string;
|
||||
reportType: string;
|
||||
studentId?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedReport {
|
||||
id: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
// ============ Queries / Mutations ============
|
||||
|
||||
/** 通知列表查询 */
|
||||
export const MyNotificationsQuery = gql`
|
||||
query MyNotifications($unreadOnly: Boolean) {
|
||||
myNotifications(unreadOnly: $unreadOnly) {
|
||||
id
|
||||
type
|
||||
title
|
||||
message
|
||||
read
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 标记已读 */
|
||||
export const MarkAsReadMutation = gql`
|
||||
mutation MarkAsRead($id: ID!) {
|
||||
markAsRead(id: $id) {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** AI 辅助出题(SSE 流式,mock 用 ReadableStream) */
|
||||
export const GenerateQuestionMutation = gql`
|
||||
mutation GenerateQuestion($input: GenerateQuestionInput!) {
|
||||
generateQuestion(input: $input) {
|
||||
id
|
||||
question
|
||||
options
|
||||
answer
|
||||
explanation
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** AI 生成教案 */
|
||||
export const GenerateLessonPlanMutation = gql`
|
||||
mutation GenerateLessonPlan($input: GenerateLessonPlanInput!) {
|
||||
generateLessonPlan(input: $input) {
|
||||
id
|
||||
content
|
||||
summary
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** AI 学情报告 */
|
||||
export const GenerateReportMutation = gql`
|
||||
mutation GenerateReport($input: GenerateReportInput!) {
|
||||
generateReport(input: $input) {
|
||||
id
|
||||
content
|
||||
summary
|
||||
recommendations
|
||||
}
|
||||
}
|
||||
`;
|
||||
713
apps/teacher-portal/src/lib/graphql-p7-admin.ts
Normal file
713
apps/teacher-portal/src/lib/graphql-p7-admin.ts
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(行政管理子模块:考勤 + 班级详情 + 课表 + 请假 + 调课)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
|
||||
*
|
||||
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
|
||||
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
|
||||
*
|
||||
* 查询范围(P7-admin):
|
||||
* - AttendanceList:考勤记录列表(按 classId/date/status 筛选 + 分页)
|
||||
* - AttendanceSheet:批量录入用学生 + 当天状态
|
||||
* - SaveAttendanceSheet:批量保存考勤录入
|
||||
* - AttendanceStats:班级考勤统计(出勤率/迟到率/早退率/请假率/预警/趋势)
|
||||
* - AttendanceReport:周报/月报数据
|
||||
* - ClassDetail:班级详情聚合(基本信息 + 学生 + 课表 + 近期作业 + 概览统计)
|
||||
* - ClassSchedule:班级课表(周一到周日 × 节次)
|
||||
* - LeaveRequests:教师请假审批列表
|
||||
* - ApproveLeaveRequest:批准/拒绝请假
|
||||
* - SubmitLeaveRequest:教师提交请假/调课申请
|
||||
* - ScheduleChanges:我的调课申请列表
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
import type { DataScope } from "./graphql";
|
||||
|
||||
// ============ Types(考勤) ============
|
||||
|
||||
/** 考勤状态 */
|
||||
export type AttendanceStatus =
|
||||
| "PRESENT"
|
||||
| "ABSENT"
|
||||
| "LATE"
|
||||
| "EARLY_LEAVE"
|
||||
| "LEAVE";
|
||||
|
||||
/** 考勤记录项 */
|
||||
export interface AttendanceRecord {
|
||||
id: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
status: AttendanceStatus;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** 考勤列表筛选条件 */
|
||||
export interface AttendanceListFilter {
|
||||
classId?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
statuses?: AttendanceStatus[] | null;
|
||||
page?: number | null;
|
||||
pageSize?: number | null;
|
||||
}
|
||||
|
||||
/** 考勤列表分页结果 */
|
||||
export interface AttendanceListResult {
|
||||
items: AttendanceRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/** 考勤录入项(按学生 + 日期) */
|
||||
export interface AttendanceSheetItem {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
date: string;
|
||||
status: AttendanceStatus | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** 考勤批量保存项 */
|
||||
export interface SaveAttendanceRecordInput {
|
||||
studentId: string;
|
||||
status: AttendanceStatus;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** 考勤批量保存输入 */
|
||||
export interface SaveAttendanceSheetInput {
|
||||
classId: string;
|
||||
date: string;
|
||||
records: SaveAttendanceRecordInput[];
|
||||
}
|
||||
|
||||
/** 考勤批量保存结果 */
|
||||
export interface SaveAttendanceSheetResult {
|
||||
classId: string;
|
||||
date: string;
|
||||
savedCount: number;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(考勤统计) ============
|
||||
|
||||
/** 班级考勤统计 */
|
||||
export interface AttendanceStats {
|
||||
classId: string;
|
||||
className: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalRecords: number;
|
||||
presentCount: number;
|
||||
absentCount: number;
|
||||
lateCount: number;
|
||||
earlyLeaveCount: number;
|
||||
leaveCount: number;
|
||||
presentRate: number;
|
||||
lateRate: number;
|
||||
earlyLeaveRate: number;
|
||||
leaveRate: number;
|
||||
warningCount: number;
|
||||
trend: AttendanceTrendPoint[];
|
||||
}
|
||||
|
||||
/** 考勤趋势点 */
|
||||
export interface AttendanceTrendPoint {
|
||||
date: string;
|
||||
presentRate: number;
|
||||
lateRate: number;
|
||||
absentRate: number;
|
||||
}
|
||||
|
||||
/** 班级考勤对比项 */
|
||||
export interface AttendanceClassComparison {
|
||||
classId: string;
|
||||
className: string;
|
||||
presentRate: number;
|
||||
lateRate: number;
|
||||
absentRate: number;
|
||||
}
|
||||
|
||||
/** 学生考勤预警项 */
|
||||
export interface AttendanceWarning {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
className: string;
|
||||
presentRate: number;
|
||||
absentCount: number;
|
||||
lateCount: number;
|
||||
}
|
||||
|
||||
// ============ Types(考勤报告) ============
|
||||
|
||||
/** 报告类型 */
|
||||
export type AttendanceReportType = "WEEKLY" | "MONTHLY";
|
||||
|
||||
/** 考勤报告数据 */
|
||||
export interface AttendanceReport {
|
||||
classId: string;
|
||||
className: string;
|
||||
reportType: AttendanceReportType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
generatedAt: string;
|
||||
summary: AttendanceReportSummary;
|
||||
details: AttendanceReportDetail[];
|
||||
}
|
||||
|
||||
/** 考勤报告摘要 */
|
||||
export interface AttendanceReportSummary {
|
||||
totalRecords: number;
|
||||
presentRate: number;
|
||||
lateRate: number;
|
||||
earlyLeaveRate: number;
|
||||
leaveRate: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
/** 考勤报告明细 */
|
||||
export interface AttendanceReportDetail {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
presentCount: number;
|
||||
absentCount: number;
|
||||
lateCount: number;
|
||||
earlyLeaveCount: number;
|
||||
leaveCount: number;
|
||||
presentRate: number;
|
||||
}
|
||||
|
||||
// ============ Types(班级详情) ============
|
||||
|
||||
/** 班级详情 */
|
||||
export interface ClassDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
gradeId: string;
|
||||
subject: string;
|
||||
studentCount: number;
|
||||
homeroomTeacher: string;
|
||||
room: string | null;
|
||||
schoolName: string;
|
||||
}
|
||||
|
||||
/** 班级详情聚合 */
|
||||
export interface ClassDetailAggregate {
|
||||
class: ClassDetail;
|
||||
students: ClassDetailStudent[];
|
||||
schedule: ClassScheduleItem[];
|
||||
recentHomework: ClassDetailHomework[];
|
||||
overview: ClassOverviewStats;
|
||||
trend: ClassDetailTrendPoint[];
|
||||
}
|
||||
|
||||
/** 班级详情学生 */
|
||||
export interface ClassDetailStudent {
|
||||
id: string;
|
||||
name: string;
|
||||
studentNo: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** 班级详情近期作业 */
|
||||
export interface ClassDetailHomework {
|
||||
id: string;
|
||||
title: string;
|
||||
dueDate: string;
|
||||
submissionRate: number;
|
||||
avgScore: number | null;
|
||||
}
|
||||
|
||||
/** 班级概览统计 */
|
||||
export interface ClassOverviewStats {
|
||||
attendanceRate: number;
|
||||
averageScore: number;
|
||||
homeworkCompletionRate: number;
|
||||
recentExamTitle: string | null;
|
||||
recentExamAvg: number | null;
|
||||
}
|
||||
|
||||
/** 班级详情趋势点 */
|
||||
export interface ClassDetailTrendPoint {
|
||||
month: string;
|
||||
avgScore: number;
|
||||
}
|
||||
|
||||
// ============ Types(课表) ============
|
||||
|
||||
/** 课表项 */
|
||||
export interface ClassScheduleItem {
|
||||
id: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
dayOfWeek: number; // 1=周一 ... 7=周日
|
||||
period: number; // 第 N 节(1-8)
|
||||
subject: string;
|
||||
teacherName: string;
|
||||
room: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
// ============ Types(请假) ============
|
||||
|
||||
/** 请假类型 */
|
||||
export type LeaveType = "SICK" | "PERSONAL" | "ANNUAL" | "OTHER";
|
||||
|
||||
/** 请假申请状态 */
|
||||
export type LeaveStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
/** 请假申请项 */
|
||||
export interface LeaveRequest {
|
||||
id: string;
|
||||
applicantId: string;
|
||||
applicantName: string;
|
||||
applicantRole: string;
|
||||
type: LeaveType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: string;
|
||||
status: LeaveStatus;
|
||||
submittedAt: string;
|
||||
reviewedAt: string | null;
|
||||
reviewerId: string | null;
|
||||
reviewerName: string | null;
|
||||
reviewComment: string | null;
|
||||
}
|
||||
|
||||
/** 请假列表筛选 */
|
||||
export interface LeaveRequestsFilter {
|
||||
dataScope?: DataScope | null;
|
||||
status?: LeaveStatus | null;
|
||||
page?: number | null;
|
||||
pageSize?: number | null;
|
||||
}
|
||||
|
||||
/** 请假列表分页结果 */
|
||||
export interface LeaveRequestsResult {
|
||||
items: LeaveRequest[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 审批请假输入 */
|
||||
export interface ApproveLeaveRequestInput {
|
||||
requestId: string;
|
||||
action: "APPROVE" | "REJECT";
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
/** 审批请假结果 */
|
||||
export interface ApproveLeaveRequestResult {
|
||||
requestId: string;
|
||||
status: LeaveStatus;
|
||||
reviewedAt: string;
|
||||
reviewerId: string;
|
||||
reviewerName: string;
|
||||
reviewComment: string | null;
|
||||
}
|
||||
|
||||
/** 提交请假申请输入 */
|
||||
export interface SubmitLeaveRequestInput {
|
||||
type: LeaveType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 提交请假申请结果 */
|
||||
export interface SubmitLeaveRequestResult {
|
||||
requestId: string;
|
||||
status: LeaveStatus;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(调课) ============
|
||||
|
||||
/** 调课类型 */
|
||||
export type ScheduleChangeType = "ADJUST" | "SUBSTITUTE";
|
||||
|
||||
/** 调课状态 */
|
||||
export type ScheduleChangeStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||
|
||||
/** 调课申请项 */
|
||||
export interface ScheduleChange {
|
||||
id: string;
|
||||
requesterId: string;
|
||||
requesterName: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
originalDate: string;
|
||||
originalPeriod: number;
|
||||
originalSubject: string;
|
||||
adjustedDate: string;
|
||||
adjustedPeriod: number;
|
||||
adjustedSubject: string;
|
||||
substituteTeacherId: string | null;
|
||||
substituteTeacherName: string | null;
|
||||
type: ScheduleChangeType;
|
||||
reason: string;
|
||||
status: ScheduleChangeStatus;
|
||||
submittedAt: string;
|
||||
reviewedAt: string | null;
|
||||
reviewComment: string | null;
|
||||
}
|
||||
|
||||
/** 调课列表分页结果 */
|
||||
export interface ScheduleChangesResult {
|
||||
items: ScheduleChange[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 提交调课申请输入 */
|
||||
export interface SubmitScheduleChangeInput {
|
||||
classId: string;
|
||||
originalDate: string;
|
||||
originalPeriod: number;
|
||||
originalSubject: string;
|
||||
adjustedDate: string;
|
||||
adjustedPeriod: number;
|
||||
adjustedSubject: string;
|
||||
substituteTeacherId?: string | null;
|
||||
type: ScheduleChangeType;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 提交调课申请结果 */
|
||||
export interface SubmitScheduleChangeResult {
|
||||
requestId: string;
|
||||
status: ScheduleChangeStatus;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
// ============ Queries / Mutations ============
|
||||
|
||||
/** 考勤记录列表查询 */
|
||||
export const AttendanceListQuery = gql`
|
||||
query AttendanceList($filter: AttendanceListFilterInput) {
|
||||
attendanceList(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
classId
|
||||
className
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
date
|
||||
status
|
||||
note
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
totalPages
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 考勤录入查询(学生列表 + 当天状态) */
|
||||
export const AttendanceSheetQuery = gql`
|
||||
query AttendanceSheet($classId: ID!, $date: String!) {
|
||||
attendanceSheet(classId: $classId, date: $date) {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
date
|
||||
status
|
||||
note
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 批量保存考勤 Mutation */
|
||||
export const SaveAttendanceSheetMutation = gql`
|
||||
mutation SaveAttendanceSheet($input: SaveAttendanceSheetInput!) {
|
||||
saveAttendanceSheet(input: $input) {
|
||||
classId
|
||||
date
|
||||
savedCount
|
||||
savedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 考勤统计查询 */
|
||||
export const AttendanceStatsQuery = gql`
|
||||
query AttendanceStats($classId: ID!, $startDate: String!, $endDate: String!) {
|
||||
attendanceStats(classId: $classId, startDate: $startDate, endDate: $endDate) {
|
||||
classId
|
||||
className
|
||||
startDate
|
||||
endDate
|
||||
totalRecords
|
||||
presentCount
|
||||
absentCount
|
||||
lateCount
|
||||
earlyLeaveCount
|
||||
leaveCount
|
||||
presentRate
|
||||
lateRate
|
||||
earlyLeaveRate
|
||||
leaveRate
|
||||
warningCount
|
||||
trend {
|
||||
date
|
||||
presentRate
|
||||
lateRate
|
||||
absentRate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 班级考勤对比查询 */
|
||||
export const AttendanceClassComparisonQuery = gql`
|
||||
query AttendanceClassComparison($startDate: String!, $endDate: String!) {
|
||||
attendanceClassComparison(startDate: $startDate, endDate: $endDate) {
|
||||
classId
|
||||
className
|
||||
presentRate
|
||||
lateRate
|
||||
absentRate
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 学生考勤预警查询 */
|
||||
export const AttendanceWarningsQuery = gql`
|
||||
query AttendanceWarnings($classId: ID!, $startDate: String!, $endDate: String!) {
|
||||
attendanceWarnings(classId: $classId, startDate: $startDate, endDate: $endDate) {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
className
|
||||
presentRate
|
||||
absentCount
|
||||
lateCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 考勤报告查询 */
|
||||
export const AttendanceReportQuery = gql`
|
||||
query AttendanceReport(
|
||||
$classId: ID!
|
||||
$reportType: AttendanceReportType!
|
||||
$startDate: String!
|
||||
$endDate: String!
|
||||
) {
|
||||
attendanceReport(
|
||||
classId: $classId
|
||||
reportType: $reportType
|
||||
startDate: $startDate
|
||||
endDate: $endDate
|
||||
) {
|
||||
classId
|
||||
className
|
||||
reportType
|
||||
startDate
|
||||
endDate
|
||||
generatedAt
|
||||
summary {
|
||||
totalRecords
|
||||
presentRate
|
||||
lateRate
|
||||
earlyLeaveRate
|
||||
leaveRate
|
||||
warningCount
|
||||
}
|
||||
details {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
presentCount
|
||||
absentCount
|
||||
lateCount
|
||||
earlyLeaveCount
|
||||
leaveCount
|
||||
presentRate
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 班级详情查询 */
|
||||
export const ClassDetailQuery = gql`
|
||||
query ClassDetail($id: ID!) {
|
||||
classDetail(id: $id) {
|
||||
class {
|
||||
id
|
||||
name
|
||||
gradeId
|
||||
subject
|
||||
studentCount
|
||||
homeroomTeacher
|
||||
room
|
||||
schoolName
|
||||
}
|
||||
students {
|
||||
id
|
||||
name
|
||||
studentNo
|
||||
email
|
||||
}
|
||||
schedule {
|
||||
id
|
||||
classId
|
||||
className
|
||||
dayOfWeek
|
||||
period
|
||||
subject
|
||||
teacherName
|
||||
room
|
||||
startTime
|
||||
endTime
|
||||
}
|
||||
recentHomework {
|
||||
id
|
||||
title
|
||||
dueDate
|
||||
submissionRate
|
||||
avgScore
|
||||
}
|
||||
overview {
|
||||
attendanceRate
|
||||
averageScore
|
||||
homeworkCompletionRate
|
||||
recentExamTitle
|
||||
recentExamAvg
|
||||
}
|
||||
trend {
|
||||
month
|
||||
avgScore
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 班级课表查询 */
|
||||
export const ClassScheduleQuery = gql`
|
||||
query ClassSchedule($classId: ID) {
|
||||
classSchedule(classId: $classId) {
|
||||
id
|
||||
classId
|
||||
className
|
||||
dayOfWeek
|
||||
period
|
||||
subject
|
||||
teacherName
|
||||
room
|
||||
startTime
|
||||
endTime
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 请假审批列表查询 */
|
||||
export const LeaveRequestsQuery = gql`
|
||||
query LeaveRequests($filter: LeaveRequestsFilterInput) {
|
||||
leaveRequests(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
applicantId
|
||||
applicantName
|
||||
applicantRole
|
||||
type
|
||||
startDate
|
||||
endDate
|
||||
reason
|
||||
status
|
||||
submittedAt
|
||||
reviewedAt
|
||||
reviewerId
|
||||
reviewerName
|
||||
reviewComment
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 审批请假 Mutation */
|
||||
export const ApproveLeaveRequestMutation = gql`
|
||||
mutation ApproveLeaveRequest($input: ApproveLeaveRequestInput!) {
|
||||
approveLeaveRequest(input: $input) {
|
||||
requestId
|
||||
status
|
||||
reviewedAt
|
||||
reviewerId
|
||||
reviewerName
|
||||
reviewComment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 提交请假申请 Mutation */
|
||||
export const SubmitLeaveRequestMutation = gql`
|
||||
mutation SubmitLeaveRequest($input: SubmitLeaveRequestInput!) {
|
||||
submitLeaveRequest(input: $input) {
|
||||
requestId
|
||||
status
|
||||
submittedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 调课申请列表查询 */
|
||||
export const ScheduleChangesQuery = gql`
|
||||
query ScheduleChanges($page: Int, $pageSize: Int) {
|
||||
scheduleChanges(page: $page, pageSize: $pageSize) {
|
||||
items {
|
||||
id
|
||||
requesterId
|
||||
requesterName
|
||||
classId
|
||||
className
|
||||
originalDate
|
||||
originalPeriod
|
||||
originalSubject
|
||||
adjustedDate
|
||||
adjustedPeriod
|
||||
adjustedSubject
|
||||
substituteTeacherId
|
||||
substituteTeacherName
|
||||
type
|
||||
reason
|
||||
status
|
||||
submittedAt
|
||||
reviewedAt
|
||||
reviewComment
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 提交调课申请 Mutation */
|
||||
export const SubmitScheduleChangeMutation = gql`
|
||||
mutation SubmitScheduleChange($input: SubmitScheduleChangeInput!) {
|
||||
submitScheduleChange(input: $input) {
|
||||
requestId
|
||||
status
|
||||
submittedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
499
apps/teacher-portal/src/lib/graphql-p7-advanced.ts
Normal file
499
apps/teacher-portal/src/lib/graphql-p7-advanced.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(选修课 + 富文本编辑 + 监考 + 扫描批改 + 课标热力图)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
|
||||
*
|
||||
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
|
||||
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
|
||||
*
|
||||
* 查询范围(P7-advanced):
|
||||
* - ElectiveCourses / ElectiveCourseDetail:选修课列表 + 详情
|
||||
* - CreateElectiveCourse / UpdateElectiveCourse / PublishElectiveCourse / CancelElectiveCourse
|
||||
* - ExamRichEditor / SaveExamRichContent:富文本试卷内容
|
||||
* - ProctoringStatus / ProctoringEvent:监考状态 + 事件
|
||||
* - SubmissionScanGrading / SaveScanGrading:扫描批改
|
||||
* - LessonPlanHeatmap:课标覆盖热力图
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
|
||||
// ============ Types(选修课) ============
|
||||
|
||||
/** 选修课状态 */
|
||||
export type ElectiveCourseStatus =
|
||||
| "draft"
|
||||
| "open"
|
||||
| "closed"
|
||||
| "cancelled";
|
||||
|
||||
/** 选课学生项 */
|
||||
export interface ElectiveStudent {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
enrolledAt: string;
|
||||
status: "enrolled" | "cancelled";
|
||||
}
|
||||
|
||||
/** 选修课列表项 */
|
||||
export interface ElectiveCourseItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
capacity: number;
|
||||
enrolledCount: number;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
semester: string;
|
||||
status: ElectiveCourseStatus;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** 选修课详情(含学生列表) */
|
||||
export interface ElectiveCourseDetail extends ElectiveCourseItem {
|
||||
students: ElectiveStudent[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 创建选修课输入 */
|
||||
export interface CreateElectiveCourseInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
capacity: number;
|
||||
semester: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/** 更新选修课输入 */
|
||||
export interface UpdateElectiveCourseInput {
|
||||
id: string;
|
||||
title?: string;
|
||||
subject?: string;
|
||||
grade?: string;
|
||||
capacity?: number;
|
||||
semester?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/** 创建/更新结果 */
|
||||
export interface ElectiveCourseMutationResult {
|
||||
id: string;
|
||||
status: ElectiveCourseStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(富文本编辑) ============
|
||||
|
||||
/** 富文本试卷内容(Tiptap examNodes JSON 结构,mock 用 JSON) */
|
||||
export interface ExamRichContent {
|
||||
examId: string;
|
||||
title: string;
|
||||
totalScore: number;
|
||||
questionCount: number;
|
||||
content: unknown; // Tiptap JSON,结构由编辑器决定
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 保存富文本输入 */
|
||||
export interface SaveExamRichContentInput {
|
||||
examId: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
/** 保存富文本结果 */
|
||||
export interface SaveExamRichContentResult {
|
||||
examId: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(监考) ============
|
||||
|
||||
/** 监考学生状态 */
|
||||
export type ProctoringStudentStatus =
|
||||
| "online"
|
||||
| "offline"
|
||||
| "submitted"
|
||||
| "flagged";
|
||||
|
||||
/** 监考摘要 */
|
||||
export interface ProctoringSummary {
|
||||
examId: string;
|
||||
expectedCount: number;
|
||||
onlineCount: number;
|
||||
submittedCount: number;
|
||||
flaggedCount: number;
|
||||
isProctoring: boolean;
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
/** 监考学生状态项 */
|
||||
export interface ProctoringStudent {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
status: ProctoringStudentStatus;
|
||||
lastEventAt: string | null;
|
||||
ipAddress: string | null;
|
||||
}
|
||||
|
||||
/** 监考事件类型 */
|
||||
export type ProctoringEventType = "WARNING" | "FLAG" | "NOTE";
|
||||
|
||||
/** 监考事件项 */
|
||||
export interface ProctoringEvent {
|
||||
eventId: string;
|
||||
examId: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
eventType: ProctoringEventType;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 监考状态完整结构 */
|
||||
export interface ProctoringStatus {
|
||||
summary: ProctoringSummary;
|
||||
students: ProctoringStudent[];
|
||||
recentEvents: ProctoringEvent[];
|
||||
}
|
||||
|
||||
/** 提交监考事件输入 */
|
||||
export interface ProctoringEventInput {
|
||||
examId: string;
|
||||
studentId: string;
|
||||
eventType: ProctoringEventType;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/** 提交监考事件结果 */
|
||||
export interface ProctoringEventResult {
|
||||
eventId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(扫描批改) ============
|
||||
|
||||
/** 扫描图片项 */
|
||||
export interface ScanImage {
|
||||
imageId: string;
|
||||
url: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
/** 扫描批改单题项 */
|
||||
export interface ScanGradingItem {
|
||||
questionId: string;
|
||||
questionTitle: string;
|
||||
questionOrder: number;
|
||||
maxScore: number;
|
||||
recognizedAnswer: string;
|
||||
aiSuggestedScore: number | null;
|
||||
aiSuggestion: string | null;
|
||||
score: number | null;
|
||||
feedback: string | null;
|
||||
}
|
||||
|
||||
/** 扫描批改视图 */
|
||||
export interface SubmissionScanGrading {
|
||||
submissionId: string;
|
||||
homeworkTitle: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
submittedAt: string;
|
||||
images: ScanImage[];
|
||||
items: ScanGradingItem[];
|
||||
prevSubmissionId: string | null;
|
||||
nextSubmissionId: string | null;
|
||||
}
|
||||
|
||||
/** 保存扫描批改单项 */
|
||||
export interface SaveScanGradingItem {
|
||||
questionId: string;
|
||||
score: number;
|
||||
feedback?: string | null;
|
||||
}
|
||||
|
||||
/** 保存扫描批改输入 */
|
||||
export interface SaveScanGradingInput {
|
||||
submissionId: string;
|
||||
items: SaveScanGradingItem[];
|
||||
}
|
||||
|
||||
/** 保存扫描批改结果 */
|
||||
export interface SaveScanGradingResult {
|
||||
submissionId: string;
|
||||
totalScore: number;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(课标热力图) ============
|
||||
|
||||
/** 热力图知识点项 */
|
||||
export interface HeatmapKnowledgePoint {
|
||||
kpId: string;
|
||||
name: string;
|
||||
chapterName: string;
|
||||
}
|
||||
|
||||
/** 热力图课案项 */
|
||||
export interface HeatmapLessonPlan {
|
||||
planId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 热力图覆盖矩阵单元格 */
|
||||
export interface HeatmapCell {
|
||||
kpId: string;
|
||||
planId: string;
|
||||
coverage: number; // 0-3 覆盖课案数
|
||||
}
|
||||
|
||||
/** 课标覆盖热力图 */
|
||||
export interface LessonPlanHeatmap {
|
||||
textbookId: string;
|
||||
textbookTitle: string;
|
||||
knowledgePoints: HeatmapKnowledgePoint[];
|
||||
lessonPlans: HeatmapLessonPlan[];
|
||||
cells: HeatmapCell[];
|
||||
}
|
||||
|
||||
// ============ Queries / Mutations(选修课) ============
|
||||
|
||||
/** 选修课列表查询(按状态筛选,教师范围) */
|
||||
export const ElectiveCoursesQuery = gql`
|
||||
query ElectiveCourses($status: String, $subject: String) {
|
||||
electiveCourses(status: $status, subject: $subject) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
capacity
|
||||
enrolledCount
|
||||
teacherId
|
||||
teacherName
|
||||
semester
|
||||
status
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 选修课详情查询(含学生列表) */
|
||||
export const ElectiveCourseDetailQuery = gql`
|
||||
query ElectiveCourseDetail($id: ID!) {
|
||||
electiveCourseDetail(id: $id) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
capacity
|
||||
enrolledCount
|
||||
teacherId
|
||||
teacherName
|
||||
semester
|
||||
status
|
||||
description
|
||||
students {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
enrolledAt
|
||||
status
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 创建选修课 Mutation */
|
||||
export const CreateElectiveCourseMutation = gql`
|
||||
mutation CreateElectiveCourse($input: CreateElectiveCourseInput!) {
|
||||
createElectiveCourse(input: $input) {
|
||||
id
|
||||
status
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 更新选修课 Mutation */
|
||||
export const UpdateElectiveCourseMutation = gql`
|
||||
mutation UpdateElectiveCourse($input: UpdateElectiveCourseInput!) {
|
||||
updateElectiveCourse(input: $input) {
|
||||
id
|
||||
status
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 发布选修课 Mutation */
|
||||
export const PublishElectiveCourseMutation = gql`
|
||||
mutation PublishElectiveCourse($id: ID!) {
|
||||
publishElectiveCourse(id: $id) {
|
||||
id
|
||||
status
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 取消选修课 Mutation */
|
||||
export const CancelElectiveCourseMutation = gql`
|
||||
mutation CancelElectiveCourse($id: ID!) {
|
||||
cancelElectiveCourse(id: $id) {
|
||||
id
|
||||
status
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries / Mutations(富文本编辑) ============
|
||||
|
||||
/** 富文本试卷内容查询 */
|
||||
export const ExamRichEditorQuery = gql`
|
||||
query ExamRichEditor($examId: ID!) {
|
||||
examRichEditor(examId: $examId) {
|
||||
examId
|
||||
title
|
||||
totalScore
|
||||
questionCount
|
||||
content
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 保存富文本内容 Mutation */
|
||||
export const SaveExamRichContentMutation = gql`
|
||||
mutation SaveExamRichContent($input: SaveExamRichContentInput!) {
|
||||
saveExamRichContent(input: $input) {
|
||||
examId
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries / Mutations(监考) ============
|
||||
|
||||
/** 监考状态查询 */
|
||||
export const ProctoringStatusQuery = gql`
|
||||
query ProctoringStatus($examId: ID!) {
|
||||
proctoringStatus(examId: $examId) {
|
||||
summary {
|
||||
examId
|
||||
expectedCount
|
||||
onlineCount
|
||||
submittedCount
|
||||
flaggedCount
|
||||
isProctoring
|
||||
startedAt
|
||||
}
|
||||
students {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
status
|
||||
lastEventAt
|
||||
ipAddress
|
||||
}
|
||||
recentEvents {
|
||||
eventId
|
||||
examId
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
eventType
|
||||
note
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 提交监考事件 Mutation */
|
||||
export const ProctoringEventMutation = gql`
|
||||
mutation ProctoringEvent($input: ProctoringEventInput!) {
|
||||
proctoringEvent(input: $input) {
|
||||
eventId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries / Mutations(扫描批改) ============
|
||||
|
||||
/** 扫描批改视图查询 */
|
||||
export const SubmissionScanGradingQuery = gql`
|
||||
query SubmissionScanGrading($submissionId: ID!) {
|
||||
submissionScanGrading(submissionId: $submissionId) {
|
||||
submissionId
|
||||
homeworkTitle
|
||||
studentName
|
||||
studentNo
|
||||
submittedAt
|
||||
images {
|
||||
imageId
|
||||
url
|
||||
page
|
||||
}
|
||||
items {
|
||||
questionId
|
||||
questionTitle
|
||||
questionOrder
|
||||
maxScore
|
||||
recognizedAnswer
|
||||
aiSuggestedScore
|
||||
aiSuggestion
|
||||
score
|
||||
feedback
|
||||
}
|
||||
prevSubmissionId
|
||||
nextSubmissionId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 保存扫描批改 Mutation */
|
||||
export const SaveScanGradingMutation = gql`
|
||||
mutation SaveScanGrading($input: SaveScanGradingInput!) {
|
||||
saveScanGrading(input: $input) {
|
||||
submissionId
|
||||
totalScore
|
||||
savedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries(课标热力图) ============
|
||||
|
||||
/** 课标覆盖热力图查询(按教材 ID) */
|
||||
export const LessonPlanHeatmapQuery = gql`
|
||||
query LessonPlanHeatmap($textbookId: ID!) {
|
||||
lessonPlanHeatmap(textbookId: $textbookId) {
|
||||
textbookId
|
||||
textbookTitle
|
||||
knowledgePoints {
|
||||
kpId
|
||||
name
|
||||
chapterName
|
||||
}
|
||||
lessonPlans {
|
||||
planId
|
||||
title
|
||||
}
|
||||
cells {
|
||||
kpId
|
||||
planId
|
||||
coverage
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
628
apps/teacher-portal/src/lib/graphql-p7-exams.ts
Normal file
628
apps/teacher-portal/src/lib/graphql-p7-exams.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(组卷 + 题库 + 教材 + 考试分析)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
|
||||
*
|
||||
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
|
||||
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
|
||||
*
|
||||
* 查询范围(P7):
|
||||
* - ExamBuild:按 examId 拉取试卷结构 + 题库候选列表分页
|
||||
* - SaveExamBuild:保存组卷(已选题目 + 分值 + 排序)
|
||||
* - ExamAnalytics:考后分析(分数分布/均分/最高/最低/及格率/科目对比/每题正确率/学生排名)
|
||||
* - QuestionsLibrary:题库列表(六维筛选 + 分页 + 总数)
|
||||
* - QuestionCreate / QuestionUpdate / QuestionDelete:题目 CRUD
|
||||
* - QuestionBatchImport / QuestionBatchExport:批量导入导出
|
||||
* - Textbooks:教材列表(q/subject/grade 筛选 + 分页)
|
||||
* - TextbookDetail:教材详情(章节树 + 知识点列表)
|
||||
* - TextbookCreate:创建教材
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
|
||||
// ============ Types(题库) ============
|
||||
|
||||
/** 题目类型 */
|
||||
export type QuestionType =
|
||||
| "SINGLE_CHOICE"
|
||||
| "MULTIPLE_CHOICE"
|
||||
| "TRUE_FALSE"
|
||||
| "FILL_BLANK"
|
||||
| "SHORT_ANSWER";
|
||||
|
||||
/** 难度等级 */
|
||||
export type QuestionDifficulty = "EASY" | "MEDIUM" | "HARD";
|
||||
|
||||
/** 题库项 */
|
||||
export interface QuestionItem {
|
||||
questionId: string;
|
||||
content: string;
|
||||
type: QuestionType;
|
||||
difficulty: QuestionDifficulty;
|
||||
textbookId: string | null;
|
||||
textbookName: string | null;
|
||||
chapterId: string | null;
|
||||
chapterName: string | null;
|
||||
kpId: string | null;
|
||||
kpName: string | null;
|
||||
score: number;
|
||||
answer: string;
|
||||
analysis: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 题库分页结果 */
|
||||
export interface QuestionsLibraryPage {
|
||||
items: QuestionItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 题库筛选条件 */
|
||||
export interface QuestionsLibraryFilter {
|
||||
q?: string | null;
|
||||
type?: QuestionType | null;
|
||||
difficulty?: QuestionDifficulty | null;
|
||||
kp?: string | null;
|
||||
textbook?: string | null;
|
||||
chapter?: string | null;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** 创建题目输入 */
|
||||
export interface QuestionCreateInput {
|
||||
content: string;
|
||||
type: QuestionType;
|
||||
difficulty: QuestionDifficulty;
|
||||
textbookId?: string | null;
|
||||
chapterId?: string | null;
|
||||
kpId?: string | null;
|
||||
score: number;
|
||||
answer: string;
|
||||
analysis?: string | null;
|
||||
}
|
||||
|
||||
/** 更新题目输入 */
|
||||
export interface QuestionUpdateInput {
|
||||
questionId: string;
|
||||
content?: string;
|
||||
type?: QuestionType;
|
||||
difficulty?: QuestionDifficulty;
|
||||
textbookId?: string | null;
|
||||
chapterId?: string | null;
|
||||
kpId?: string | null;
|
||||
score?: number;
|
||||
answer?: string;
|
||||
analysis?: string | null;
|
||||
}
|
||||
|
||||
/** 批量导入项 */
|
||||
export interface QuestionBatchImportItem {
|
||||
content: string;
|
||||
type: QuestionType;
|
||||
difficulty: QuestionDifficulty;
|
||||
textbookId?: string | null;
|
||||
chapterId?: string | null;
|
||||
kpId?: string | null;
|
||||
score: number;
|
||||
answer: string;
|
||||
analysis?: string | null;
|
||||
}
|
||||
|
||||
/** 批量导入输入 */
|
||||
export interface QuestionBatchImportInput {
|
||||
items: QuestionBatchImportItem[];
|
||||
}
|
||||
|
||||
/** 批量导入结果 */
|
||||
export interface QuestionBatchImportResult {
|
||||
importedCount: number;
|
||||
failedCount: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** 批量导出结果 */
|
||||
export interface QuestionBatchExportResult {
|
||||
items: QuestionItem[];
|
||||
exportedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(教材) ============
|
||||
|
||||
/** 教材项(列表) */
|
||||
export interface TextbookItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
version: string;
|
||||
coverUrl: string | null;
|
||||
chapterCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 教材分页结果 */
|
||||
export interface TextbooksPage {
|
||||
items: TextbookItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 教材筛选 */
|
||||
export interface TextbooksFilter {
|
||||
q?: string | null;
|
||||
subject?: string | null;
|
||||
grade?: string | null;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** 教材创建输入 */
|
||||
export interface TextbookCreateInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
version: string;
|
||||
coverUrl?: string | null;
|
||||
}
|
||||
|
||||
/** 知识点 */
|
||||
export interface KnowledgePoint {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** 章节节点(含知识点) */
|
||||
export interface TextbookChapter {
|
||||
id: string;
|
||||
textbookId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
content: string;
|
||||
parentId: string | null;
|
||||
knowledgePoints: KnowledgePoint[];
|
||||
}
|
||||
|
||||
/** 教材详情 */
|
||||
export interface TextbookDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
version: string;
|
||||
coverUrl: string | null;
|
||||
chapters: TextbookChapter[];
|
||||
}
|
||||
|
||||
// ============ Types(组卷) ============
|
||||
|
||||
/** 已选题目(试卷节点) */
|
||||
export interface ExamBuildNode {
|
||||
questionId: string;
|
||||
score: number;
|
||||
sortOrder: number;
|
||||
// 题目冗余字段(便于预览)
|
||||
content: string;
|
||||
type: QuestionType;
|
||||
difficulty: QuestionDifficulty;
|
||||
}
|
||||
|
||||
/** 试卷结构 */
|
||||
export interface ExamBuildStructure {
|
||||
examId: string;
|
||||
title: string;
|
||||
totalScore: number;
|
||||
passScore: number;
|
||||
duration: number;
|
||||
selected: ExamBuildNode[];
|
||||
// 题库候选列表(首屏分页)
|
||||
candidates: QuestionsLibraryPage;
|
||||
}
|
||||
|
||||
/** 保存组卷输入项 */
|
||||
export interface SaveExamBuildNodeInput {
|
||||
questionId: string;
|
||||
score: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** 保存组卷输入 */
|
||||
export interface SaveExamBuildInput {
|
||||
examId: string;
|
||||
questions: SaveExamBuildNodeInput[];
|
||||
}
|
||||
|
||||
/** 保存组卷结果 */
|
||||
export interface SaveExamBuildResult {
|
||||
examId: string;
|
||||
totalScore: number;
|
||||
questionCount: number;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(考试分析) ============
|
||||
|
||||
/** 顶部统计卡片 */
|
||||
export interface ExamAnalyticsSummary {
|
||||
expectedCount: number;
|
||||
attendedCount: number;
|
||||
avgScore: number;
|
||||
maxScore: number;
|
||||
minScore: number;
|
||||
passRate: number;
|
||||
}
|
||||
|
||||
/** 分数分布段 */
|
||||
export interface ExamScoreBand {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 每题正确率 */
|
||||
export interface ExamQuestionAccuracy {
|
||||
questionId: string;
|
||||
questionTitle: string;
|
||||
order: number;
|
||||
correctRate: number;
|
||||
avgScore: number;
|
||||
maxScore: number;
|
||||
}
|
||||
|
||||
/** 知识点掌握度(同 grades 模块,但语义独立) */
|
||||
export interface ExamKpMastery {
|
||||
knowledgePoint: string;
|
||||
mastery: number;
|
||||
}
|
||||
|
||||
/** 班级对比(复用 grades 类型语义,独立定义) */
|
||||
export interface ExamClassComparison {
|
||||
classId: string;
|
||||
className: string;
|
||||
avg: number;
|
||||
significant: boolean;
|
||||
}
|
||||
|
||||
/** 历次考试趋势点 */
|
||||
export interface ExamHistoryTrend {
|
||||
examTitle: string;
|
||||
examDate: string;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
/** 学生排名项 */
|
||||
export interface ExamStudentRank {
|
||||
rank: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
level: "A" | "B" | "C" | "D" | "E";
|
||||
}
|
||||
|
||||
/** 考试分析完整结构 */
|
||||
export interface ExamAnalytics {
|
||||
examId: string;
|
||||
examTitle: string;
|
||||
summary: ExamAnalyticsSummary;
|
||||
distribution: ExamScoreBand[];
|
||||
questionAccuracy: ExamQuestionAccuracy[];
|
||||
knowledgeMastery: ExamKpMastery[];
|
||||
classComparison: ExamClassComparison[];
|
||||
historyTrend: ExamHistoryTrend[];
|
||||
rankings: ExamStudentRank[];
|
||||
}
|
||||
|
||||
// ============ Queries / Mutations ============
|
||||
|
||||
/** 组卷查询(按 examId 拉取试卷结构 + 题库候选列表分页) */
|
||||
export const ExamBuildQuery = gql`
|
||||
query ExamBuild(
|
||||
$examId: ID!
|
||||
$candidatePage: Int
|
||||
$candidatePageSize: Int
|
||||
$filter: QuestionsLibraryFilter
|
||||
) {
|
||||
examBuild(
|
||||
examId: $examId
|
||||
candidatePage: $candidatePage
|
||||
candidatePageSize: $candidatePageSize
|
||||
filter: $filter
|
||||
) {
|
||||
examId
|
||||
title
|
||||
totalScore
|
||||
passScore
|
||||
duration
|
||||
selected {
|
||||
questionId
|
||||
score
|
||||
sortOrder
|
||||
content
|
||||
type
|
||||
difficulty
|
||||
}
|
||||
candidates {
|
||||
items {
|
||||
questionId
|
||||
content
|
||||
type
|
||||
difficulty
|
||||
textbookId
|
||||
textbookName
|
||||
chapterId
|
||||
chapterName
|
||||
kpId
|
||||
kpName
|
||||
score
|
||||
answer
|
||||
analysis
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 保存组卷 Mutation */
|
||||
export const SaveExamBuildMutation = gql`
|
||||
mutation SaveExamBuild($input: SaveExamBuildInput!) {
|
||||
saveExamBuild(input: $input) {
|
||||
examId
|
||||
totalScore
|
||||
questionCount
|
||||
savedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 考试分析查询 */
|
||||
export const ExamAnalyticsQuery = gql`
|
||||
query ExamAnalytics($examId: ID!) {
|
||||
examAnalytics(examId: $examId) {
|
||||
examId
|
||||
examTitle
|
||||
summary {
|
||||
expectedCount
|
||||
attendedCount
|
||||
avgScore
|
||||
maxScore
|
||||
minScore
|
||||
passRate
|
||||
}
|
||||
distribution {
|
||||
label
|
||||
min
|
||||
max
|
||||
count
|
||||
}
|
||||
questionAccuracy {
|
||||
questionId
|
||||
questionTitle
|
||||
order
|
||||
correctRate
|
||||
avgScore
|
||||
maxScore
|
||||
}
|
||||
knowledgeMastery {
|
||||
knowledgePoint
|
||||
mastery
|
||||
}
|
||||
classComparison {
|
||||
classId
|
||||
className
|
||||
avg
|
||||
significant
|
||||
}
|
||||
historyTrend {
|
||||
examTitle
|
||||
examDate
|
||||
avg
|
||||
}
|
||||
rankings {
|
||||
rank
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
totalScore
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 题库列表查询(六维筛选 + 分页 + 总数) */
|
||||
export const QuestionsLibraryQuery = gql`
|
||||
query QuestionsLibrary($filter: QuestionsLibraryFilter) {
|
||||
questionsLibrary(filter: $filter) {
|
||||
items {
|
||||
questionId
|
||||
content
|
||||
type
|
||||
difficulty
|
||||
textbookId
|
||||
textbookName
|
||||
chapterId
|
||||
chapterName
|
||||
kpId
|
||||
kpName
|
||||
score
|
||||
answer
|
||||
analysis
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 创建题目 Mutation */
|
||||
export const QuestionCreateMutation = gql`
|
||||
mutation QuestionCreate($input: QuestionCreateInput!) {
|
||||
questionCreate(input: $input) {
|
||||
questionId
|
||||
content
|
||||
type
|
||||
difficulty
|
||||
textbookId
|
||||
textbookName
|
||||
chapterId
|
||||
chapterName
|
||||
kpId
|
||||
kpName
|
||||
score
|
||||
answer
|
||||
analysis
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 更新题目 Mutation */
|
||||
export const QuestionUpdateMutation = gql`
|
||||
mutation QuestionUpdate($input: QuestionUpdateInput!) {
|
||||
questionUpdate(input: $input) {
|
||||
questionId
|
||||
content
|
||||
type
|
||||
difficulty
|
||||
textbookId
|
||||
textbookName
|
||||
chapterId
|
||||
chapterName
|
||||
kpId
|
||||
kpName
|
||||
score
|
||||
answer
|
||||
analysis
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 删除题目 Mutation */
|
||||
export const QuestionDeleteMutation = gql`
|
||||
mutation QuestionDelete($questionId: ID!) {
|
||||
questionDelete(questionId: $questionId) {
|
||||
questionId
|
||||
deleted
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 批量导入题目 Mutation */
|
||||
export const QuestionBatchImportMutation = gql`
|
||||
mutation QuestionBatchImport($input: QuestionBatchImportInput!) {
|
||||
questionBatchImport(input: $input) {
|
||||
importedCount
|
||||
failedCount
|
||||
errors
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 批量导出题目查询(按当前筛选条件) */
|
||||
export const QuestionBatchExportQuery = gql`
|
||||
query QuestionBatchExport($filter: QuestionsLibraryFilter) {
|
||||
questionBatchExport(filter: $filter) {
|
||||
items {
|
||||
questionId
|
||||
content
|
||||
type
|
||||
difficulty
|
||||
textbookId
|
||||
textbookName
|
||||
chapterId
|
||||
chapterName
|
||||
kpId
|
||||
kpName
|
||||
score
|
||||
answer
|
||||
analysis
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
exportedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 教材列表查询 */
|
||||
export const TextbooksQuery = gql`
|
||||
query Textbooks($filter: TextbooksFilter) {
|
||||
textbooks(filter: $filter) {
|
||||
items {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
version
|
||||
coverUrl
|
||||
chapterCount
|
||||
createdAt
|
||||
}
|
||||
total
|
||||
page
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 教材详情查询(章节树 + 知识点列表) */
|
||||
export const TextbookDetailQuery = gql`
|
||||
query TextbookDetail($id: ID!) {
|
||||
textbookDetail(id: $id) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
version
|
||||
coverUrl
|
||||
chapters {
|
||||
id
|
||||
textbookId
|
||||
title
|
||||
order
|
||||
content
|
||||
parentId
|
||||
knowledgePoints {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 创建教材 Mutation */
|
||||
export const TextbookCreateMutation = gql`
|
||||
mutation TextbookCreate($input: TextbookCreateInput!) {
|
||||
textbookCreate(input: $input) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
version
|
||||
coverUrl
|
||||
chapterCount
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
418
apps/teacher-portal/src/lib/graphql-p7-grades.ts
Normal file
418
apps/teacher-portal/src/lib/graphql-p7-grades.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(成绩完整子模块 + 作业批改链路)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
|
||||
*
|
||||
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
|
||||
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
|
||||
*
|
||||
* 查询范围(P7):
|
||||
* - GradeEntry:按 examId + classId 拉取学生成绩录入列表
|
||||
* - SaveGradeEntries:批量保存成绩录入
|
||||
* - GradeStats:班级成绩统计(avg/median/max/min/passRate/stdDev/rankings)
|
||||
* - GradeAnalytics:成绩分析(趋势/分布/学科对比/班级对比/知识点掌握度)
|
||||
* - ReportCard:学生成绩报告卡
|
||||
* - HomeworkSubmissions:按作业维度统计提交/已批数
|
||||
* - HomeworkAssignmentSubmissions:按作业 ID 拉取所有提交列表
|
||||
* - GradeSubmission:单份提交批改
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
|
||||
// ============ Types(成绩录入) ============
|
||||
|
||||
/** 成绩录入项(单生单考试) */
|
||||
export interface GradeEntryItem {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
score: number | null;
|
||||
feedback: string | null;
|
||||
}
|
||||
|
||||
/** 批量保存成绩录入项 */
|
||||
export interface SaveGradeEntryInput {
|
||||
studentId: string;
|
||||
score: number;
|
||||
feedback?: string | null;
|
||||
}
|
||||
|
||||
/** 批量保存成绩录入输入 */
|
||||
export interface SaveGradeEntriesInput {
|
||||
examId: string;
|
||||
classId: string;
|
||||
entries: SaveGradeEntryInput[];
|
||||
}
|
||||
|
||||
/** 批量保存结果 */
|
||||
export interface SaveGradeEntriesResult {
|
||||
examId: string;
|
||||
classId: string;
|
||||
savedCount: number;
|
||||
}
|
||||
|
||||
// ============ Types(班级成绩统计) ============
|
||||
|
||||
/** 班级成绩统计 */
|
||||
export interface GradeStats {
|
||||
classId: string;
|
||||
className: string;
|
||||
subject: string;
|
||||
avg: number;
|
||||
median: number;
|
||||
max: number;
|
||||
min: number;
|
||||
passRate: number;
|
||||
stdDev: number;
|
||||
totalCount: number;
|
||||
rankings: GradeRanking[];
|
||||
}
|
||||
|
||||
/** 排名项 */
|
||||
export interface GradeRanking {
|
||||
rank: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
level: "A" | "B" | "C" | "D" | "E";
|
||||
}
|
||||
|
||||
// ============ Types(成绩分析) ============
|
||||
|
||||
/** 成绩分析聚合 */
|
||||
export interface GradeAnalytics {
|
||||
classId: string;
|
||||
className: string;
|
||||
trend: GradeTrendPoint[];
|
||||
distribution: GradeDistributionBand[];
|
||||
subjectComparison: SubjectComparisonItem[];
|
||||
classComparison: ClassComparisonItem[];
|
||||
knowledgeMastery: KnowledgeMasteryItem[];
|
||||
}
|
||||
|
||||
/** 趋势点 */
|
||||
export interface GradeTrendPoint {
|
||||
month: string;
|
||||
avg: number;
|
||||
max: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
/** 分数分布段 */
|
||||
export interface GradeDistributionBand {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 学科对比项 */
|
||||
export interface SubjectComparisonItem {
|
||||
subject: string;
|
||||
avg: number;
|
||||
}
|
||||
|
||||
/** 班级对比项 */
|
||||
export interface ClassComparisonItem {
|
||||
classId: string;
|
||||
className: string;
|
||||
avg: number;
|
||||
significant: boolean;
|
||||
}
|
||||
|
||||
/** 知识点掌握度项 */
|
||||
export interface KnowledgeMasteryItem {
|
||||
knowledgePoint: string;
|
||||
mastery: number;
|
||||
}
|
||||
|
||||
// ============ Types(报告卡) ============
|
||||
|
||||
/** 学生成绩报告卡 */
|
||||
export interface ReportCard {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
className: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
subjects: ReportCardSubject[];
|
||||
totalScore: number;
|
||||
classRank: number;
|
||||
classSize: number;
|
||||
gradeRank: number;
|
||||
gradeSize: number;
|
||||
teacherComment: string;
|
||||
}
|
||||
|
||||
/** 报告卡科目项 */
|
||||
export interface ReportCardSubject {
|
||||
subject: string;
|
||||
score: number;
|
||||
rank: number;
|
||||
teacherName: string;
|
||||
}
|
||||
|
||||
// ============ Types(作业提交) ============
|
||||
|
||||
/** 作业提交统计项(按作业维度) */
|
||||
export interface HomeworkSubmissionStat {
|
||||
homeworkId: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
status: "draft" | "published" | "graded";
|
||||
dueDate: string;
|
||||
totalCount: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
avgScore: number | null;
|
||||
}
|
||||
|
||||
/** 单份作业提交详情(含题目作答) */
|
||||
export interface HomeworkSubmissionDetail {
|
||||
submissionId: string;
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
status: "SUBMITTED" | "GRADED";
|
||||
submittedAt: string;
|
||||
totalScore: number | null;
|
||||
answers: SubmissionAnswer[];
|
||||
prevSubmissionId: string | null;
|
||||
nextSubmissionId: string | null;
|
||||
}
|
||||
|
||||
/** 单题作答 */
|
||||
export interface SubmissionAnswer {
|
||||
questionId: string;
|
||||
questionTitle: string;
|
||||
questionType: "SINGLE_CHOICE" | "MULTIPLE_CHOICE" | "SHORT_ANSWER" | "ESSAY";
|
||||
maxScore: number;
|
||||
studentAnswer: string;
|
||||
correctAnswer: string;
|
||||
score: number | null;
|
||||
feedback: string | null;
|
||||
aiSuggestion: string | null;
|
||||
}
|
||||
|
||||
/** 单份批改输入 */
|
||||
export interface GradeSubmissionInput {
|
||||
submissionId: string;
|
||||
score: number;
|
||||
feedback?: string | null;
|
||||
aiAssisted?: boolean;
|
||||
questionScores?: Array<{
|
||||
questionId: string;
|
||||
score: number;
|
||||
feedback?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 单份批改结果 */
|
||||
export interface GradeSubmissionResult {
|
||||
submissionId: string;
|
||||
status: "GRADED";
|
||||
totalScore: number;
|
||||
feedback: string | null;
|
||||
}
|
||||
|
||||
/** AI 批量评分建议项 */
|
||||
export interface AiBatchGradingItem {
|
||||
submissionId: string;
|
||||
suggestedScore: number;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
// ============ Queries / Mutations ============
|
||||
|
||||
/** 成绩录入列表查询(按 examId + classId) */
|
||||
export const GradeEntryQuery = gql`
|
||||
query GradeEntry($examId: ID!, $classId: ID!) {
|
||||
gradeEntry(examId: $examId, classId: $classId) {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
score
|
||||
feedback
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 批量保存成绩录入 Mutation */
|
||||
export const SaveGradeEntriesMutation = gql`
|
||||
mutation SaveGradeEntries($input: SaveGradeEntriesInput!) {
|
||||
saveGradeEntries(input: $input) {
|
||||
examId
|
||||
classId
|
||||
savedCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 班级成绩统计查询 */
|
||||
export const GradeStatsQuery = gql`
|
||||
query GradeStats($classId: ID!, $subject: String) {
|
||||
gradeStats(classId: $classId, subject: $subject) {
|
||||
classId
|
||||
className
|
||||
subject
|
||||
avg
|
||||
median
|
||||
max
|
||||
min
|
||||
passRate
|
||||
stdDev
|
||||
totalCount
|
||||
rankings {
|
||||
rank
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
totalScore
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 成绩分析查询 */
|
||||
export const GradeAnalyticsQuery = gql`
|
||||
query GradeAnalytics($classId: ID!, $semester: String, $examType: String) {
|
||||
gradeAnalytics(classId: $classId, semester: $semester, examType: $examType) {
|
||||
classId
|
||||
className
|
||||
trend {
|
||||
month
|
||||
avg
|
||||
max
|
||||
min
|
||||
}
|
||||
distribution {
|
||||
label
|
||||
min
|
||||
max
|
||||
count
|
||||
}
|
||||
subjectComparison {
|
||||
subject
|
||||
avg
|
||||
}
|
||||
classComparison {
|
||||
classId
|
||||
className
|
||||
avg
|
||||
significant
|
||||
}
|
||||
knowledgeMastery {
|
||||
knowledgePoint
|
||||
mastery
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 学生成绩报告卡查询 */
|
||||
export const ReportCardQuery = gql`
|
||||
query ReportCard($studentId: ID!, $academicYear: String, $semester: String) {
|
||||
reportCard(studentId: $studentId, academicYear: $academicYear, semester: $semester) {
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
className
|
||||
academicYear
|
||||
semester
|
||||
subjects {
|
||||
subject
|
||||
score
|
||||
rank
|
||||
teacherName
|
||||
}
|
||||
totalScore
|
||||
classRank
|
||||
classSize
|
||||
gradeRank
|
||||
gradeSize
|
||||
teacherComment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 作业提交统计列表查询(按作业维度) */
|
||||
export const HomeworkSubmissionsQuery = gql`
|
||||
query HomeworkSubmissions($classId: ID, $status: String) {
|
||||
homeworkSubmissions(classId: $classId, status: $status) {
|
||||
homeworkId
|
||||
title
|
||||
classId
|
||||
className
|
||||
status
|
||||
dueDate
|
||||
totalCount
|
||||
submittedCount
|
||||
gradedCount
|
||||
avgScore
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 按作业 ID 拉取所有提交列表查询 */
|
||||
export const HomeworkAssignmentSubmissionsQuery = gql`
|
||||
query HomeworkAssignmentSubmissions($homeworkId: ID!) {
|
||||
homeworkAssignmentSubmissions(homeworkId: $homeworkId) {
|
||||
submissionId
|
||||
homeworkId
|
||||
homeworkTitle
|
||||
classId
|
||||
className
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
status
|
||||
submittedAt
|
||||
totalScore
|
||||
answers {
|
||||
questionId
|
||||
questionTitle
|
||||
questionType
|
||||
maxScore
|
||||
studentAnswer
|
||||
correctAnswer
|
||||
score
|
||||
feedback
|
||||
aiSuggestion
|
||||
}
|
||||
prevSubmissionId
|
||||
nextSubmissionId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 单份提交批改 Mutation */
|
||||
export const GradeSubmissionMutation = gql`
|
||||
mutation GradeSubmission($input: GradeSubmissionInput!) {
|
||||
gradeSubmission(input: $input) {
|
||||
submissionId
|
||||
status
|
||||
totalScore
|
||||
feedback
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** AI 批量评分建议查询(mock:返回 AI 评分建议列表) */
|
||||
export const AiBatchGradingQuery = gql`
|
||||
query AiBatchGrading($homeworkId: ID!) {
|
||||
aiBatchGrading(homeworkId: $homeworkId) {
|
||||
submissionId
|
||||
suggestedScore
|
||||
suggestion
|
||||
}
|
||||
}
|
||||
`;
|
||||
737
apps/teacher-portal/src/lib/graphql-p7-insights.ts
Normal file
737
apps/teacher-portal/src/lib/graphql-p7-insights.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(备课 + 课程计划 + 诊断 + 错题本 + 自适应练习)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
|
||||
*
|
||||
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
|
||||
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
|
||||
*
|
||||
* 查询范围(P7-insights):
|
||||
* - LessonPlans / LessonPlanDetail / LessonPlanLibrary / LessonPlanCalendar / LessonPlanHeatmap
|
||||
* - CreateLessonPlan / UpdateLessonPlan / AIGenerateLessonPlan / ForkLessonPlan
|
||||
* - CoursePlans / CoursePlanDetail / CreateCoursePlan / UpdateCoursePlan
|
||||
* - DiagnosticReports / ClassDiagnostic / StudentDiagnostic
|
||||
* - ErrorBook(错题分析聚合)
|
||||
* - PracticeAnalytics(自适应练习分析聚合)
|
||||
*/
|
||||
|
||||
import { gql } from "urql";
|
||||
|
||||
// ============ Types(课案) ============
|
||||
|
||||
export type LessonPlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export type LessonPlanTemplate =
|
||||
| "REGULAR"
|
||||
| "UNIT"
|
||||
| "REVIEW"
|
||||
| "ACTIVITY"
|
||||
| "PERSONALIZED";
|
||||
|
||||
export interface LessonPlanAIHistoryItem {
|
||||
id: string;
|
||||
prompt: string;
|
||||
generatedAt: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface LessonPlanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
status: LessonPlanStatus;
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
classIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LessonPlanDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
content: string;
|
||||
status: LessonPlanStatus;
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
classIds: string[];
|
||||
aiHistory: LessonPlanAIHistoryItem[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LessonPlanLibraryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
rating: number;
|
||||
forkCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LessonPlanCalendarDay {
|
||||
date: string;
|
||||
planCount: number;
|
||||
planTitles: string[];
|
||||
}
|
||||
|
||||
export interface LessonPlanCalendarMonth {
|
||||
year: number;
|
||||
month: number;
|
||||
days: LessonPlanCalendarDay[];
|
||||
}
|
||||
|
||||
export interface LessonPlanHeatmapCell {
|
||||
textbookKp: string;
|
||||
planIds: string[];
|
||||
covered: boolean;
|
||||
}
|
||||
|
||||
export interface LessonPlanHeatmap {
|
||||
rows: string[];
|
||||
cols: string[];
|
||||
cells: LessonPlanHeatmapCell[];
|
||||
}
|
||||
|
||||
export interface CreateLessonPlanInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
textbookId?: string | null;
|
||||
template: LessonPlanTemplate;
|
||||
}
|
||||
|
||||
export interface UpdateLessonPlanInput {
|
||||
planId: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
status?: LessonPlanStatus;
|
||||
textbookId?: string | null;
|
||||
chapter?: string;
|
||||
classIds?: string[];
|
||||
}
|
||||
|
||||
export interface AIGenerateLessonPlanInput {
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface AIGenerateLessonPlanResult {
|
||||
id: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
model: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
// ============ Types(课程计划) ============
|
||||
|
||||
export type CoursePlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export interface CoursePlanChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
hours: number;
|
||||
objectives: string;
|
||||
keyPoints: string;
|
||||
activities: string;
|
||||
}
|
||||
|
||||
export interface CoursePlanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
totalHours: number;
|
||||
status: CoursePlanStatus;
|
||||
textbookId: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CoursePlanDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
totalHours: number;
|
||||
status: CoursePlanStatus;
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
homeworkCount: number;
|
||||
chapters: CoursePlanChapter[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateCoursePlanInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
totalHours: number;
|
||||
textbookId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateCoursePlanInput {
|
||||
id: string;
|
||||
title?: string;
|
||||
status?: CoursePlanStatus;
|
||||
chapters?: CoursePlanChapter[];
|
||||
}
|
||||
|
||||
// ============ Types(诊断) ============
|
||||
|
||||
export type DiagnosticReportType = "INDIVIDUAL" | "CLASS" | "GRADE";
|
||||
export type DiagnosticReportStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export interface DiagnosticReportItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: DiagnosticReportType;
|
||||
targetType: string;
|
||||
status: DiagnosticReportStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticKpMastery {
|
||||
knowledgePoint: string;
|
||||
avg: number;
|
||||
median: number;
|
||||
studentCount: number;
|
||||
distribution: { mastered: number; partial: number; weak: number };
|
||||
}
|
||||
|
||||
export interface DiagnosticStudentRank {
|
||||
rank: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
masteryAvg: number;
|
||||
}
|
||||
|
||||
export interface ClassDiagnostic {
|
||||
classId: string;
|
||||
className: string;
|
||||
studentCount: number;
|
||||
summary: { knowledgePoint: string; avg: number }[];
|
||||
kpMastery: DiagnosticKpMastery[];
|
||||
rankings: DiagnosticStudentRank[];
|
||||
}
|
||||
|
||||
export interface DiagnosticRadarPoint {
|
||||
axis: string;
|
||||
value: number;
|
||||
classAvg: number;
|
||||
}
|
||||
|
||||
export interface StudentDiagnostic {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
radar: DiagnosticRadarPoint[];
|
||||
reports: { id: string; title: string; createdAt: string; status: DiagnosticReportStatus }[];
|
||||
}
|
||||
|
||||
// ============ Types(错题本) ============
|
||||
|
||||
export interface ErrorBookSummary {
|
||||
totalErrors: number;
|
||||
highFreqErrors: number;
|
||||
weakKpCount: number;
|
||||
avgErrorRate: number;
|
||||
trendUp: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorBookClassComparison {
|
||||
classId: string;
|
||||
className: string;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export interface ErrorBookChapterWeakness {
|
||||
chapter: string;
|
||||
errorCount: number;
|
||||
}
|
||||
|
||||
export interface ErrorBookKpWeakness {
|
||||
knowledgePoint: string;
|
||||
errorRate: number;
|
||||
}
|
||||
|
||||
export interface ErrorBookStudentGroup {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
errorCount: number;
|
||||
topErrors: { questionId: string; content: string; errorRate: number }[];
|
||||
}
|
||||
|
||||
export interface ErrorBookTopQuestion {
|
||||
questionId: string;
|
||||
content: string;
|
||||
errorRate: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export interface ErrorBook {
|
||||
subject: string;
|
||||
classId: string;
|
||||
summary: ErrorBookSummary;
|
||||
classComparison: ErrorBookClassComparison[];
|
||||
chapterWeakness: ErrorBookChapterWeakness[];
|
||||
kpWeakness: ErrorBookKpWeakness[];
|
||||
studentGroups: ErrorBookStudentGroup[];
|
||||
topQuestions: ErrorBookTopQuestion[];
|
||||
}
|
||||
|
||||
// ============ Types(练习) ============
|
||||
|
||||
export interface PracticeSummary {
|
||||
totalSessions: number;
|
||||
completionRate: number;
|
||||
avgAccuracy: number;
|
||||
weakKpCount: number;
|
||||
participationRate: number;
|
||||
}
|
||||
|
||||
export interface PracticeClassComparison {
|
||||
classId: string;
|
||||
className: string;
|
||||
totalSessions: number;
|
||||
completionRate: number;
|
||||
avgAccuracy: number;
|
||||
participationRate: number;
|
||||
weakKpCount: number;
|
||||
}
|
||||
|
||||
export interface PracticeTypeBreakdown {
|
||||
type: string;
|
||||
count: number;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
export interface PracticeKpWeakness {
|
||||
knowledgePoint: string;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
export interface PracticeStudentRank {
|
||||
rank: number;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalSessions: number;
|
||||
accuracy: number;
|
||||
weakKp: string;
|
||||
}
|
||||
|
||||
export interface PracticeInactiveStudent {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
lastActiveDays: number;
|
||||
}
|
||||
|
||||
export interface PracticeAnalytics {
|
||||
classId: string;
|
||||
summary: PracticeSummary;
|
||||
classComparison: PracticeClassComparison[];
|
||||
typeBreakdown: PracticeTypeBreakdown[];
|
||||
kpWeakness: PracticeKpWeakness[];
|
||||
rankings: PracticeStudentRank[];
|
||||
inactiveStudents: PracticeInactiveStudent[];
|
||||
}
|
||||
|
||||
// ============ Queries / Mutations(课案) ============
|
||||
|
||||
export const LessonPlansQuery = gql`
|
||||
query LessonPlans($subject: String) {
|
||||
lessonPlans(subject: $subject) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
chapter
|
||||
status
|
||||
textbookId
|
||||
textbookTitle
|
||||
classIds
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LessonPlanDetailQuery = gql`
|
||||
query LessonPlanDetail($planId: ID!) {
|
||||
lessonPlanDetail(planId: $planId) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
chapter
|
||||
content
|
||||
status
|
||||
textbookId
|
||||
textbookTitle
|
||||
classIds
|
||||
aiHistory {
|
||||
id
|
||||
prompt
|
||||
generatedAt
|
||||
model
|
||||
}
|
||||
updatedAt
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CreateLessonPlanMutation = gql`
|
||||
mutation CreateLessonPlan($input: CreateLessonPlanInput!) {
|
||||
createLessonPlan(input: $input) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
chapter
|
||||
status
|
||||
textbookId
|
||||
textbookTitle
|
||||
classIds
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateLessonPlanMutation = gql`
|
||||
mutation UpdateLessonPlan($input: UpdateLessonPlanInput!) {
|
||||
updateLessonPlan(input: $input) {
|
||||
id
|
||||
title
|
||||
content
|
||||
status
|
||||
textbookId
|
||||
chapter
|
||||
classIds
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AIGenerateLessonPlanMutation = gql`
|
||||
mutation AIGenerateLessonPlan($input: AIGenerateLessonPlanInput!) {
|
||||
aiGenerateLessonPlan(input: $input) {
|
||||
id
|
||||
content
|
||||
summary
|
||||
model
|
||||
generatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LessonPlanLibraryQuery = gql`
|
||||
query LessonPlanLibrary($subject: String, $grade: String) {
|
||||
lessonPlanLibrary(subject: $subject, grade: $grade) {
|
||||
id
|
||||
title
|
||||
author
|
||||
subject
|
||||
grade
|
||||
chapter
|
||||
rating
|
||||
forkCount
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ForkLessonPlanMutation = gql`
|
||||
mutation ForkLessonPlan($planId: ID!) {
|
||||
forkLessonPlan(planId: $planId) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
chapter
|
||||
status
|
||||
textbookId
|
||||
textbookTitle
|
||||
classIds
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LessonPlanCalendarQuery = gql`
|
||||
query LessonPlanCalendar($year: Int!, $month: Int!) {
|
||||
lessonPlanCalendar(year: $year, month: $month) {
|
||||
year
|
||||
month
|
||||
days {
|
||||
date
|
||||
planCount
|
||||
planTitles
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LessonPlanHeatmapQuery = gql`
|
||||
query LessonPlanHeatmap {
|
||||
lessonPlanHeatmap {
|
||||
rows
|
||||
cols
|
||||
cells {
|
||||
textbookKp
|
||||
planIds
|
||||
covered
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries / Mutations(课程计划) ============
|
||||
|
||||
export const CoursePlansQuery = gql`
|
||||
query CoursePlans($status: CoursePlanStatus) {
|
||||
coursePlans(status: $status) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
academicYear
|
||||
semester
|
||||
totalHours
|
||||
status
|
||||
textbookId
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CoursePlanDetailQuery = gql`
|
||||
query CoursePlanDetail($id: ID!) {
|
||||
coursePlanDetail(id: $id) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
academicYear
|
||||
semester
|
||||
totalHours
|
||||
status
|
||||
textbookId
|
||||
textbookTitle
|
||||
homeworkCount
|
||||
chapters {
|
||||
id
|
||||
title
|
||||
hours
|
||||
objectives
|
||||
keyPoints
|
||||
activities
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CreateCoursePlanMutation = gql`
|
||||
mutation CreateCoursePlan($input: CreateCoursePlanInput!) {
|
||||
createCoursePlan(input: $input) {
|
||||
id
|
||||
title
|
||||
subject
|
||||
grade
|
||||
academicYear
|
||||
semester
|
||||
totalHours
|
||||
status
|
||||
textbookId
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UpdateCoursePlanMutation = gql`
|
||||
mutation UpdateCoursePlan($input: UpdateCoursePlanInput!) {
|
||||
updateCoursePlan(input: $input) {
|
||||
id
|
||||
title
|
||||
status
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries(诊断) ============
|
||||
|
||||
export const DiagnosticReportsQuery = gql`
|
||||
query DiagnosticReports($type: DiagnosticReportType, $status: DiagnosticReportStatus) {
|
||||
diagnosticReports(type: $type, status: $status) {
|
||||
id
|
||||
title
|
||||
type
|
||||
targetType
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ClassDiagnosticQuery = gql`
|
||||
query ClassDiagnostic($classId: ID!) {
|
||||
classDiagnostic(classId: $classId) {
|
||||
classId
|
||||
className
|
||||
studentCount
|
||||
summary {
|
||||
knowledgePoint
|
||||
avg
|
||||
}
|
||||
kpMastery {
|
||||
knowledgePoint
|
||||
avg
|
||||
median
|
||||
studentCount
|
||||
distribution {
|
||||
mastered
|
||||
partial
|
||||
weak
|
||||
}
|
||||
}
|
||||
rankings {
|
||||
rank
|
||||
studentId
|
||||
studentName
|
||||
masteryAvg
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StudentDiagnosticQuery = gql`
|
||||
query StudentDiagnostic($studentId: ID!) {
|
||||
studentDiagnostic(studentId: $studentId) {
|
||||
studentId
|
||||
studentName
|
||||
radar {
|
||||
axis
|
||||
value
|
||||
classAvg
|
||||
}
|
||||
reports {
|
||||
id
|
||||
title
|
||||
createdAt
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ Queries(错题本 / 练习) ============
|
||||
|
||||
export const ErrorBookQuery = gql`
|
||||
query ErrorBook($subject: String!, $classId: String) {
|
||||
errorBook(subject: $subject, classId: $classId) {
|
||||
subject
|
||||
classId
|
||||
summary {
|
||||
totalErrors
|
||||
highFreqErrors
|
||||
weakKpCount
|
||||
avgErrorRate
|
||||
trendUp
|
||||
}
|
||||
classComparison {
|
||||
classId
|
||||
className
|
||||
errorCount
|
||||
}
|
||||
chapterWeakness {
|
||||
chapter
|
||||
errorCount
|
||||
}
|
||||
kpWeakness {
|
||||
knowledgePoint
|
||||
errorRate
|
||||
}
|
||||
studentGroups {
|
||||
studentId
|
||||
studentName
|
||||
errorCount
|
||||
topErrors {
|
||||
questionId
|
||||
content
|
||||
errorRate
|
||||
}
|
||||
}
|
||||
topQuestions {
|
||||
questionId
|
||||
content
|
||||
errorRate
|
||||
subject
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PracticeAnalyticsQuery = gql`
|
||||
query PracticeAnalytics($classId: String) {
|
||||
practiceAnalytics(classId: $classId) {
|
||||
classId
|
||||
summary {
|
||||
totalSessions
|
||||
completionRate
|
||||
avgAccuracy
|
||||
weakKpCount
|
||||
participationRate
|
||||
}
|
||||
classComparison {
|
||||
classId
|
||||
className
|
||||
totalSessions
|
||||
completionRate
|
||||
avgAccuracy
|
||||
participationRate
|
||||
weakKpCount
|
||||
}
|
||||
typeBreakdown {
|
||||
type
|
||||
count
|
||||
ratio
|
||||
}
|
||||
kpWeakness {
|
||||
knowledgePoint
|
||||
accuracy
|
||||
}
|
||||
rankings {
|
||||
rank
|
||||
studentId
|
||||
studentName
|
||||
studentNo
|
||||
totalSessions
|
||||
accuracy
|
||||
weakKp
|
||||
}
|
||||
inactiveStudents {
|
||||
studentId
|
||||
studentName
|
||||
lastActiveDays
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -300,3 +300,173 @@ export const UpdateUserMutation = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ P3 Mutation/Query(MSW mock,core-edu 域) ============
|
||||
// 以下 operations 用于考试创建/作业布置/成绩录入/详情查询(workline.md §3 P3 交付物)。
|
||||
// 开发期通过 MSW 拦截返回 mock 响应;上游就绪后由 ai03 在 teacher-bff.graphql 扩展。
|
||||
|
||||
/** 创建考试输入 */
|
||||
export interface CreateExamInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
examDate: string;
|
||||
duration: number;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
/** 创建考试 Mutation(P3 扩展,MSW mock) */
|
||||
export const CreateExamMutation = gql`
|
||||
mutation CreateExam($input: CreateExamInput!) {
|
||||
createExam(input: $input) {
|
||||
id
|
||||
classId
|
||||
title
|
||||
description
|
||||
examDate
|
||||
duration
|
||||
totalScore
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 布置作业输入 */
|
||||
export interface AssignHomeworkInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate: string;
|
||||
}
|
||||
|
||||
/** 布置作业 Mutation(P3 扩展,MSW mock) */
|
||||
export const AssignHomeworkMutation = gql`
|
||||
mutation AssignHomework($input: AssignHomeworkInput!) {
|
||||
assignHomework(input: $input) {
|
||||
id
|
||||
classId
|
||||
title
|
||||
description
|
||||
dueDate
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 录入成绩输入 */
|
||||
export interface RecordGradeInput {
|
||||
studentId: string;
|
||||
examId?: string | null;
|
||||
homeworkId?: string | null;
|
||||
score: number;
|
||||
feedback?: string | null;
|
||||
}
|
||||
|
||||
/** 录入成绩 Mutation(P3 扩展,MSW mock) */
|
||||
export const RecordGradeMutation = gql`
|
||||
mutation RecordGrade($input: RecordGradeInput!) {
|
||||
recordGrade(input: $input) {
|
||||
id
|
||||
studentId
|
||||
studentName
|
||||
examId
|
||||
homeworkId
|
||||
score
|
||||
feedback
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 考试题目(core-edu 域,P3 扩展) */
|
||||
export interface ExamQuestion {
|
||||
id: string;
|
||||
examId: string;
|
||||
title: string;
|
||||
type: "SINGLE_CHOICE" | "MULTIPLE_CHOICE" | "SHORT_ANSWER" | "ESSAY";
|
||||
score: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 考试详情(含题目列表) */
|
||||
export interface ExamDetail {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
examDate: string;
|
||||
duration: number;
|
||||
totalScore: number;
|
||||
status: ExamStatus;
|
||||
questions: ExamQuestion[];
|
||||
}
|
||||
|
||||
/** 考试详情查询(P3 扩展,MSW mock) */
|
||||
export const ExamDetailQuery = gql`
|
||||
query ExamDetail($id: ID!) {
|
||||
examDetail(id: $id) {
|
||||
id
|
||||
classId
|
||||
title
|
||||
description
|
||||
examDate
|
||||
duration
|
||||
totalScore
|
||||
status
|
||||
questions {
|
||||
id
|
||||
examId
|
||||
title
|
||||
type
|
||||
score
|
||||
order
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 作业提交(core-edu 域,P3 扩展) */
|
||||
export interface HomeworkSubmission {
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
status: SubmissionStatus;
|
||||
score: number | null;
|
||||
feedback: string | null;
|
||||
submittedAt: string | null;
|
||||
}
|
||||
|
||||
/** 作业详情(含提交列表) */
|
||||
export interface HomeworkDetail {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
dueDate: string;
|
||||
status: SubmissionStatus;
|
||||
submissions: HomeworkSubmission[];
|
||||
}
|
||||
|
||||
/** 作业详情查询(P3 扩展,MSW mock) */
|
||||
export const HomeworkDetailQuery = gql`
|
||||
query HomeworkDetail($id: ID!) {
|
||||
homeworkDetail(id: $id) {
|
||||
id
|
||||
classId
|
||||
title
|
||||
description
|
||||
dueDate
|
||||
status
|
||||
submissions {
|
||||
id
|
||||
homeworkId
|
||||
studentId
|
||||
studentName
|
||||
status
|
||||
score
|
||||
feedback
|
||||
submittedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
272
apps/teacher-portal/src/lib/observability/a11y.ts
Normal file
272
apps/teacher-portal/src/lib/observability/a11y.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* A11y 审计工具(P6 硬化)
|
||||
*
|
||||
* - runA11yAudit() - 运行 axe-core 审计(条件加载,动态 import)
|
||||
* - checkContrast() - 对比度检查工具
|
||||
* - generateA11yReport() - 生成审计报告
|
||||
*
|
||||
* 注意:axe-core 未安装,使用动态 import 在运行时按需加载。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / WCAG 2.2 AA 规范
|
||||
*/
|
||||
|
||||
/**
|
||||
* WCAG 2.2 AA 对比度阈值。
|
||||
*
|
||||
* - 普通文本:4.5:1
|
||||
* - 大文本(18pt+ 或 14pt 粗体):3.0:1
|
||||
* - 非文本组件(UI 边框/图标):3.0:1
|
||||
*/
|
||||
export const CONTRAST_THRESHOLDS = {
|
||||
normalText: 4.5,
|
||||
largeText: 3.0,
|
||||
nonTextComponents: 3.0,
|
||||
} as const;
|
||||
|
||||
/** A11y 审计结果级别 */
|
||||
export type A11yIssueLevel = "minor" | "moderate" | "serious" | "critical";
|
||||
|
||||
/** A11y 审计单个问题 */
|
||||
export interface A11yIssue {
|
||||
/** 规则 ID(如 "color-contrast") */
|
||||
id: string;
|
||||
/** 问题级别 */
|
||||
level: A11yIssueLevel;
|
||||
/** 问题描述 */
|
||||
description: string;
|
||||
/** 受影响元素的选择器 */
|
||||
selector: string;
|
||||
/** 修复建议 */
|
||||
help: string;
|
||||
/** 帮助文档 URL */
|
||||
helpUrl: string;
|
||||
}
|
||||
|
||||
/** A11y 审计报告 */
|
||||
export interface A11yAuditReport {
|
||||
/** 审计时间戳 */
|
||||
timestamp: number;
|
||||
/** 页面 URL */
|
||||
url: string;
|
||||
/** 通过的规则数 */
|
||||
passes: number;
|
||||
/** 违规规则数 */
|
||||
violations: number;
|
||||
/** 不完整规则数 */
|
||||
incomplete: number;
|
||||
/** 问题列表 */
|
||||
issues: A11yIssue[];
|
||||
/** 是否通过 WCAG 2.2 AA */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* axe-core 的最小类型声明(仅声明使用的 API 子集)。
|
||||
*/
|
||||
interface AxeResult {
|
||||
passes: unknown[];
|
||||
violations: Array<{
|
||||
id: string;
|
||||
impact: A11yIssueLevel;
|
||||
description: string;
|
||||
help: string;
|
||||
helpUrl: string;
|
||||
nodes: Array<{ target: string[] }>;
|
||||
}>;
|
||||
incomplete: unknown[];
|
||||
}
|
||||
|
||||
interface AxeModule {
|
||||
default: (options: {
|
||||
runOnly?: { type: string; values: string[] };
|
||||
}) => Promise<AxeResult>;
|
||||
}
|
||||
|
||||
/** axe-core 的最小配置(启用 WCAG 2.2 AA 规则集) */
|
||||
const AXE_RUN_OPTIONS = {
|
||||
runOnly: {
|
||||
type: "tag" as const,
|
||||
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行 axe-core A11y 审计。
|
||||
*
|
||||
* 动态 import axe-core,避免未安装包导致构建失败。
|
||||
* 仅在浏览器环境运行(需要 DOM)。
|
||||
*
|
||||
* @returns 审计结果数组(违规项),加载失败返回空数组
|
||||
*/
|
||||
export async function runA11yAudit(): Promise<A11yIssue[]> {
|
||||
if (typeof window === "undefined") return [];
|
||||
if (typeof document === "undefined") return [];
|
||||
|
||||
try {
|
||||
const axeMod = (await import("axe-core")) as unknown as AxeModule;
|
||||
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
|
||||
const result = await axe(AXE_RUN_OPTIONS);
|
||||
|
||||
return result.violations.map((violation) => {
|
||||
const node = violation.nodes[0];
|
||||
return {
|
||||
id: violation.id,
|
||||
level: violation.impact,
|
||||
description: violation.description,
|
||||
selector: node ? node.target.join(", ") : "",
|
||||
help: violation.help,
|
||||
helpUrl: violation.helpUrl,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] axe-core 加载失败,跳过 A11y 审计", err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 hex 颜色转换为相对亮度值(0~1)。
|
||||
*
|
||||
* 依据 WCAG 2.x 相对亮度公式:
|
||||
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||
*/
|
||||
function relativeLuminance(hex: string): number | null {
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length !== 6 && cleaned.length !== 3) return null;
|
||||
|
||||
const fullHex =
|
||||
cleaned.length === 3
|
||||
? cleaned
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: cleaned;
|
||||
|
||||
const r = parseInt(fullHex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(fullHex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(fullHex.slice(4, 6), 16) / 255;
|
||||
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
|
||||
const toLinear = (c: number): number =>
|
||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
|
||||
const R = toLinear(r);
|
||||
const G = toLinear(g);
|
||||
const B = toLinear(b);
|
||||
|
||||
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比度检查工具。
|
||||
*
|
||||
* 计算两个 hex 颜色之间的对比度比值(1~21)。
|
||||
*
|
||||
* @param foreground 前景色(hex,如 "#000000")
|
||||
* @param background 背景色(hex,如 "#ffffff")
|
||||
* @param isLargeText 是否大文本(默认 false,使用 4.5:1 阈值)
|
||||
* @returns 是否通过 WCAG 2.2 AA 对比度要求(无效颜色返回 false)
|
||||
*/
|
||||
export function checkContrast(
|
||||
foreground: string,
|
||||
background: string,
|
||||
isLargeText = false,
|
||||
): boolean {
|
||||
const fg = relativeLuminance(foreground);
|
||||
const bg = relativeLuminance(background);
|
||||
if (fg === null || bg === null) return false;
|
||||
|
||||
const lighter = Math.max(fg, bg);
|
||||
const darker = Math.min(fg, bg);
|
||||
const ratio = (lighter + 0.05) / (darker + 0.05);
|
||||
|
||||
const threshold = isLargeText
|
||||
? CONTRAST_THRESHOLDS.largeText
|
||||
: CONTRAST_THRESHOLDS.normalText;
|
||||
|
||||
return ratio >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两个颜色之间的对比度比值。
|
||||
*
|
||||
* @returns 对比度比值(1~21),无效颜色返回 null
|
||||
*/
|
||||
export function getContrastRatio(
|
||||
foreground: string,
|
||||
background: string,
|
||||
): number | null {
|
||||
const fg = relativeLuminance(foreground);
|
||||
const bg = relativeLuminance(background);
|
||||
if (fg === null || bg === null) return null;
|
||||
|
||||
const lighter = Math.max(fg, bg);
|
||||
const darker = Math.min(fg, bg);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 A11y 审计报告。
|
||||
*
|
||||
* 运行 axe-core 审计并汇总为结构化报告。
|
||||
*
|
||||
* @returns 审计报告(加载失败返回空报告)
|
||||
*/
|
||||
export async function generateA11yReport(): Promise<A11yAuditReport> {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
url: "",
|
||||
passes: 0,
|
||||
violations: 0,
|
||||
incomplete: 0,
|
||||
issues: [],
|
||||
passed: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const axeMod = (await import("axe-core")) as unknown as AxeModule;
|
||||
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
|
||||
const result = await axe(AXE_RUN_OPTIONS);
|
||||
|
||||
const issues: A11yIssue[] = result.violations.map((violation) => {
|
||||
const node = violation.nodes[0];
|
||||
return {
|
||||
id: violation.id,
|
||||
level: violation.impact,
|
||||
description: violation.description,
|
||||
selector: node ? node.target.join(", ") : "",
|
||||
help: violation.help,
|
||||
helpUrl: violation.helpUrl,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
url: window.location.href,
|
||||
passes: result.passes.length,
|
||||
violations: result.violations.length,
|
||||
incomplete: result.incomplete.length,
|
||||
issues,
|
||||
passed: result.violations.length === 0,
|
||||
};
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] A11y 审计失败", err);
|
||||
}
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
url: typeof window !== "undefined" ? window.location.href : "",
|
||||
passes: 0,
|
||||
violations: 0,
|
||||
incomplete: 0,
|
||||
issues: [],
|
||||
passed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
apps/teacher-portal/src/lib/observability/cookie-migration.ts
Normal file
189
apps/teacher-portal/src/lib/observability/cookie-migration.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Cookie 迁移准备(P6 硬化)
|
||||
*
|
||||
* - migrateTokenToCookie() - 将 localStorage token 迁移到 httpOnly cookie
|
||||
* - checkCookieSupport() - 检测浏览器是否支持 Secure + SameSite cookie
|
||||
* - isCookieMigrationEnabled() - 检查是否启用迁移(环境变量)
|
||||
* - 迁移状态枚举
|
||||
*
|
||||
* 注意:实际迁移在 iam refresh cookie 端点就绪后启用,此处仅做准备。
|
||||
* 关联:project_rules §4 安全规范(Cookie: httpOnly + Secure + SameSite=Strict)
|
||||
* 02-architecture-design.md §2.1 会话状态
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* 迁移状态枚举。
|
||||
*/
|
||||
export enum CookieMigrationStatus {
|
||||
/** 未启用迁移 */
|
||||
Disabled = "disabled",
|
||||
/** 浏览器不支持必要的 Cookie 特性 */
|
||||
Unsupported = "unsupported",
|
||||
/** localStorage 中无 token,无需迁移 */
|
||||
NoToken = "no_token",
|
||||
/** 迁移进行中 */
|
||||
InProgress = "in_progress",
|
||||
/** 迁移成功 */
|
||||
Completed = "completed",
|
||||
/** 迁移失败(iam 端点未就绪或网络错误) */
|
||||
Failed = "failed",
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 中存储的 token key(与 auth.ts 保持一致)。
|
||||
*
|
||||
* 注意:此处直接引用 key 字符串,不导入 auth.ts,避免循环依赖。
|
||||
* auth.ts 使用 "edu_access_token"。
|
||||
*/
|
||||
const LEGACY_TOKEN_KEY = "edu_access_token";
|
||||
|
||||
/** iam refresh cookie 端点(端点就绪后启用) */
|
||||
const IAM_REFRESH_COOKIE_ENDPOINT = "/api/v1/iam/auth/refresh-cookie";
|
||||
|
||||
/**
|
||||
* 检查是否启用 Cookie 迁移(通过环境变量控制)。
|
||||
*
|
||||
* 实际迁移在 iam refresh cookie 端点就绪后由运维开启。
|
||||
*
|
||||
* @returns 是否启用迁移
|
||||
*/
|
||||
export function isCookieMigrationEnabled(): boolean {
|
||||
return getObservabilityConfig().cookieMigrationEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测浏览器是否支持 Secure + SameSite=Strict cookie。
|
||||
*
|
||||
* 通过设置测试 cookie 并读回验证。仅在浏览器环境运行。
|
||||
*
|
||||
* @returns 是否支持必要的 Cookie 特性
|
||||
*/
|
||||
export function checkCookieSupport(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
try {
|
||||
// 测试 Secure + SameSite=Strict cookie
|
||||
const testCookie = "edu_cookie_test=1; Secure; SameSite=Strict; max-age=1";
|
||||
document.cookie = testCookie;
|
||||
|
||||
// 检查是否能读回(不支持 Secure 时 HTTPS 以外环境设置会失败)
|
||||
const supported = document.cookie.includes("edu_cookie_test");
|
||||
|
||||
// 清理测试 cookie
|
||||
document.cookie =
|
||||
"edu_cookie_test=; Secure; SameSite=Strict; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
|
||||
return supported;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 HTTPS 安全上下文(Secure cookie 需要)。
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// window.isSecureContext 是标准 API
|
||||
return (
|
||||
typeof window.isSecureContext === "boolean" ? window.isSecureContext : false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取 legacy token(仅检查是否存在,不暴露 token 值)。
|
||||
*/
|
||||
function hasLegacyToken(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return localStorage.getItem(LEGACY_TOKEN_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 localStorage token 迁移到 httpOnly cookie。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 检查是否启用迁移(环境变量)
|
||||
* 2. 检查浏览器 Cookie 支持
|
||||
* 3. 检查 localStorage 是否有 token
|
||||
* 4. 调用 iam refresh cookie 端点(服务端设置 httpOnly cookie)
|
||||
* 5. 成功后清除 localStorage token
|
||||
*
|
||||
* 注意:iam refresh cookie 端点未就绪时返回 Failed,不影响现有功能。
|
||||
*
|
||||
* @returns 迁移状态
|
||||
*/
|
||||
export async function migrateTokenToCookie(): Promise<CookieMigrationStatus> {
|
||||
// 1. 检查是否启用迁移
|
||||
if (!isCookieMigrationEnabled()) {
|
||||
return CookieMigrationStatus.Disabled;
|
||||
}
|
||||
|
||||
// 2. 检查浏览器 Cookie 支持
|
||||
if (!checkCookieSupport()) {
|
||||
return CookieMigrationStatus.Unsupported;
|
||||
}
|
||||
|
||||
// 3. 检查 localStorage 是否有 token
|
||||
if (!hasLegacyToken()) {
|
||||
return CookieMigrationStatus.NoToken;
|
||||
}
|
||||
|
||||
// 4. 调用 iam refresh cookie 端点
|
||||
// 端点未就绪时返回 Failed(不抛异常,不影响现有功能)
|
||||
try {
|
||||
const response = await fetch(IAM_REFRESH_COOKIE_ENDPOINT, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// iam 端点未就绪(404)或认证失败(401),返回 Failed
|
||||
return CookieMigrationStatus.Failed;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { success?: boolean };
|
||||
if (!data.success) {
|
||||
return CookieMigrationStatus.Failed;
|
||||
}
|
||||
|
||||
// 5. 成功后清除 localStorage token(httpOnly cookie 已由服务端设置)
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
} catch {
|
||||
// 清除失败不影响迁移成功状态(cookie 已生效)
|
||||
}
|
||||
}
|
||||
|
||||
return CookieMigrationStatus.Completed;
|
||||
} catch {
|
||||
// 网络错误或端点不可达
|
||||
return CookieMigrationStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查迁移状态(非破坏性,不调用 iam 端点)。
|
||||
*
|
||||
* 用于在应用启动时判断是否需要提示用户或执行迁移。
|
||||
*/
|
||||
export function getMigrationStatus(): CookieMigrationStatus {
|
||||
if (!isCookieMigrationEnabled()) {
|
||||
return CookieMigrationStatus.Disabled;
|
||||
}
|
||||
if (!checkCookieSupport()) {
|
||||
return CookieMigrationStatus.Unsupported;
|
||||
}
|
||||
if (!hasLegacyToken()) {
|
||||
return CookieMigrationStatus.NoToken;
|
||||
}
|
||||
// 有 token 且环境支持,等待迁移
|
||||
return CookieMigrationStatus.InProgress;
|
||||
}
|
||||
113
apps/teacher-portal/src/lib/observability/env.ts
Normal file
113
apps/teacher-portal/src/lib/observability/env.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 可观测性环境变量集中管理(P6 硬化)
|
||||
*
|
||||
* - 类型安全的环境变量访问
|
||||
* - 统一所有可观测性模块(Sentry / Web Vitals / OTel / Cookie 迁移)的配置入口
|
||||
* - 所有变量必须以 NEXT_PUBLIC_ 前缀(project_rules §4 安全规范)
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
/**
|
||||
* 可观测性配置对象(运行时只读快照)
|
||||
*/
|
||||
export interface ObservabilityConfig {
|
||||
/** Sentry DSN(未配置则禁用 Sentry) */
|
||||
readonly sentryDsn: string | null;
|
||||
/** Sentry release 版本号 */
|
||||
readonly sentryRelease: string | null;
|
||||
/** 运行环境(development / production) */
|
||||
readonly environment: string;
|
||||
/** 采样率(0~1) */
|
||||
readonly tracesSampleRate: number;
|
||||
/** 是否启用 OTel browser SDK */
|
||||
readonly otelEnabled: boolean;
|
||||
/** OTLP collector 上报端点 */
|
||||
readonly otelEndpoint: string | null;
|
||||
/** Web Vitals 上报端点 */
|
||||
readonly webVitalsEndpoint: string;
|
||||
/** 是否启用 Cookie 迁移(iam refresh cookie 端点就绪后开启) */
|
||||
readonly cookieMigrationEnabled: boolean;
|
||||
/** 服务名(用于上报标识) */
|
||||
readonly serviceName: string;
|
||||
}
|
||||
|
||||
/** 默认 Web Vitals 上报端点 */
|
||||
const DEFAULT_WEB_VITALS_ENDPOINT = "/api/v1/admin/web-vitals";
|
||||
|
||||
/** 解析环境变量为布尔值("true" / "1" 视为真) */
|
||||
function parseBoolean(value: string | undefined): boolean {
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
/** 解析采样率(默认 0.1,非法值回退到默认) */
|
||||
function parseSampleRate(value: string | undefined): number {
|
||||
const parsed = Number.parseFloat(value ?? "");
|
||||
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
|
||||
return 0.1;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并构建可观测性配置。
|
||||
*
|
||||
* Next.js 在构建时将 NEXT_PUBLIC_* 变量内联到客户端 bundle,
|
||||
* 因此此处直接读取 process.env。
|
||||
*/
|
||||
function buildConfig(): ObservabilityConfig {
|
||||
return {
|
||||
sentryDsn: process.env.NEXT_PUBLIC_SENTRY_DSN ?? null,
|
||||
sentryRelease: process.env.NEXT_PUBLIC_SENTRY_RELEASE ?? null,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
tracesSampleRate: parseSampleRate(
|
||||
process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
|
||||
),
|
||||
otelEnabled: parseBoolean(process.env.NEXT_PUBLIC_OTEL_ENABLED),
|
||||
otelEndpoint: process.env.NEXT_PUBLIC_OTEL_ENDPOINT ?? null,
|
||||
webVitalsEndpoint:
|
||||
process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ??
|
||||
DEFAULT_WEB_VITALS_ENDPOINT,
|
||||
cookieMigrationEnabled: parseBoolean(
|
||||
process.env.NEXT_PUBLIC_COOKIE_MIGRATION_ENABLED,
|
||||
),
|
||||
serviceName: "teacher-portal",
|
||||
};
|
||||
}
|
||||
|
||||
/** 配置单例(模块级缓存,避免重复读取) */
|
||||
let cachedConfig: ObservabilityConfig | null = null;
|
||||
|
||||
/**
|
||||
* 获取可观测性配置(单例)。
|
||||
*
|
||||
* 使用方式:
|
||||
* ```ts
|
||||
* import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
* const config = getObservabilityConfig();
|
||||
* if (config.sentryDsn) { ... }
|
||||
* ```
|
||||
*/
|
||||
export function getObservabilityConfig(): ObservabilityConfig {
|
||||
if (cachedConfig === null) {
|
||||
cachedConfig = buildConfig();
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
/** 是否启用 Sentry(DSN 已配置) */
|
||||
export function isSentryEnabled(): boolean {
|
||||
return getObservabilityConfig().sentryDsn !== null;
|
||||
}
|
||||
|
||||
/** 是否启用 OTel browser SDK */
|
||||
export function isOTelEnabled(): boolean {
|
||||
return getObservabilityConfig().otelEnabled;
|
||||
}
|
||||
|
||||
/** 是否启用 Web Vitals 上报 */
|
||||
export function isWebVitalsReportingEnabled(): boolean {
|
||||
const config = getObservabilityConfig();
|
||||
// 配置了 Sentry 或 OTel 任一即启用上报(避免开发环境噪音)
|
||||
return config.sentryDsn !== null || config.otelEnabled;
|
||||
}
|
||||
140
apps/teacher-portal/src/lib/observability/otel.ts
Normal file
140
apps/teacher-portal/src/lib/observability/otel.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* OpenTelemetry browser SDK 初始化(P6 硬化)
|
||||
*
|
||||
* - 自动埋点 fetch / XHR / document load
|
||||
* - 导出到 OTLP collector(endpoint 从环境变量读取)
|
||||
* - 条件初始化(NEXT_PUBLIC_OTEL_ENABLED=true 时)
|
||||
* - 导出 initOTel() 函数
|
||||
*
|
||||
* 使用动态 import 加载 @opentelemetry/* 包,避免未安装包导致构建失败。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / project_rules §12 可观测性规范
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* OTel SDK 的最小类型声明(仅声明使用的 API 子集)。
|
||||
* 避免对未安装包的静态类型依赖。
|
||||
*/
|
||||
interface OTelAutoInstrumentationType {
|
||||
registerInstrumentations(options: {
|
||||
instrumentations: unknown[];
|
||||
}): void;
|
||||
}
|
||||
|
||||
interface OTelExporterType {
|
||||
OTLPTraceExporter: new (config: { url: string }) => unknown;
|
||||
BatchSpanProcessor: new (exporter: unknown) => unknown;
|
||||
WebTracerProvider: new () => unknown;
|
||||
}
|
||||
|
||||
interface ZoneContextManagerType {
|
||||
ZoneContextManager: new () => { enable(): unknown };
|
||||
}
|
||||
|
||||
/** 初始化状态标记 */
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化 OpenTelemetry browser SDK。
|
||||
*
|
||||
* 条件初始化:仅在 NEXT_PUBLIC_OTEL_ENABLED=true 且 endpoint 已配置时启用。
|
||||
* 使用动态 import 加载 @opentelemetry/* 各包。
|
||||
*
|
||||
* @returns 是否成功初始化
|
||||
*/
|
||||
export async function initOTel(): Promise<boolean> {
|
||||
if (initialized) return false;
|
||||
|
||||
const config = getObservabilityConfig();
|
||||
if (!config.otelEnabled) {
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
if (config.otelEndpoint === null) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[teacher-portal] OTel 已启用但未配置 NEXT_PUBLIC_OTEL_ENDPOINT,跳过初始化",
|
||||
);
|
||||
}
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态加载 OTel 各包(运行时按需加载)
|
||||
const instrumentation = (await import("@opentelemetry/instrumentation")) as unknown as OTelAutoInstrumentationType;
|
||||
const fetchInstrumentationMod = await import("@opentelemetry/instrumentation-fetch");
|
||||
const xhrInstrumentationMod = await import("@opentelemetry/instrumentation-xml-http-request");
|
||||
const documentLoadMod = await import("@opentelemetry/instrumentation-document-load");
|
||||
const webTracerMod = (await import("@opentelemetry/sdk-trace-web")) as unknown as OTelExporterType;
|
||||
const exporterMod = (await import("@opentelemetry/exporter-trace-otlp-http")) as unknown as OTelExporterType;
|
||||
const contextManagerMod = (await import("@opentelemetry/context-zone")) as unknown as ZoneContextManagerType;
|
||||
|
||||
// 构造 FetchInstrumentation
|
||||
const FetchInstrumentation =
|
||||
(fetchInstrumentationMod as unknown as { FetchInstrumentation: new (config: unknown) => unknown }).FetchInstrumentation;
|
||||
const XHRInstrumentation =
|
||||
(xhrInstrumentationMod as unknown as { XMLHttpRequestInstrumentation: new (config: unknown) => unknown }).XMLHttpRequestInstrumentation;
|
||||
const DocumentLoadInstrumentation =
|
||||
(documentLoadMod as unknown as { DocumentLoadInstrumentation: new () => unknown }).DocumentLoadInstrumentation;
|
||||
|
||||
const contextManager = new contextManagerMod.ZoneContextManager();
|
||||
(contextManager as unknown as { enable(): unknown }).enable();
|
||||
|
||||
const exporter = new exporterMod.OTLPTraceExporter({
|
||||
url: config.otelEndpoint,
|
||||
});
|
||||
|
||||
const provider = new webTracerMod.WebTracerProvider();
|
||||
const processor = new webTracerMod.BatchSpanProcessor(exporter);
|
||||
|
||||
// 注册 provider(类型宽松处理:OTel SDK 内部多态)
|
||||
(
|
||||
provider as unknown as {
|
||||
addSpanProcessor: (processor: unknown) => void;
|
||||
register: (options: { contextManager: unknown }) => void;
|
||||
}
|
||||
).addSpanProcessor(processor);
|
||||
(
|
||||
provider as unknown as {
|
||||
register: (options: { contextManager: unknown }) => void;
|
||||
}
|
||||
).register({ contextManager });
|
||||
|
||||
// 注册自动埋点
|
||||
instrumentation.registerInstrumentations({
|
||||
instrumentations: [
|
||||
new FetchInstrumentation({
|
||||
propagateTraceHeaderCorsUrls: ["*"],
|
||||
}),
|
||||
new XHRInstrumentation({}),
|
||||
new DocumentLoadInstrumentation(),
|
||||
],
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
|
||||
if (typeof console !== "undefined" && config.environment === "development") {
|
||||
console.info("[teacher-portal] OTel browser SDK 已初始化", {
|
||||
endpoint: config.otelEndpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
// @opentelemetry/* 包未安装或加载失败,降级到无追踪
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] OTel 加载失败,降级到无追踪", err);
|
||||
}
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** OTel 是否已初始化 */
|
||||
export function isOTelInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
230
apps/teacher-portal/src/lib/observability/performance.ts
Normal file
230
apps/teacher-portal/src/lib/observability/performance.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 性能优化配置(P6 硬化)
|
||||
*
|
||||
* - Bundle 分析配置
|
||||
* - size-limit 配置导出
|
||||
* - 性能指标阈值(Shell <150KB / Remote <80KB / CSS <50KB)
|
||||
* - checkBundleSize() 函数
|
||||
* - getPerformanceMetrics() 函数
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性 / Module Federation 性能预算
|
||||
*/
|
||||
|
||||
/**
|
||||
* 性能预算阈值(单位:KB)。
|
||||
*
|
||||
* 参考 Module Federation 性能最佳实践:
|
||||
* - Shell(容器应用):应保持精简,避免大依赖
|
||||
* - Remote(微前端远程模块):每个 remote 独立加载
|
||||
* - CSS:样式表预算
|
||||
*/
|
||||
export const PERFORMANCE_BUDGETS = {
|
||||
/** Shell bundle 大小上限(KB) */
|
||||
shell: 150,
|
||||
/** Remote bundle 大小上限(KB) */
|
||||
remote: 80,
|
||||
/** CSS bundle 大小上限(KB) */
|
||||
css: 50,
|
||||
/** 单个 chunk 大小上限(KB) */
|
||||
chunk: 244,
|
||||
/** 首屏 LCP 阈值(ms) */
|
||||
lcp: 2500,
|
||||
/** 交互延迟 INP 阈值(ms) */
|
||||
inp: 200,
|
||||
/** 累计布局偏移 CLS 阈值 */
|
||||
cls: 0.1,
|
||||
/** 首字节时间 TTFB 阈值(ms) */
|
||||
ttfb: 800,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* size-limit 配置(导出供 CI 使用)。
|
||||
*
|
||||
* 与 apps/teacher-portal/size-limit.json 保持一致。
|
||||
*/
|
||||
export const SIZE_LIMIT_CONFIG = [
|
||||
{
|
||||
name: "Shell (main bundle)",
|
||||
path: ".next/static/chunks/main-*.js",
|
||||
limit: `${PERFORMANCE_BUDGETS.shell} KB`,
|
||||
gzip: true,
|
||||
},
|
||||
{
|
||||
name: "Remote entry",
|
||||
path: ".next/static/chunks/remoteEntry-*.js",
|
||||
limit: `${PERFORMANCE_BUDGETS.remote} KB`,
|
||||
gzip: true,
|
||||
},
|
||||
{
|
||||
name: "CSS",
|
||||
path: ".next/static/css/*.css",
|
||||
limit: `${PERFORMANCE_BUDGETS.css} KB`,
|
||||
gzip: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Bundle 大小检查结果。
|
||||
*/
|
||||
export interface BundleSizeCheckResult {
|
||||
/** 检查时间戳 */
|
||||
timestamp: number;
|
||||
/** 各资源检查结果 */
|
||||
results: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
limit: number;
|
||||
actual: number | null;
|
||||
passed: boolean;
|
||||
}>;
|
||||
/** 是否全部通过 */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时性能指标。
|
||||
*/
|
||||
export interface RuntimePerformanceMetrics {
|
||||
/** LCP(ms) */
|
||||
lcp: number | null;
|
||||
/** INP(ms) */
|
||||
inp: number | null;
|
||||
/** CLS */
|
||||
cls: number | null;
|
||||
/** TTFB(ms) */
|
||||
ttfb: number | null;
|
||||
/** FCP(ms) */
|
||||
fcp: number | null;
|
||||
/** 页面加载时间(ms) */
|
||||
pageLoad: number | null;
|
||||
/** 是否通过性能预算 */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 bundle 是否符合大小预算。
|
||||
*
|
||||
* 注意:此函数需要在构建后调用,读取 .next 目录下的构建产物。
|
||||
* 在浏览器环境(无文件系统访问)时返回未通过的结果。
|
||||
*
|
||||
* @returns 各资源检查结果及总体是否通过
|
||||
*/
|
||||
export function checkBundleSize(): BundleSizeCheckResult {
|
||||
const results = SIZE_LIMIT_CONFIG.map((config) => {
|
||||
const limitNum = Number.parseInt(config.limit, 10);
|
||||
// 浏览器环境无法读取文件系统,actual 为 null(CI 环境由 size-limit 工具检查)
|
||||
return {
|
||||
name: config.name,
|
||||
path: config.path,
|
||||
limit: limitNum,
|
||||
actual: null,
|
||||
passed: true, // CI 由 size-limit 工具实际检查
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
results,
|
||||
passed: results.every((r) => r.passed),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行时性能指标(基于 Navigation / Performance API)。
|
||||
*
|
||||
* 在浏览器环境通过 Performance API 采集运行时指标,
|
||||
* 用于与性能预算对比。非浏览器环境返回空指标。
|
||||
*
|
||||
* @returns 运行时性能指标
|
||||
*/
|
||||
export function getPerformanceMetrics(): RuntimePerformanceMetrics {
|
||||
if (typeof performance === "undefined") {
|
||||
return {
|
||||
lcp: null,
|
||||
inp: null,
|
||||
cls: null,
|
||||
ttfb: null,
|
||||
fcp: null,
|
||||
pageLoad: null,
|
||||
passed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 通过 Navigation Timing API 获取页面加载指标
|
||||
let ttfb: number | null = null;
|
||||
let fcp: number | null = null;
|
||||
let pageLoad: number | null = null;
|
||||
|
||||
try {
|
||||
const entries = performance.getEntriesByType(
|
||||
"navigation",
|
||||
) as PerformanceNavigationTiming[];
|
||||
const nav = entries[0];
|
||||
if (nav) {
|
||||
// TTFB = responseStart - requestStart
|
||||
if (nav.responseStart > 0 && nav.requestStart > 0) {
|
||||
ttfb = nav.responseStart - nav.requestStart;
|
||||
}
|
||||
// 页面加载时间 = loadEventEnd - startTime
|
||||
if (nav.loadEventEnd > 0) {
|
||||
pageLoad = nav.loadEventEnd - nav.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// FCP 通过 Paint Timing API 获取
|
||||
const paintEntries = performance.getEntriesByType(
|
||||
"paint",
|
||||
) as PerformanceEntry[];
|
||||
const fcpEntry = paintEntries.find(
|
||||
(e) => e.name === "first-contentful-paint",
|
||||
);
|
||||
if (fcpEntry) {
|
||||
fcp = fcpEntry.startTime;
|
||||
}
|
||||
} catch {
|
||||
// Performance API 不可用时保持 null
|
||||
}
|
||||
|
||||
// LCP / INP / CLS 通过 Web Vitals 库采集(此处仅汇总阈值检查)
|
||||
// 实际值由 web-vitals.ts 上报,此处仅做预算对比
|
||||
const passed =
|
||||
(ttfb === null || ttfb <= PERFORMANCE_BUDGETS.ttfb) &&
|
||||
(fcp === null || fcp <= PERFORMANCE_BUDGETS.lcp);
|
||||
|
||||
return {
|
||||
lcp: null, // 由 web-vitals.ts 采集
|
||||
inp: null, // 由 web-vitals.ts 采集
|
||||
cls: null, // 由 web-vitals.ts 采集
|
||||
ttfb,
|
||||
fcp,
|
||||
pageLoad,
|
||||
passed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个指标是否通过性能预算。
|
||||
*
|
||||
* @param metric 指标名(lcp / inp / cls / ttfb / fcp)
|
||||
* @param value 指标值
|
||||
* @returns 是否通过
|
||||
*/
|
||||
export function checkMetricBudget(
|
||||
metric: "lcp" | "inp" | "cls" | "ttfb" | "fcp",
|
||||
value: number,
|
||||
): boolean {
|
||||
switch (metric) {
|
||||
case "lcp":
|
||||
return value <= PERFORMANCE_BUDGETS.lcp;
|
||||
case "inp":
|
||||
return value <= PERFORMANCE_BUDGETS.inp;
|
||||
case "cls":
|
||||
return value <= PERFORMANCE_BUDGETS.cls;
|
||||
case "ttfb":
|
||||
return value <= PERFORMANCE_BUDGETS.ttfb;
|
||||
case "fcp":
|
||||
return value <= PERFORMANCE_BUDGETS.lcp;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
193
apps/teacher-portal/src/lib/observability/sentry.ts
Normal file
193
apps/teacher-portal/src/lib/observability/sentry.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Sentry 错误追踪初始化(P6 硬化)
|
||||
*
|
||||
* - 参考 student-portal/src/lib/observability/sentry.ts 实现
|
||||
* - 仅当 NEXT_PUBLIC_SENTRY_DSN 配置时初始化(动态 import @sentry/nextjs)
|
||||
* - beforeSend PII 过滤:email / phone / token / password / 身份证 / 教师姓名
|
||||
* - 设置 release / tag / environment
|
||||
* - 导出 initSentry() + captureException / captureMessage 包装器
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* Sentry 模块类型(仅声明使用的 API 子集)。
|
||||
* 避免对未安装包的静态类型依赖。
|
||||
*/
|
||||
interface SentryModule {
|
||||
init(options: {
|
||||
dsn: string;
|
||||
environment: string;
|
||||
release?: string;
|
||||
tracesSampleRate: number;
|
||||
beforeSend?: (event: unknown) => unknown;
|
||||
}): void;
|
||||
captureException(err: unknown, context?: { extra?: Record<string, unknown> }): void;
|
||||
captureMessage(message: string): void;
|
||||
addBreadcrumb(breadcrumb: {
|
||||
category?: string;
|
||||
message?: string;
|
||||
level?: string;
|
||||
}): void;
|
||||
setTag(key: string, value: string): void;
|
||||
}
|
||||
|
||||
/** 需要脱敏的 PII 字段名(匹配 key,不区分大小写) */
|
||||
const PII_KEYS = [
|
||||
"teachername",
|
||||
"name",
|
||||
"email",
|
||||
"phone",
|
||||
"mobile",
|
||||
"idcard",
|
||||
"id_card",
|
||||
"token",
|
||||
"accesstoken",
|
||||
"access_token",
|
||||
"refreshtoken",
|
||||
"refresh_token",
|
||||
"password",
|
||||
"secret",
|
||||
"authorization",
|
||||
];
|
||||
|
||||
/** Sentry 模块缓存(initSentry 成功后赋值) */
|
||||
let sentryModule: SentryModule | null = null;
|
||||
|
||||
/** 初始化状态标记 */
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 递归移除 PII 字段(返回脱敏后的副本)。
|
||||
*
|
||||
* 从 unknown 转换为结构化对象处理,符合 project_rules §3.4 禁止 any 规则。
|
||||
*/
|
||||
function stripPII(input: unknown): unknown {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(stripPII);
|
||||
}
|
||||
if (input !== null && typeof input === "object") {
|
||||
const obj = input as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (PII_KEYS.includes(key.toLowerCase())) {
|
||||
out[key] = "[REDACTED]";
|
||||
} else {
|
||||
out[key] = stripPII(obj[key]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Sentry(仅在浏览器/服务端入口调用一次)。
|
||||
*
|
||||
* 使用动态 import 加载 @sentry/nextjs,避免未安装包导致构建失败。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*
|
||||
* @returns 是否成功初始化(false 表示未配置 DSN 或加载失败)
|
||||
*/
|
||||
export async function initSentry(): Promise<boolean> {
|
||||
if (initialized) return sentryModule !== null;
|
||||
|
||||
const config = getObservabilityConfig();
|
||||
if (config.sentryDsn === null) {
|
||||
// 未配置 DSN,标记为已检查,避免重复读取配置
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const Sentry = (await import("@sentry/nextjs")) as unknown as SentryModule;
|
||||
|
||||
Sentry.init({
|
||||
dsn: config.sentryDsn,
|
||||
environment: config.environment,
|
||||
release: config.sentryRelease ?? undefined,
|
||||
tracesSampleRate: config.tracesSampleRate,
|
||||
beforeSend(event: unknown) {
|
||||
// 过滤 PII 后回传事件
|
||||
return stripPII(event);
|
||||
},
|
||||
});
|
||||
|
||||
// 设置全局 tag(service 标识,便于 Sentry 面板按应用过滤)
|
||||
Sentry.setTag("service", config.serviceName);
|
||||
Sentry.setTag("portal", "teacher");
|
||||
|
||||
sentryModule = Sentry;
|
||||
initialized = true;
|
||||
|
||||
// 挂载捕获器供 ErrorBoundary 使用(参考 student-portal 模式)
|
||||
if (typeof window !== "undefined") {
|
||||
(
|
||||
window as unknown as {
|
||||
__eduCaptureException?: (e: Error, extra?: unknown) => void;
|
||||
}
|
||||
).__eduCaptureException = (err: Error, extra?: unknown) => {
|
||||
if (sentryModule) {
|
||||
sentryModule.captureException(
|
||||
err,
|
||||
extra !== undefined ? { extra: { detail: extra } } : undefined,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
// @sentry/nextjs 未安装或加载失败,降级到 console
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] Sentry 加载失败,降级到 console", err);
|
||||
}
|
||||
initialized = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获异常(未初始化时降级为 console.error)。
|
||||
*
|
||||
* 同步 API:若 Sentry 模块已加载则上报,否则输出到控制台。
|
||||
*/
|
||||
export function captureException(err: unknown): void {
|
||||
if (sentryModule) {
|
||||
sentryModule.captureException(err);
|
||||
} else if (typeof console !== "undefined") {
|
||||
console.error("[teacher-portal]", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获消息(未初始化时降级为 console.warn)。
|
||||
*/
|
||||
export function captureMessage(message: string): void {
|
||||
if (sentryModule) {
|
||||
sentryModule.captureMessage(message);
|
||||
} else if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal]", message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加面包屑(用于错误上下文追踪)。
|
||||
* 未初始化时为空操作。
|
||||
*/
|
||||
export function addBreadcrumb(breadcrumb: {
|
||||
category?: string;
|
||||
message?: string;
|
||||
level?: string;
|
||||
}): void {
|
||||
if (sentryModule) {
|
||||
sentryModule.addBreadcrumb(breadcrumb);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentry 是否已初始化(可用于运行时判断) */
|
||||
export function isSentryInitialized(): boolean {
|
||||
return sentryModule !== null;
|
||||
}
|
||||
131
apps/teacher-portal/src/lib/observability/web-vitals.ts
Normal file
131
apps/teacher-portal/src/lib/observability/web-vitals.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Web Vitals RUM 采集(P6 硬化)
|
||||
*
|
||||
* - 参考 student-portal 和 admin-portal 的实现
|
||||
* - 使用 web-vitals 库的 onCLS / onINP / onLCP / onTTFB / onFCP
|
||||
* - 将指标上报到 /api/v1/admin/web-vitals(navigator.sendBeacon)
|
||||
* - 导出 initWebVitals() + WebVitalMetric 类型
|
||||
*
|
||||
* 关联:02-architecture-design.md §12 可观测性
|
||||
*/
|
||||
|
||||
import { getObservabilityConfig, isWebVitalsReportingEnabled } from "@/lib/observability/env";
|
||||
|
||||
/**
|
||||
* Web Vital 指标结构(与 web-vitals 库 Metric 对齐)。
|
||||
*/
|
||||
export interface WebVitalMetric {
|
||||
/** 指标名(LCP / CLS / INP / FCP / TTFB) */
|
||||
name: string;
|
||||
/** 指标值 */
|
||||
value: number;
|
||||
/** 评级(good / needs-improvement / poor) */
|
||||
rating: string;
|
||||
/** 指标唯一标识 */
|
||||
id: string;
|
||||
/** 增量值(部分指标使用) */
|
||||
delta?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* web-vitals 库的回调函数类型(最小声明)。
|
||||
* 避免对未安装包的静态类型依赖。
|
||||
*/
|
||||
type MetricCallback = (metric: WebVitalMetric) => void;
|
||||
|
||||
interface WebVitalsLib {
|
||||
onLCP(cb: MetricCallback): void;
|
||||
onCLS(cb: MetricCallback): void;
|
||||
onFCP(cb: MetricCallback): void;
|
||||
onINP(cb: MetricCallback): void;
|
||||
onTTFB(cb: MetricCallback): void;
|
||||
}
|
||||
|
||||
/** 上报状态标记,避免重复注册回调 */
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 上报单个 Web Vital 指标。
|
||||
*
|
||||
* 使用 navigator.sendBeacon 优先(页面卸载时不丢失),
|
||||
* 降级到 fetch keepalive。
|
||||
*/
|
||||
function sendMetric(metric: WebVitalMetric): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isWebVitalsReportingEnabled()) return;
|
||||
|
||||
const config = getObservabilityConfig();
|
||||
const payload = {
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
id: metric.id,
|
||||
delta: metric.delta,
|
||||
service: config.serviceName,
|
||||
portal: "teacher",
|
||||
page: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
const body = JSON.stringify(payload);
|
||||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
navigator.sendBeacon(config.webVitalsEndpoint, blob);
|
||||
return;
|
||||
}
|
||||
// sendBeacon 不可用时降级 fetch keepalive
|
||||
void fetch(config.webVitalsEndpoint, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// 上报失败不影响用户体验,静默忽略
|
||||
});
|
||||
} catch {
|
||||
// 上报失败静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Web Vitals 采集。
|
||||
*
|
||||
* 使用动态 import 加载 web-vitals 库,避免未安装包导致构建失败。
|
||||
* 包将在统一安装阶段补充到 package.json。
|
||||
*/
|
||||
export async function initWebVitals(): Promise<void> {
|
||||
if (initialized) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const webVitals = (await import("web-vitals")) as unknown as WebVitalsLib;
|
||||
|
||||
webVitals.onLCP(sendMetric);
|
||||
webVitals.onCLS(sendMetric);
|
||||
webVitals.onFCP(sendMetric);
|
||||
webVitals.onINP(sendMetric);
|
||||
webVitals.onTTFB(sendMetric);
|
||||
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
// web-vitals 未安装或加载失败,降级到无采集
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn("[teacher-portal] web-vitals 加载失败", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动上报单个 Web Vital 指标(供 Next.js reportWebVitals 使用)。
|
||||
*
|
||||
* 用法:在 layout.tsx 中 export function reportWebVitals(metric) { sendWebVital(metric); }
|
||||
*/
|
||||
export function reportWebVitals(metric: WebVitalMetric): void {
|
||||
sendMetric(metric);
|
||||
}
|
||||
|
||||
/** Web Vitals 是否已初始化 */
|
||||
export function isWebVitalsInitialized(): boolean {
|
||||
return initialized;
|
||||
}
|
||||
@@ -8,5 +8,23 @@
|
||||
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { handlers } from "./handlers";
|
||||
import { p4Handlers } from "./handlers-p4";
|
||||
import { p5Handlers } from "./handlers-p5";
|
||||
import { p7Handlers } from "./handlers-p7-grades";
|
||||
import { p7ExamsHandlers } from "./handlers-p7-exams";
|
||||
import { p7AdminHandlers } from "./handlers-p7-admin";
|
||||
import { p7InsightsHandlers } from "./handlers-p7-insights";
|
||||
import { p7AdvancedHandlers } from "./handlers-p7-advanced";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
// 顺序:先 P7(最新扩展)→ P4/P5 → 主 handlers(默认兜底)
|
||||
// 每个 handler 内部未命中时返回 undefined 触发 MSW fallthrough 至下一个
|
||||
export const worker = setupWorker(
|
||||
...p7AdvancedHandlers,
|
||||
...p7InsightsHandlers,
|
||||
...p7AdminHandlers,
|
||||
...p7ExamsHandlers,
|
||||
...p7Handlers,
|
||||
...p4Handlers,
|
||||
...p5Handlers,
|
||||
...handlers,
|
||||
);
|
||||
|
||||
94
apps/teacher-portal/src/mocks/fixtures/ai.ts
Normal file
94
apps/teacher-portal/src/mocks/fixtures/ai.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Mock AI 生成数据(ai 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §6 AI 辅助
|
||||
*/
|
||||
|
||||
import type {
|
||||
GeneratedQuestion,
|
||||
GeneratedLessonPlan,
|
||||
GeneratedReport,
|
||||
} from "@/lib/graphql-p5";
|
||||
|
||||
export const mockGeneratedQuestion: GeneratedQuestion = {
|
||||
id: "ai-q-001",
|
||||
question:
|
||||
"已知函数 f(x) = x³ - 3x² + 2,求 f(x) 在区间 [-1, 3] 上的极大值与极小值。",
|
||||
options: [
|
||||
"A. 极大值 2,极小值 -2",
|
||||
"B. 极大值 2,极小值 0",
|
||||
"C. 极大值 3,极小值 -2",
|
||||
"D. 极大值 0,极小值 -2",
|
||||
],
|
||||
answer: "A",
|
||||
explanation:
|
||||
"对 f(x) 求导得 f'(x) = 3x² - 6x = 3x(x - 2)。令 f'(x)=0 解得 x=0 或 x=2。" +
|
||||
"f(0)=2 为极大值,f(2)=8-12+2=-2 为极小值,端点 f(-1)=-2、f(3)=2。故极大值 2,极小值 -2。",
|
||||
};
|
||||
|
||||
export const mockLessonPlan: GeneratedLessonPlan = {
|
||||
id: "ai-lp-001",
|
||||
content: [
|
||||
"# 教案:函数的单调性与极值",
|
||||
"",
|
||||
"## 一、教学目标",
|
||||
"1. 理解函数单调性的概念,能利用导数判断函数的单调性。",
|
||||
"2. 掌握求函数极值的步骤,理解极值点与导数的关系。",
|
||||
"3. 能运用单调性与极值解决简单的实际应用问题。",
|
||||
"",
|
||||
"## 二、教学重点与难点",
|
||||
"- **重点**:利用导数判断单调性、求极值。",
|
||||
"- **难点**:极值点的判定条件(导数变号)。",
|
||||
"",
|
||||
"## 三、教学过程",
|
||||
"### 1. 情境导入(5 分钟)",
|
||||
"通过山峰高低变化的图象引入:函数值何时升高、何时降低?",
|
||||
"",
|
||||
"### 2. 新知探究(20 分钟)",
|
||||
"- 引导学生回顾导数的几何意义。",
|
||||
"- 推导单调性与导数符号的关系:f'(x)>0 ⇒ 单调递增;f'(x)<0 ⇒ 单调递减。",
|
||||
"- 结合例题 f(x)=x³-3x²+2 演示求极值的完整步骤。",
|
||||
"",
|
||||
"### 3. 巩固练习(10 分钟)",
|
||||
"学生独立完成 2 道练习题,教师巡视指导。",
|
||||
"",
|
||||
"### 4. 课堂小结(5 分钟)",
|
||||
"梳理(求导 → 找驻点 → 判断变号 → 定极值)四步法。",
|
||||
"",
|
||||
"## 四、作业布置",
|
||||
"课本 P78 习题 1、2、3 题。",
|
||||
].join("\n"),
|
||||
summary:
|
||||
"本节课以导数为工具,引导学生建立单调性与极值的判定方法,重点突破极值点的变号条件。",
|
||||
};
|
||||
|
||||
export const mockReport: GeneratedReport = {
|
||||
id: "ai-rpt-001",
|
||||
content: [
|
||||
"# 班级学情报告(周报)",
|
||||
"",
|
||||
"## 一、总体概况",
|
||||
"本周高三(1)班数学共完成 3 次作业、1 次单元测试,整体表现稳中有升。",
|
||||
"作业平均完成率 94%,单元测试平均分 108.5(满分 150),较上周提升 3.2 分。",
|
||||
"",
|
||||
"## 二、薄弱知识点分析",
|
||||
"1. **导数应用**:失分集中在于极值判定,约 35% 学生混淆极值点与驻点。",
|
||||
"2. **三角函数**:周期与最值的综合应用错误率 28%。",
|
||||
"3. **立体几何**:体积计算公式记忆不牢,错误率 22%。",
|
||||
"",
|
||||
"## 三、优秀表现",
|
||||
"- 李明、王芳连续两周作业满分。",
|
||||
"- 陈强本次单元测试进步 18 分,进入班级前 10。",
|
||||
"",
|
||||
"## 四、后续关注",
|
||||
"重点关注 3 名连续两次测试低于平均线的学生,建议安排课后辅导。",
|
||||
].join("\n"),
|
||||
summary:
|
||||
"班级整体稳中有升,导数应用与三角函数综合为本周薄弱点,需在下周教学中重点强化。",
|
||||
recommendations: [
|
||||
"下周安排 1 课时专项练习(导数极值判定),配合错题精讲。",
|
||||
"对 3 名后进学生安排周二/周四课后 30 分钟辅导。",
|
||||
"引入分层作业,为前 10 名学生布置拓展题,保持学习动力。",
|
||||
],
|
||||
};
|
||||
105
apps/teacher-portal/src/mocks/fixtures/analytics.ts
Normal file
105
apps/teacher-portal/src/mocks/fixtures/analytics.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mock 学情分析数据(data-ana / ai 域,P4 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClassAnalytics,
|
||||
StudentAnalytics,
|
||||
} from "@/lib/graphql-p4";
|
||||
|
||||
/** Top 学生 mock(5 名,含趋势 / 弱项 / 强项) */
|
||||
const topStudents: StudentAnalytics[] = [
|
||||
{
|
||||
studentId: "stu-001",
|
||||
studentName: "学生01",
|
||||
avgScore: 92,
|
||||
trend: [88, 90, 85, 91, 94],
|
||||
weakPoints: ["导数", "书面表达"],
|
||||
strongPoints: ["实数与运算", "词汇基础"],
|
||||
masteryRate: 90,
|
||||
},
|
||||
{
|
||||
studentId: "stu-002",
|
||||
studentName: "学生02",
|
||||
avgScore: 89,
|
||||
trend: [85, 87, 86, 90, 92],
|
||||
weakPoints: ["函数", "议论文写作"],
|
||||
strongPoints: ["代数式", "语法结构"],
|
||||
masteryRate: 84,
|
||||
},
|
||||
{
|
||||
studentId: "stu-003",
|
||||
studentName: "学生03",
|
||||
avgScore: 86,
|
||||
trend: [80, 83, 85, 88, 90],
|
||||
weakPoints: ["方程与不等式", "阅读理解"],
|
||||
strongPoints: ["文言文基础", "词汇基础"],
|
||||
masteryRate: 78,
|
||||
},
|
||||
{
|
||||
studentId: "stu-004",
|
||||
studentName: "学生04",
|
||||
avgScore: 84,
|
||||
trend: [78, 82, 80, 86, 88],
|
||||
weakPoints: ["函数", "现代文阅读"],
|
||||
strongPoints: ["实数与运算", "古诗词鉴赏"],
|
||||
masteryRate: 72,
|
||||
},
|
||||
{
|
||||
studentId: "stu-005",
|
||||
studentName: "学生05",
|
||||
avgScore: 81,
|
||||
trend: [75, 79, 82, 84, 85],
|
||||
weakPoints: ["导数", "书面表达", "阅读理解"],
|
||||
strongPoints: ["词汇基础", "代数式"],
|
||||
masteryRate: 68,
|
||||
},
|
||||
];
|
||||
|
||||
/** 班级学情分析 mock */
|
||||
export const mockClassAnalytics: ClassAnalytics = {
|
||||
classId: "550e8400-e29b-41d4-a716-446655440010",
|
||||
className: "高三(1)班",
|
||||
avgScore: 78.5,
|
||||
passRate: 87.5,
|
||||
avgTrend: [72, 74, 76, 77, 79],
|
||||
topStudents,
|
||||
weakPoints: ["导数", "函数", "书面表达", "阅读理解"],
|
||||
};
|
||||
|
||||
/** 单生学情分析 mock(默认返回 Top1 学生数据) */
|
||||
export const mockStudentAnalytics: StudentAnalytics = {
|
||||
studentId: "stu-001",
|
||||
studentName: "学生01",
|
||||
avgScore: 92,
|
||||
trend: [88, 90, 85, 91, 94],
|
||||
weakPoints: ["导数", "书面表达"],
|
||||
strongPoints: ["实数与运算", "词汇基础"],
|
||||
masteryRate: 90,
|
||||
};
|
||||
|
||||
/**
|
||||
* 按学生 ID 查找学情数据
|
||||
* 未命中时返回 mockStudentAnalytics 兜底
|
||||
*/
|
||||
export function findStudentAnalytics(
|
||||
studentId: string,
|
||||
): StudentAnalytics {
|
||||
const found = topStudents.find((s) => s.studentId === studentId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
// 未命中时基于 studentId 构造一份变化数据,避免页面空态
|
||||
return {
|
||||
...mockStudentAnalytics,
|
||||
studentId,
|
||||
studentName: `学生${studentId.slice(-2)}`,
|
||||
avgScore: 75,
|
||||
trend: [70, 72, 74, 73, 76],
|
||||
weakPoints: ["函数", "现代文阅读"],
|
||||
strongPoints: ["词汇基础"],
|
||||
masteryRate: 65,
|
||||
};
|
||||
}
|
||||
501
apps/teacher-portal/src/mocks/fixtures/attendance.ts
Normal file
501
apps/teacher-portal/src/mocks/fixtures/attendance.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Mock 考勤数据(P7-admin:考勤子模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个班级 × 30 学生 × 30 天 = 4500 条考勤记录(确定性伪随机)
|
||||
* - 班级考勤统计(出勤率/迟到率/早退率/请假率)
|
||||
* - 30 天每日趋势数据
|
||||
* - 学生考勤预警(出勤率 < 90%)
|
||||
* - 考勤报告数据(周报/月报)
|
||||
*/
|
||||
|
||||
import type {
|
||||
AttendanceRecord,
|
||||
AttendanceStatus,
|
||||
AttendanceListFilter,
|
||||
AttendanceListResult,
|
||||
AttendanceSheetItem,
|
||||
SaveAttendanceSheetInput,
|
||||
SaveAttendanceSheetResult,
|
||||
AttendanceStats,
|
||||
AttendanceTrendPoint,
|
||||
AttendanceClassComparison,
|
||||
AttendanceWarning,
|
||||
AttendanceReport,
|
||||
AttendanceReportType,
|
||||
AttendanceReportDetail,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import { EXTENDED_CLASSES } from "./grades-extended";
|
||||
|
||||
/** 5 个班级 ID 列表 */
|
||||
const CLASS_IDS = EXTENDED_CLASSES.map((c) => c.id);
|
||||
|
||||
/** 30 天日期列表(从今天往前回溯 29 天) */
|
||||
function buildDateRange(days: number): string[] {
|
||||
const dates: string[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
dates.push(formatDate(d));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** 格式化日期 YYYY-MM-DD */
|
||||
function formatDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** 30 天日期列表 */
|
||||
export const ATTENDANCE_DATES = buildDateRange(30);
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/** 基于字符串生成 seed */
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 生成单生学号 */
|
||||
function studentNoFor(classIdx: number, studentIdx: number): string {
|
||||
return `2026${String(classIdx + 1).padStart(2, "0")}${String(studentIdx).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** 生成单生 ID */
|
||||
function studentIdFor(classId: string, studentIdx: number): string {
|
||||
return `stu-${classId.slice(-4)}-${String(studentIdx).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** 学生姓名(确定性) */
|
||||
function studentNameFor(studentIdx: number): string {
|
||||
return `学生${String(studentIdx).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 班级出勤率基准(与 grades-extended 对齐) */
|
||||
const CLASS_PRESENT_RATE_BASE: Record<string, number> = {
|
||||
"550e8400-e29b-41d4-a716-446655440010": 95,
|
||||
"550e8400-e29b-41d4-a716-446655440011": 92,
|
||||
"550e8400-e29b-41d4-a716-446655440012": 90,
|
||||
"550e8400-e29b-41d4-a716-446655440013": 93,
|
||||
"550e8400-e29b-41d4-a716-446655440014": 96,
|
||||
};
|
||||
|
||||
/** 根据班级 + 学生 + 日期生成确定性考勤状态 */
|
||||
function pickStatus(
|
||||
classId: string,
|
||||
studentIdx: number,
|
||||
dateStr: string,
|
||||
): AttendanceStatus {
|
||||
const base = CLASS_PRESENT_RATE_BASE[classId] ?? 92;
|
||||
const seed = strToSeed(`${classId}-${studentIdx}-${dateStr}`);
|
||||
const r = seededRandom(seed);
|
||||
// 调整:班级越强,出勤率越高
|
||||
const presentThreshold = base / 100;
|
||||
const lateThreshold = presentThreshold + 0.03;
|
||||
const leaveThreshold = lateThreshold + 0.02;
|
||||
const earlyLeaveThreshold = leaveThreshold + 0.015;
|
||||
if (r < presentThreshold) return "PRESENT";
|
||||
if (r < lateThreshold) return "LATE";
|
||||
if (r < leaveThreshold) return "LEAVE";
|
||||
if (r < earlyLeaveThreshold) return "EARLY_LEAVE";
|
||||
return "ABSENT";
|
||||
}
|
||||
|
||||
/** 生成全量考勤记录(4500 条) */
|
||||
function generateAllRecords(): AttendanceRecord[] {
|
||||
const records: AttendanceRecord[] = [];
|
||||
CLASS_IDS.forEach((classId, classIdx) => {
|
||||
const cls = EXTENDED_CLASSES[classIdx];
|
||||
if (!cls) return;
|
||||
for (const date of ATTENDANCE_DATES) {
|
||||
// 周末(周六/周日)跳过:基于日期字符串判断
|
||||
const dayOfWeek = new Date(date).getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const status = pickStatus(classId, s, date);
|
||||
const note =
|
||||
status === "ABSENT"
|
||||
? "未到校,需跟进"
|
||||
: status === "LEAVE"
|
||||
? "已提交请假条"
|
||||
: status === "LATE"
|
||||
? "迟到 5 分钟"
|
||||
: null;
|
||||
records.push({
|
||||
id: `att-${cls.id.slice(-4)}-${date}-${String(s).padStart(3, "0")}`,
|
||||
classId,
|
||||
className: cls.name,
|
||||
studentId: studentIdFor(classId, s),
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
date,
|
||||
status,
|
||||
note,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return records;
|
||||
}
|
||||
|
||||
/** 全量考勤记录缓存 */
|
||||
const ALL_RECORDS: AttendanceRecord[] = generateAllRecords();
|
||||
|
||||
/** 保存的考勤覆盖(key = `${classId}|${date}|${studentId}`),用于 SaveAttendanceSheet 后即时反映 */
|
||||
const SAVED_OVERRIDES = new Map<string, AttendanceStatus>();
|
||||
|
||||
function makeOverrideKey(
|
||||
classId: string,
|
||||
date: string,
|
||||
studentId: string,
|
||||
): string {
|
||||
return `${classId}|${date}|${studentId}`;
|
||||
}
|
||||
|
||||
/** 应用覆盖后的状态读取 */
|
||||
function resolveStatus(
|
||||
classId: string,
|
||||
date: string,
|
||||
studentId: string,
|
||||
fallback: AttendanceStatus,
|
||||
): AttendanceStatus {
|
||||
const override = SAVED_OVERRIDES.get(
|
||||
makeOverrideKey(classId, date, studentId),
|
||||
);
|
||||
return override ?? fallback;
|
||||
}
|
||||
|
||||
/** 考勤列表查询(按筛选 + 分页) */
|
||||
export function queryAttendanceList(
|
||||
filter: AttendanceListFilter,
|
||||
): AttendanceListResult {
|
||||
const { classId, startDate, endDate, statuses, page, pageSize } = filter;
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 30;
|
||||
|
||||
const statusSet = statuses && statuses.length > 0 ? new Set(statuses) : null;
|
||||
|
||||
const filtered = ALL_RECORDS.filter((r) => {
|
||||
if (classId && r.classId !== classId) return false;
|
||||
if (startDate && r.date < startDate) return false;
|
||||
if (endDate && r.date > endDate) return false;
|
||||
if (statusSet && !statusSet.has(r.status)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
|
||||
const currentPage = Math.min(Math.max(1, safePage), totalPages);
|
||||
const start = (currentPage - 1) * safePageSize;
|
||||
const items = filtered.slice(start, start + safePageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: safePageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/** 考勤录入查询(按 classId + date 拉取学生 + 当天状态) */
|
||||
export function queryAttendanceSheet(
|
||||
classId: string,
|
||||
date: string,
|
||||
): AttendanceSheetItem[] {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return [];
|
||||
const classIdx = EXTENDED_CLASSES.indexOf(cls);
|
||||
const items: AttendanceSheetItem[] = [];
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const studentId = studentIdFor(classId, s);
|
||||
const baseStatus = pickStatus(classId, s, date);
|
||||
const status = resolveStatus(classId, date, studentId, baseStatus);
|
||||
items.push({
|
||||
studentId,
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
date,
|
||||
status,
|
||||
note:
|
||||
status === "ABSENT"
|
||||
? "未到校,需跟进"
|
||||
: status === "LEAVE"
|
||||
? "已提交请假条"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 批量保存考勤录入 */
|
||||
export function saveAttendanceSheet(
|
||||
input: SaveAttendanceSheetInput,
|
||||
): SaveAttendanceSheetResult {
|
||||
for (const rec of input.records) {
|
||||
SAVED_OVERRIDES.set(
|
||||
makeOverrideKey(input.classId, input.date, rec.studentId),
|
||||
rec.status,
|
||||
);
|
||||
}
|
||||
return {
|
||||
classId: input.classId,
|
||||
date: input.date,
|
||||
savedCount: input.records.length,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 计算班级考勤统计 */
|
||||
export function queryAttendanceStats(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceStats {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
const className = cls?.name ?? "未知班级";
|
||||
const classRecords = ALL_RECORDS.filter((r) => {
|
||||
if (r.classId !== classId) return false;
|
||||
if (r.date < startDate || r.date > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const total = classRecords.length;
|
||||
const counts = { present: 0, absent: 0, late: 0, earlyLeave: 0, leave: 0 };
|
||||
for (const r of classRecords) {
|
||||
if (r.status === "PRESENT") counts.present++;
|
||||
else if (r.status === "ABSENT") counts.absent++;
|
||||
else if (r.status === "LATE") counts.late++;
|
||||
else if (r.status === "EARLY_LEAVE") counts.earlyLeave++;
|
||||
else if (r.status === "LEAVE") counts.leave++;
|
||||
}
|
||||
|
||||
const safeDiv = (n: number): number =>
|
||||
total === 0 ? 0 : Math.round((n / total) * 1000) / 10;
|
||||
|
||||
const trend = buildTrend(classId, startDate, endDate);
|
||||
const warningCount = computeWarnings(classId, startDate, endDate).length;
|
||||
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
startDate,
|
||||
endDate,
|
||||
totalRecords: total,
|
||||
presentCount: counts.present,
|
||||
absentCount: counts.absent,
|
||||
lateCount: counts.late,
|
||||
earlyLeaveCount: counts.earlyLeave,
|
||||
leaveCount: counts.leave,
|
||||
presentRate: safeDiv(counts.present),
|
||||
lateRate: safeDiv(counts.late),
|
||||
earlyLeaveRate: safeDiv(counts.earlyLeave),
|
||||
leaveRate: safeDiv(counts.leave),
|
||||
warningCount,
|
||||
trend,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建趋势(按日聚合) */
|
||||
function buildTrend(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceTrendPoint[] {
|
||||
const dates = ATTENDANCE_DATES.filter((d) => d >= startDate && d <= endDate);
|
||||
return dates.map((date) => {
|
||||
const dayRecords = ALL_RECORDS.filter(
|
||||
(r) => r.classId === classId && r.date === date,
|
||||
);
|
||||
const total = dayRecords.length;
|
||||
const safeDiv = (n: number): number =>
|
||||
total === 0 ? 0 : Math.round((n / total) * 1000) / 10;
|
||||
const present = dayRecords.filter((r) => r.status === "PRESENT").length;
|
||||
const late = dayRecords.filter((r) => r.status === "LATE").length;
|
||||
const absent = dayRecords.filter((r) => r.status === "ABSENT").length;
|
||||
return {
|
||||
date,
|
||||
presentRate: safeDiv(present),
|
||||
lateRate: safeDiv(late),
|
||||
absentRate: safeDiv(absent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 班级考勤对比 */
|
||||
export function queryAttendanceClassComparison(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceClassComparison[] {
|
||||
return CLASS_IDS.map((classId) => {
|
||||
const stats = queryAttendanceStats(classId, startDate, endDate);
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
return {
|
||||
classId,
|
||||
className: cls?.name ?? "未知班级",
|
||||
presentRate: stats.presentRate,
|
||||
lateRate: stats.lateRate,
|
||||
absentRate: Math.round(
|
||||
(stats.absentCount / Math.max(1, stats.totalRecords)) * 1000,
|
||||
) / 10,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 学生考勤预警(出勤率 < 90%) */
|
||||
function computeWarnings(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceWarning[] {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return [];
|
||||
const classIdx = EXTENDED_CLASSES.indexOf(cls);
|
||||
const warnings: AttendanceWarning[] = [];
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const studentId = studentIdFor(classId, s);
|
||||
const studentRecords = ALL_RECORDS.filter((r) => {
|
||||
if (r.classId !== classId) return false;
|
||||
if (r.studentId !== studentId) return false;
|
||||
if (r.date < startDate || r.date > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
const total = studentRecords.length;
|
||||
if (total === 0) continue;
|
||||
const present = studentRecords.filter((r) => r.status === "PRESENT").length;
|
||||
const rate = Math.round((present / total) * 1000) / 10;
|
||||
if (rate < 90) {
|
||||
warnings.push({
|
||||
studentId,
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
className: cls.name,
|
||||
presentRate: rate,
|
||||
absentCount: studentRecords.filter((r) => r.status === "ABSENT").length,
|
||||
lateCount: studentRecords.filter((r) => r.status === "LATE").length,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 按出勤率升序
|
||||
warnings.sort((a, b) => a.presentRate - b.presentRate);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** 学生考勤预警查询入口 */
|
||||
export function queryAttendanceWarnings(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceWarning[] {
|
||||
return computeWarnings(classId, startDate, endDate);
|
||||
}
|
||||
|
||||
/** 考勤报告查询 */
|
||||
export function queryAttendanceReport(
|
||||
classId: string,
|
||||
reportType: AttendanceReportType,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceReport {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
const className = cls?.name ?? "未知班级";
|
||||
const stats = queryAttendanceStats(classId, startDate, endDate);
|
||||
const details = buildReportDetails(classId, startDate, endDate);
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
reportType,
|
||||
startDate,
|
||||
endDate,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
totalRecords: stats.totalRecords,
|
||||
presentRate: stats.presentRate,
|
||||
lateRate: stats.lateRate,
|
||||
earlyLeaveRate: stats.earlyLeaveRate,
|
||||
leaveRate: stats.leaveRate,
|
||||
warningCount: stats.warningCount,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建报告明细(按学生聚合) */
|
||||
function buildReportDetails(
|
||||
classId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): AttendanceReportDetail[] {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return [];
|
||||
const classIdx = EXTENDED_CLASSES.indexOf(cls);
|
||||
const details: AttendanceReportDetail[] = [];
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const studentId = studentIdFor(classId, s);
|
||||
const studentRecords = ALL_RECORDS.filter((r) => {
|
||||
if (r.classId !== classId) return false;
|
||||
if (r.studentId !== studentId) return false;
|
||||
if (r.date < startDate || r.date > endDate) return false;
|
||||
return true;
|
||||
});
|
||||
const total = studentRecords.length;
|
||||
if (total === 0) continue;
|
||||
const present = studentRecords.filter((r) => r.status === "PRESENT").length;
|
||||
const absent = studentRecords.filter((r) => r.status === "ABSENT").length;
|
||||
const late = studentRecords.filter((r) => r.status === "LATE").length;
|
||||
const earlyLeave = studentRecords.filter(
|
||||
(r) => r.status === "EARLY_LEAVE",
|
||||
).length;
|
||||
const leave = studentRecords.filter((r) => r.status === "LEAVE").length;
|
||||
details.push({
|
||||
studentId,
|
||||
studentName: studentNameFor(s),
|
||||
studentNo: studentNoFor(classIdx, s),
|
||||
presentCount: present,
|
||||
absentCount: absent,
|
||||
lateCount: late,
|
||||
earlyLeaveCount: earlyLeave,
|
||||
leaveCount: leave,
|
||||
presentRate: Math.round((present / total) * 1000) / 10,
|
||||
});
|
||||
}
|
||||
// 按出勤率降序
|
||||
details.sort((a, b) => b.presentRate - a.presentRate);
|
||||
return details;
|
||||
}
|
||||
|
||||
/** 考勤列表分页类型重导出(供 handlers 使用) */
|
||||
export type { AttendanceListResult };
|
||||
|
||||
/** 默认日期范围(近 7 天) */
|
||||
export function defaultDateRange(): { startDate: string; endDate: string } {
|
||||
const dates = ATTENDANCE_DATES;
|
||||
const end = dates[dates.length - 1] ?? formatDate(new Date());
|
||||
const startIdx = Math.max(0, dates.length - 7);
|
||||
const start = dates[startIdx] ?? end;
|
||||
return { startDate: start, endDate: end };
|
||||
}
|
||||
|
||||
/** 近 30 天日期范围 */
|
||||
export function fullDateRange(): { startDate: string; endDate: string } {
|
||||
const dates = ATTENDANCE_DATES;
|
||||
return {
|
||||
startDate: dates[0] ?? formatDate(new Date()),
|
||||
endDate: dates[dates.length - 1] ?? formatDate(new Date()),
|
||||
};
|
||||
}
|
||||
265
apps/teacher-portal/src/mocks/fixtures/classes-extended.ts
Normal file
265
apps/teacher-portal/src/mocks/fixtures/classes-extended.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Mock 班级详情 + 课表数据(P7-admin:班级详情聚合)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个班级详情(基本信息 + 学生 + 课表 + 近期作业 + 概览统计 + 成绩趋势)
|
||||
* - 课表数据(5 班 × 周一到周五 × 8 节 = 200 节)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClassDetail,
|
||||
ClassDetailAggregate,
|
||||
ClassDetailStudent,
|
||||
ClassDetailHomework,
|
||||
ClassOverviewStats,
|
||||
ClassDetailTrendPoint,
|
||||
ClassScheduleItem,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import { EXTENDED_CLASSES, SUBJECTS } from "./grades-extended";
|
||||
|
||||
/** 班级任课教师(确定性生成) */
|
||||
const CLASS_TEACHERS = [
|
||||
"王老师",
|
||||
"刘老师",
|
||||
"陈老师",
|
||||
"杨老师",
|
||||
"周老师",
|
||||
] as const;
|
||||
|
||||
/** 班主任名单 */
|
||||
const HOMEROOM_TEACHERS = [
|
||||
"王主任",
|
||||
"李主任",
|
||||
"张主任",
|
||||
"赵主任",
|
||||
"钱主任",
|
||||
] as const;
|
||||
|
||||
/** 教室编号 */
|
||||
const ROOMS = ["A101", "A102", "A103", "A104", "A105"] as const;
|
||||
|
||||
/** 学校名 */
|
||||
const SCHOOL_NAME = "示范中学";
|
||||
|
||||
/** 8 节课的起止时间 */
|
||||
const PERIOD_TIMES: Array<{ start: string; end: string }> = [
|
||||
{ start: "08:00", end: "08:45" },
|
||||
{ start: "08:55", end: "09:40" },
|
||||
{ start: "10:00", end: "10:45" },
|
||||
{ start: "10:55", end: "11:40" },
|
||||
{ start: "13:30", end: "14:15" },
|
||||
{ start: "14:25", end: "15:10" },
|
||||
{ start: "15:25", end: "16:10" },
|
||||
{ start: "16:20", end: "17:05" },
|
||||
];
|
||||
|
||||
/** 班级学科(班主任学科) */
|
||||
const CLASS_SUBJECTS = ["数学", "语文", "英语", "物理", "化学"] as const;
|
||||
|
||||
/** 学生姓名(确定性) */
|
||||
function studentNameFor(studentIdx: number): string {
|
||||
return `学生${String(studentIdx).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 学生学号 */
|
||||
function studentNoFor(classIdx: number, studentIdx: number): string {
|
||||
return `2026${String(classIdx + 1).padStart(2, "0")}${String(studentIdx).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/** 学生邮箱 */
|
||||
function emailFor(classIdx: number, studentIdx: number): string {
|
||||
return `student${classIdx + 1}_${studentIdx}@edu.test`;
|
||||
}
|
||||
|
||||
/** 班级学生列表生成 */
|
||||
function buildStudents(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
count: number,
|
||||
): ClassDetailStudent[] {
|
||||
const items: ClassDetailStudent[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
items.push({
|
||||
id: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
name: studentNameFor(i),
|
||||
studentNo: studentNoFor(classIdx, i),
|
||||
email: emailFor(classIdx, i),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 班级详情生成 */
|
||||
function buildClassDetail(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
): ClassDetail {
|
||||
const cls = EXTENDED_CLASSES[classIdx];
|
||||
const subject = CLASS_SUBJECTS[classIdx] ?? "数学";
|
||||
return {
|
||||
id: classId,
|
||||
name: cls?.name ?? `班级${classIdx + 1}`,
|
||||
gradeId: cls?.gradeId ?? "grade-12",
|
||||
subject,
|
||||
studentCount: 30,
|
||||
homeroomTeacher: HOMEROOM_TEACHERS[classIdx] ?? "王主任",
|
||||
room: ROOMS[classIdx] ?? "A101",
|
||||
schoolName: SCHOOL_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
/** 课表生成(5 班 × 周一到周五 × 8 节 = 200 节) */
|
||||
function buildSchedule(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
): ClassScheduleItem[] {
|
||||
const cls = EXTENDED_CLASSES[classIdx];
|
||||
const items: ClassScheduleItem[] = [];
|
||||
// 每天最多 6 节(最后两节为自习或空)
|
||||
const subjectRotation = SUBJECTS;
|
||||
for (let day = 1; day <= 5; day++) {
|
||||
for (let period = 1; period <= 8; period++) {
|
||||
// 第 7、8 节多数为自习(保留少量科目)
|
||||
const isSelfStudy = period >= 7 && (day + period) % 2 === 0;
|
||||
const subjectIdx = (day + period + classIdx) % subjectRotation.length;
|
||||
const subject = isSelfStudy
|
||||
? "自习"
|
||||
: (subjectRotation[subjectIdx] ?? "数学");
|
||||
const teacherIdx = (day + period + classIdx) % CLASS_TEACHERS.length;
|
||||
const times = PERIOD_TIMES[period - 1];
|
||||
if (!times) continue;
|
||||
items.push({
|
||||
id: `sch-${classId.slice(-4)}-${day}-${period}`,
|
||||
classId,
|
||||
className: cls?.name ?? `班级${classIdx + 1}`,
|
||||
dayOfWeek: day,
|
||||
period,
|
||||
subject,
|
||||
teacherName: isSelfStudy
|
||||
? "—"
|
||||
: (CLASS_TEACHERS[teacherIdx] ?? "未知"),
|
||||
room: ROOMS[classIdx] ?? "A101",
|
||||
startTime: times.start,
|
||||
endTime: times.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 近期作业生成(5 个) */
|
||||
function buildRecentHomework(classId: string): ClassDetailHomework[] {
|
||||
const items: ClassDetailHomework[] = [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const due = new Date(today);
|
||||
due.setDate(due.getDate() + i - 1);
|
||||
const dueStr = `${due.getFullYear()}-${String(due.getMonth() + 1).padStart(2, "0")}-${String(due.getDate()).padStart(2, "0")}`;
|
||||
const subject = SUBJECTS[i % SUBJECTS.length] ?? "数学";
|
||||
items.push({
|
||||
id: `hw-${classId.slice(-4)}-${i}`,
|
||||
title: `${subject} - 作业 ${i + 1}`,
|
||||
dueDate: dueStr,
|
||||
submissionRate: 70 + ((i * 7) % 25),
|
||||
avgScore: i === 4 ? null : 75 + ((i * 5) % 18),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 概览统计 */
|
||||
function buildOverview(
|
||||
classId: string,
|
||||
classIdx: number,
|
||||
): ClassOverviewStats {
|
||||
const base = 78 + classIdx * 2;
|
||||
return {
|
||||
attendanceRate: 90 + (classIdx % 5),
|
||||
averageScore: base,
|
||||
homeworkCompletionRate: 80 + (classIdx % 10),
|
||||
recentExamTitle: `高三第${classIdx + 1}次月考`,
|
||||
recentExamAvg: base - 4,
|
||||
};
|
||||
}
|
||||
|
||||
/** 成绩趋势(6 个月) */
|
||||
function buildTrend(classId: string): ClassDetailTrendPoint[] {
|
||||
const months = ["1月", "2月", "3月", "4月", "5月", "6月"];
|
||||
const base = 75 + (classId.charCodeAt(classId.length - 1) % 8);
|
||||
return months.map((m, i) => ({
|
||||
month: m,
|
||||
avgScore: base + ((i * 3) % 8) - 2,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 班级详情缓存(按 id 索引) */
|
||||
const CLASS_DETAIL_CACHE: Record<string, ClassDetailAggregate> = {};
|
||||
|
||||
/** 初始化所有班级详情 */
|
||||
function initClassDetails(): void {
|
||||
EXTENDED_CLASSES.forEach((cls, idx) => {
|
||||
const classId = cls.id;
|
||||
CLASS_DETAIL_CACHE[classId] = {
|
||||
class: buildClassDetail(classId, idx),
|
||||
students: buildStudents(classId, idx, 30),
|
||||
schedule: buildSchedule(classId, idx),
|
||||
recentHomework: buildRecentHomework(classId),
|
||||
overview: buildOverview(classId, idx),
|
||||
trend: buildTrend(classId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
initClassDetails();
|
||||
|
||||
/** 查询班级详情聚合 */
|
||||
export function queryClassDetail(
|
||||
classId: string,
|
||||
): ClassDetailAggregate | null {
|
||||
return CLASS_DETAIL_CACHE[classId] ?? null;
|
||||
}
|
||||
|
||||
/** 查询班级课表(支持 classId 过滤) */
|
||||
export function queryClassSchedule(classId?: string): ClassScheduleItem[] {
|
||||
const allItems: ClassScheduleItem[] = [];
|
||||
for (const cls of EXTENDED_CLASSES) {
|
||||
const detail = CLASS_DETAIL_CACHE[cls.id];
|
||||
if (!detail) continue;
|
||||
if (classId && cls.id !== classId) continue;
|
||||
allItems.push(...detail.schedule);
|
||||
}
|
||||
// 按 dayOfWeek 升序,再按 period 升序
|
||||
allItems.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
|
||||
return a.period - b.period;
|
||||
});
|
||||
return allItems;
|
||||
}
|
||||
|
||||
/** 班级详情基本信息(独立访问) */
|
||||
export function queryClassBasicInfo(classId: string): ClassDetail | null {
|
||||
return CLASS_DETAIL_CACHE[classId]?.class ?? null;
|
||||
}
|
||||
|
||||
/** 班级课表按 dayOfWeek 分组 */
|
||||
export function groupScheduleByDay(
|
||||
schedule: ClassScheduleItem[],
|
||||
): Record<number, ClassScheduleItem[]> {
|
||||
const grouped: Record<number, ClassScheduleItem[]> = {
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
5: [],
|
||||
6: [],
|
||||
7: [],
|
||||
};
|
||||
for (const item of schedule) {
|
||||
const list = grouped[item.dayOfWeek];
|
||||
if (list) list.push(item);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
218
apps/teacher-portal/src/mocks/fixtures/course-plans.ts
Normal file
218
apps/teacher-portal/src/mocks/fixtures/course-plans.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Mock 课程计划数据(course-plans 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个课程计划(draft/published/archived)
|
||||
* - 每个含 4-6 章节(含学时/目标/重难点/活动)
|
||||
* - 关联教材 + 作业入口
|
||||
*/
|
||||
|
||||
import type {
|
||||
CoursePlanItem,
|
||||
CoursePlanDetail,
|
||||
CoursePlanChapter,
|
||||
CreateCoursePlanInput,
|
||||
UpdateCoursePlanInput,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
interface CoursePlanDef {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
academicYear: string;
|
||||
semester: string;
|
||||
totalHours: number;
|
||||
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
homeworkCount: number;
|
||||
chapterCount: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const COURSE_PLAN_DEFS: CoursePlanDef[] = [
|
||||
{
|
||||
id: "cp-001",
|
||||
title: "高三数学上学期课程计划",
|
||||
subject: "数学",
|
||||
grade: "高中三年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "上学期",
|
||||
totalHours: 64,
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
homeworkCount: 12,
|
||||
chapterCount: 6,
|
||||
updatedAt: "2026-07-01T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-002",
|
||||
title: "高一语文上学期课程计划",
|
||||
subject: "语文",
|
||||
grade: "高中一年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "上学期",
|
||||
totalHours: 48,
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-001",
|
||||
textbookTitle: "小学语文三年级上册",
|
||||
homeworkCount: 8,
|
||||
chapterCount: 4,
|
||||
updatedAt: "2026-07-02T14:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-003",
|
||||
title: "初中英语七年级下学期课程计划",
|
||||
subject: "英语",
|
||||
grade: "初中一年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "下学期",
|
||||
totalHours: 36,
|
||||
status: "DRAFT",
|
||||
textbookId: "tb-003",
|
||||
textbookTitle: "初中英语七年级上册",
|
||||
homeworkCount: 6,
|
||||
chapterCount: 5,
|
||||
updatedAt: "2026-07-03T10:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-004",
|
||||
title: "小学数学六年级复习课程计划",
|
||||
subject: "数学",
|
||||
grade: "小学六年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "下学期",
|
||||
totalHours: 24,
|
||||
status: "ARCHIVED",
|
||||
textbookId: "tb-002",
|
||||
textbookTitle: "小学数学六年级上册",
|
||||
homeworkCount: 4,
|
||||
chapterCount: 4,
|
||||
updatedAt: "2026-06-28T09:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "cp-005",
|
||||
title: "高三数学下学期复习冲刺",
|
||||
subject: "数学",
|
||||
grade: "高中三年级",
|
||||
academicYear: "2026-2027",
|
||||
semester: "下学期",
|
||||
totalHours: 48,
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
homeworkCount: 10,
|
||||
chapterCount: 5,
|
||||
updatedAt: "2026-07-08T13:45:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** 课程计划列表项 */
|
||||
export const mockCoursePlans: CoursePlanItem[] = COURSE_PLAN_DEFS.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
subject: p.subject,
|
||||
grade: p.grade,
|
||||
academicYear: p.academicYear,
|
||||
semester: p.semester,
|
||||
totalHours: p.totalHours,
|
||||
status: p.status,
|
||||
textbookId: p.textbookId,
|
||||
updatedAt: p.updatedAt,
|
||||
}));
|
||||
|
||||
/** 按状态筛选 */
|
||||
export function filterCoursePlans(
|
||||
status?: "DRAFT" | "PUBLISHED" | "ARCHIVED" | "ALL" | null,
|
||||
): CoursePlanItem[] {
|
||||
if (!status || status === "ALL") return mockCoursePlans;
|
||||
return mockCoursePlans.filter((p) => p.status === status);
|
||||
}
|
||||
|
||||
/** 生成章节(按章节模板) */
|
||||
function generateChapters(planId: string, count: number): CoursePlanChapter[] {
|
||||
const template = [
|
||||
{ title: "第一章 导入与基础", obj: "建立学科认知框架", kp: "基础概念与运算规则" },
|
||||
{ title: "第二章 核心知识", obj: "掌握核心知识与方法", kp: "核心定理与推导" },
|
||||
{ title: "第三章 综合应用", obj: "能够解决综合问题", kp: "应用场景与解题策略" },
|
||||
{ title: "第四章 拓展提升", obj: "拓展学科思维", kp: "高阶方法与变式" },
|
||||
{ title: "第五章 复习巩固", obj: "系统化复习", kp: "知识网络与方法对比" },
|
||||
{ title: "第六章 评估反馈", obj: "阶段性评估", kp: "易错点与补强" },
|
||||
];
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const t = template[i] ?? template[0]!;
|
||||
return {
|
||||
id: `${planId}-ch-${String(i + 1).padStart(2, "0")}`,
|
||||
title: t.title,
|
||||
hours: 8 + i * 2,
|
||||
objectives: t.obj,
|
||||
keyPoints: t.kp,
|
||||
activities: `讲授 + 小组讨论 + 课堂练习 + 课后作业`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询课程计划详情 */
|
||||
export function findCoursePlanDetail(id: string): CoursePlanDetail | null {
|
||||
const found = COURSE_PLAN_DEFS.find((p) => p.id === id);
|
||||
if (!found) return null;
|
||||
return {
|
||||
id: found.id,
|
||||
title: found.title,
|
||||
subject: found.subject,
|
||||
grade: found.grade,
|
||||
academicYear: found.academicYear,
|
||||
semester: found.semester,
|
||||
totalHours: found.totalHours,
|
||||
status: found.status,
|
||||
textbookId: found.textbookId,
|
||||
textbookTitle: found.textbookTitle,
|
||||
homeworkCount: found.homeworkCount,
|
||||
chapters: generateChapters(found.id, found.chapterCount),
|
||||
updatedAt: found.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建课程计划 */
|
||||
export function createMockCoursePlan(
|
||||
input: CreateCoursePlanInput,
|
||||
): CoursePlanItem {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `cp-${String(Date.now()).slice(-6)}`,
|
||||
title: input.title,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
academicYear: input.academicYear,
|
||||
semester: input.semester,
|
||||
totalHours: input.totalHours,
|
||||
status: "DRAFT",
|
||||
textbookId: input.textbookId ?? null,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新课程计划 */
|
||||
export function updateMockCoursePlan(
|
||||
input: UpdateCoursePlanInput,
|
||||
): CoursePlanItem | null {
|
||||
const found = COURSE_PLAN_DEFS.find((p) => p.id === input.id);
|
||||
if (!found) return null;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: found.id,
|
||||
title: input.title ?? found.title,
|
||||
subject: found.subject,
|
||||
grade: found.grade,
|
||||
academicYear: found.academicYear,
|
||||
semester: found.semester,
|
||||
totalHours: found.totalHours,
|
||||
status: input.status ?? found.status,
|
||||
textbookId: found.textbookId,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
233
apps/teacher-portal/src/mocks/fixtures/diagnostic.ts
Normal file
233
apps/teacher-portal/src/mocks/fixtures/diagnostic.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Mock 诊断数据(diagnostic 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 个班级诊断(每班 10 知识点掌握度 + 学生排名)
|
||||
* - 5 个学生诊断(雷达图 + 班级对比 + 报告列表)
|
||||
* - 诊断报告列表(按 type + status 筛选)
|
||||
*/
|
||||
|
||||
import type {
|
||||
DiagnosticReportItem,
|
||||
DiagnosticReportType,
|
||||
DiagnosticReportStatus,
|
||||
ClassDiagnostic,
|
||||
StudentDiagnostic,
|
||||
DiagnosticKpMastery,
|
||||
DiagnosticStudentRank,
|
||||
DiagnosticRadarPoint,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 5 个班级(复用 grades-extended 的 class id 保持一致) */
|
||||
export const DIAGNOSTIC_CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班" },
|
||||
] as const;
|
||||
|
||||
/** 10 个知识点(数学/语文混合) */
|
||||
const KNOWLEDGE_POINTS = [
|
||||
"函数与导数",
|
||||
"数列",
|
||||
"立体几何",
|
||||
"解析几何",
|
||||
"概率统计",
|
||||
"实数运算",
|
||||
"文言文基础",
|
||||
"现代文阅读",
|
||||
"古诗词鉴赏",
|
||||
"议论文写作",
|
||||
] as const;
|
||||
|
||||
/** 班级平均基准(用于确定性 mock) */
|
||||
const CLASS_BASE: Record<string, number> = {
|
||||
"550e8400-e29b-41d4-a716-446655440010": 78,
|
||||
"550e8400-e29b-41d4-a716-446655440011": 82,
|
||||
"550e8400-e29b-41d4-a716-446655440012": 75,
|
||||
"550e8400-e29b-41d4-a716-446655440013": 80,
|
||||
"550e8400-e29b-41d4-a716-446655440014": 85,
|
||||
};
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? (sorted[mid] ?? 0)
|
||||
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
||||
}
|
||||
|
||||
/** 诊断报告列表 mock(12 条) */
|
||||
const REPORT_DEFS: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: DiagnosticReportType;
|
||||
targetType: string;
|
||||
status: DiagnosticReportStatus;
|
||||
createdAt: string;
|
||||
}> = [
|
||||
{ id: "dr-001", title: "高三(1)班 期中诊断报告", type: "CLASS", targetType: "高三(1)班", status: "PUBLISHED", createdAt: "2026-07-01T09:00:00Z" },
|
||||
{ id: "dr-002", title: "学生01 个人诊断报告", type: "INDIVIDUAL", targetType: "学生01", status: "PUBLISHED", createdAt: "2026-07-02T10:30:00Z" },
|
||||
{ id: "dr-003", title: "高三年级 期中诊断", type: "GRADE", targetType: "高三年级", status: "PUBLISHED", createdAt: "2026-07-03T14:15:00Z" },
|
||||
{ id: "dr-004", title: "高三(2)班 月考诊断", type: "CLASS", targetType: "高三(2)班", status: "DRAFT", createdAt: "2026-07-04T11:00:00Z" },
|
||||
{ id: "dr-005", title: "学生02 个人诊断", type: "INDIVIDUAL", targetType: "学生02", status: "DRAFT", createdAt: "2026-07-05T08:45:00Z" },
|
||||
{ id: "dr-006", title: "高三(3)班 单元诊断", type: "CLASS", targetType: "高三(3)班", status: "ARCHIVED", createdAt: "2026-06-28T16:20:00Z" },
|
||||
{ id: "dr-007", title: "学生03 个人诊断", type: "INDIVIDUAL", targetType: "学生03", status: "PUBLISHED", createdAt: "2026-07-06T09:00:00Z" },
|
||||
{ id: "dr-008", title: "高三年级 期末诊断", type: "GRADE", targetType: "高三年级", status: "DRAFT", createdAt: "2026-07-07T13:30:00Z" },
|
||||
{ id: "dr-009", title: "高三(4)班 月考诊断", type: "CLASS", targetType: "高三(4)班", status: "PUBLISHED", createdAt: "2026-07-08T10:15:00Z" },
|
||||
{ id: "dr-010", title: "学生04 个人诊断", type: "INDIVIDUAL", targetType: "学生04", status: "ARCHIVED", createdAt: "2026-06-30T15:00:00Z" },
|
||||
{ id: "dr-011", title: "高三(5)班 期中诊断", type: "CLASS", targetType: "高三(5)班", status: "PUBLISHED", createdAt: "2026-07-09T11:30:00Z" },
|
||||
{ id: "dr-012", title: "学生05 个人诊断", type: "INDIVIDUAL", targetType: "学生05", status: "DRAFT", createdAt: "2026-07-10T14:00:00Z" },
|
||||
];
|
||||
|
||||
/** 按类型 + 状态筛选诊断报告 */
|
||||
export function filterDiagnosticReports(
|
||||
type?: DiagnosticReportType | null,
|
||||
status?: DiagnosticReportStatus | null,
|
||||
): DiagnosticReportItem[] {
|
||||
return REPORT_DEFS.filter((r) => {
|
||||
if (type && r.type !== type) return false;
|
||||
if (status && r.status !== status) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级知识点掌握度(10 知识点) */
|
||||
function generateKpMastery(classId: string): DiagnosticKpMastery[] {
|
||||
const base = CLASS_BASE[classId] ?? 78;
|
||||
return KNOWLEDGE_POINTS.map((kp, i) => {
|
||||
const seed = strToSeed(`${classId}-kp-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const avg = Math.max(30, Math.min(98, base + delta));
|
||||
// 模拟 20 学生掌握度分布
|
||||
const studentCount = 20;
|
||||
const mastered = Math.round(studentCount * (avg / 100));
|
||||
const weak = Math.max(0, Math.round(studentCount * ((100 - avg) / 100) * 0.4));
|
||||
const partial = Math.max(0, studentCount - mastered - weak);
|
||||
// 中位数:基于 avg 的稳定 mock
|
||||
const med = Math.max(30, Math.min(98, avg - 2 + (i % 3) - 1));
|
||||
return {
|
||||
knowledgePoint: kp,
|
||||
avg,
|
||||
median: med,
|
||||
studentCount,
|
||||
distribution: { mastered, partial, weak },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级学生排名(20 学生) */
|
||||
function generateRankings(classId: string): DiagnosticStudentRank[] {
|
||||
const base = CLASS_BASE[classId] ?? 78;
|
||||
const ranks: DiagnosticStudentRank[] = [];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const seed = strToSeed(`${classId}-rank-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const masteryAvg = Math.max(30, Math.min(98, base + delta));
|
||||
ranks.push({
|
||||
rank: i,
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
masteryAvg,
|
||||
});
|
||||
}
|
||||
ranks.sort((a, b) => b.masteryAvg - a.masteryAvg);
|
||||
return ranks.map((r, i) => ({ ...r, rank: i + 1 }));
|
||||
}
|
||||
|
||||
/** 按班级 ID 查询班级诊断 */
|
||||
export function findClassDiagnostic(classId: string): ClassDiagnostic | null {
|
||||
const cls = DIAGNOSTIC_CLASSES.find((c) => c.id === classId);
|
||||
if (!cls) return null;
|
||||
const kpMastery = generateKpMastery(classId);
|
||||
// 取前 5 知识点作为顶部摘要
|
||||
const summary = kpMastery.slice(0, 5).map((k) => ({
|
||||
knowledgePoint: k.knowledgePoint,
|
||||
avg: k.avg,
|
||||
}));
|
||||
return {
|
||||
classId,
|
||||
className: cls.name,
|
||||
studentCount: 20,
|
||||
summary,
|
||||
kpMastery,
|
||||
rankings: generateRankings(classId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 5 个学生诊断 */
|
||||
const STUDENT_DIAGNOSTIC_DEFS: Array<{ id: string; name: string }> = [
|
||||
{ id: "stu-001", name: "学生01" },
|
||||
{ id: "stu-002", name: "学生02" },
|
||||
{ id: "stu-003", name: "学生03" },
|
||||
{ id: "stu-004", name: "学生04" },
|
||||
{ id: "stu-005", name: "学生05" },
|
||||
];
|
||||
|
||||
/** 生成学生雷达图(6 轴) */
|
||||
function generateRadar(studentId: string): DiagnosticRadarPoint[] {
|
||||
const axes = ["函数", "数列", "几何", "概率", "统计", "导数"];
|
||||
const classId = "550e8400-e29b-41d4-a716-446655440010";
|
||||
const classBase = CLASS_BASE[classId] ?? 78;
|
||||
return axes.map((axis, i) => {
|
||||
const seed = strToSeed(`${studentId}-radar-${i}`);
|
||||
const studentDelta = Math.round((seededRandom(seed) - 0.5) * 40);
|
||||
const classDelta = Math.round((seededRandom(seed + 1) - 0.5) * 10);
|
||||
return {
|
||||
axis,
|
||||
value: Math.max(20, Math.min(98, classBase + studentDelta)),
|
||||
classAvg: Math.max(40, Math.min(95, classBase + classDelta)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按学生 ID 查询学生诊断 */
|
||||
export function findStudentDiagnostic(
|
||||
studentId: string,
|
||||
): StudentDiagnostic | null {
|
||||
const found = STUDENT_DIAGNOSTIC_DEFS.find((s) => s.id === studentId);
|
||||
const name = found?.name ?? `学生${studentId.slice(-2)}`;
|
||||
return {
|
||||
studentId,
|
||||
studentName: name,
|
||||
radar: generateRadar(studentId),
|
||||
reports: [
|
||||
{ id: `dr-${studentId}-1`, title: `${name} 期中诊断`, createdAt: "2026-07-01T09:00:00Z", status: "PUBLISHED" },
|
||||
{ id: `dr-${studentId}-2`, title: `${name} 月考诊断`, createdAt: "2026-06-15T10:30:00Z", status: "ARCHIVED" },
|
||||
{ id: `dr-${studentId}-3`, title: `${name} 单元诊断`, createdAt: "2026-05-20T14:00:00Z", status: "ARCHIVED" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 班级对比(5 班级 × 平均掌握度) */
|
||||
export function getClassDiagnosticComparison(): Array<{
|
||||
classId: string;
|
||||
className: string;
|
||||
avgMastery: number;
|
||||
}> {
|
||||
return DIAGNOSTIC_CLASSES.map((c) => {
|
||||
const base = CLASS_BASE[c.id] ?? 78;
|
||||
return { classId: c.id, className: c.name, avgMastery: base };
|
||||
});
|
||||
}
|
||||
|
||||
/** 中位数工具导出(便于 handler 使用) */
|
||||
export { median };
|
||||
298
apps/teacher-portal/src/mocks/fixtures/elective.ts
Normal file
298
apps/teacher-portal/src/mocks/fixtures/elective.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Mock 选修课数据(P7-advanced:选修课模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 10 个选修课(覆盖 draft/open/closed/cancelled 状态)
|
||||
* - 每个含学生选课列表
|
||||
* - 按 ID 查找详情 + CRUD mock 函数
|
||||
*/
|
||||
|
||||
import type {
|
||||
ElectiveCourseItem,
|
||||
ElectiveCourseDetail,
|
||||
ElectiveStudent,
|
||||
ElectiveCourseStatus,
|
||||
CreateElectiveCourseInput,
|
||||
UpdateElectiveCourseInput,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 当前教师 ID(mock:登录用户) */
|
||||
export const CURRENT_TEACHER_ID = "tch-001";
|
||||
export const CURRENT_TEACHER_NAME = "王老师";
|
||||
|
||||
/** 10 个选修课定义(覆盖 4 种状态) */
|
||||
const ELECTIVE_COURSE_DEFS: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
capacity: number;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
semester: string;
|
||||
status: ElectiveCourseStatus;
|
||||
description: string | null;
|
||||
enrolledCount: number;
|
||||
}> = [
|
||||
{
|
||||
id: "ele-001",
|
||||
title: "趣味数学建模",
|
||||
subject: "数学",
|
||||
grade: "高一",
|
||||
capacity: 40,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: "通过实际案例学习数学建模方法",
|
||||
enrolledCount: 32,
|
||||
},
|
||||
{
|
||||
id: "ele-002",
|
||||
title: "现代文学赏析",
|
||||
subject: "语文",
|
||||
grade: "高二",
|
||||
capacity: 35,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: "阅读分析当代经典文学作品",
|
||||
enrolledCount: 28,
|
||||
},
|
||||
{
|
||||
id: "ele-003",
|
||||
title: "物理实验探究",
|
||||
subject: "物理",
|
||||
grade: "高一",
|
||||
capacity: 30,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "closed",
|
||||
description: "动手实验,探究物理规律",
|
||||
enrolledCount: 30,
|
||||
},
|
||||
{
|
||||
id: "ele-004",
|
||||
title: "英语口语训练",
|
||||
subject: "英语",
|
||||
grade: "高二",
|
||||
capacity: 25,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: null,
|
||||
enrolledCount: 20,
|
||||
},
|
||||
{
|
||||
id: "ele-005",
|
||||
title: "化学与生活",
|
||||
subject: "化学",
|
||||
grade: "高一",
|
||||
capacity: 40,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "draft",
|
||||
description: "了解化学在日常生活中的应用",
|
||||
enrolledCount: 0,
|
||||
},
|
||||
{
|
||||
id: "ele-006",
|
||||
title: "生物多样性",
|
||||
subject: "生物",
|
||||
grade: "高二",
|
||||
capacity: 35,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "draft",
|
||||
description: "探索生态系统与物种保护",
|
||||
enrolledCount: 0,
|
||||
},
|
||||
{
|
||||
id: "ele-007",
|
||||
title: "历史人物评说",
|
||||
subject: "历史",
|
||||
grade: "高二",
|
||||
capacity: 50,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2025秋季",
|
||||
status: "closed",
|
||||
description: "评析历史人物的功过是非",
|
||||
enrolledCount: 48,
|
||||
},
|
||||
{
|
||||
id: "ele-008",
|
||||
title: "地理与环境",
|
||||
subject: "地理",
|
||||
grade: "高一",
|
||||
capacity: 40,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2025秋季",
|
||||
status: "cancelled",
|
||||
description: "已取消:教师调动",
|
||||
enrolledCount: 0,
|
||||
},
|
||||
{
|
||||
id: "ele-009",
|
||||
title: "信息技术基础",
|
||||
subject: "信息技术",
|
||||
grade: "高一",
|
||||
capacity: 60,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "open",
|
||||
description: "学习编程基础与算法思维",
|
||||
enrolledCount: 55,
|
||||
},
|
||||
{
|
||||
id: "ele-010",
|
||||
title: "美术鉴赏",
|
||||
subject: "美术",
|
||||
grade: "高二",
|
||||
capacity: 30,
|
||||
teacherId: CURRENT_TEACHER_ID,
|
||||
teacherName: CURRENT_TEACHER_NAME,
|
||||
semester: "2026春季",
|
||||
status: "closed",
|
||||
description: "鉴赏中外美术名作",
|
||||
enrolledCount: 30,
|
||||
},
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 生成单个选修课的学生列表(按 enrolledCount) */
|
||||
function generateStudents(
|
||||
courseId: string,
|
||||
enrolledCount: number,
|
||||
): ElectiveStudent[] {
|
||||
const list: ElectiveStudent[] = [];
|
||||
const now = Date.now();
|
||||
for (let i = 1; i <= enrolledCount; i++) {
|
||||
const seed = strToSeed(`${courseId}-stu-${i}`);
|
||||
const isCancelled = seededRandom(seed) > 0.9;
|
||||
list.push({
|
||||
studentId: `stu-${courseId}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(4, "0")}`,
|
||||
enrolledAt: new Date(now - i * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: isCancelled ? "cancelled" : "enrolled",
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 生成选修课列表(可按 status / subject 过滤) */
|
||||
export function generateElectiveCourses(
|
||||
status?: string,
|
||||
subject?: string,
|
||||
): ElectiveCourseItem[] {
|
||||
return ELECTIVE_COURSE_DEFS.filter((c) => {
|
||||
if (status && c.status !== status) return false;
|
||||
if (subject && c.subject !== subject) return false;
|
||||
return true;
|
||||
}).map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
subject: c.subject,
|
||||
grade: c.grade,
|
||||
capacity: c.capacity,
|
||||
enrolledCount: c.enrolledCount,
|
||||
teacherId: c.teacherId,
|
||||
teacherName: c.teacherName,
|
||||
semester: c.semester,
|
||||
status: c.status,
|
||||
description: c.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按 ID 查找选修课详情(含学生列表) */
|
||||
export function findElectiveCourseDetail(
|
||||
id: string,
|
||||
): ElectiveCourseDetail | null {
|
||||
const def = ELECTIVE_COURSE_DEFS.find((c) => c.id === id);
|
||||
if (!def) return null;
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: def.id,
|
||||
title: def.title,
|
||||
subject: def.subject,
|
||||
grade: def.grade,
|
||||
capacity: def.capacity,
|
||||
enrolledCount: def.enrolledCount,
|
||||
teacherId: def.teacherId,
|
||||
teacherName: def.teacherName,
|
||||
semester: def.semester,
|
||||
status: def.status,
|
||||
description: def.description,
|
||||
students: generateStudents(def.id, def.enrolledCount),
|
||||
createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建选修课(mock:返回新 ID + draft 状态) */
|
||||
export function createMockElectiveCourse(
|
||||
_input: CreateElectiveCourseInput,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
const randomId = `ele-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return {
|
||||
id: randomId,
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新选修课(mock:返回更新时间) */
|
||||
export function updateMockElectiveCourse(
|
||||
input: UpdateElectiveCourseInput,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
return {
|
||||
id: input.id,
|
||||
status: "draft",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 发布选修课(mock:返回 open 状态) */
|
||||
export function publishMockElectiveCourse(
|
||||
id: string,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
return {
|
||||
id,
|
||||
status: "open",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 取消选修课(mock:返回 cancelled 状态) */
|
||||
export function cancelMockElectiveCourse(
|
||||
id: string,
|
||||
): { id: string; status: ElectiveCourseStatus; updatedAt: string } {
|
||||
return {
|
||||
id,
|
||||
status: "cancelled",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
182
apps/teacher-portal/src/mocks/fixtures/error-book.ts
Normal file
182
apps/teacher-portal/src/mocks/fixtures/error-book.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Mock 错题本数据(error-book 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 班级 × 6 学科 错题统计
|
||||
* - 章节薄弱度(5 学科 × 10 章节)
|
||||
* - 知识点薄弱度(5 学科 × 20 知识点)
|
||||
* - Top10 高频错题
|
||||
* - 学生错题分组(前 5 学生)
|
||||
*/
|
||||
|
||||
import type {
|
||||
ErrorBook,
|
||||
ErrorBookSummary,
|
||||
ErrorBookClassComparison,
|
||||
ErrorBookChapterWeakness,
|
||||
ErrorBookKpWeakness,
|
||||
ErrorBookStudentGroup,
|
||||
ErrorBookTopQuestion,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 5 个班级 */
|
||||
const CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", base: 120 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", base: 110 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", base: 135 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", base: 105 },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", base: 95 },
|
||||
] as const;
|
||||
|
||||
/** 6 学科(错题本用) */
|
||||
export const ERROR_BOOK_SUBJECTS = [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
] as const;
|
||||
|
||||
/** 章节模板(每学科 10 章节) */
|
||||
const CHAPTER_TEMPLATES: Record<string, string[]> = {
|
||||
语文: ["现代文阅读", "古诗词鉴赏", "文言文阅读", "成语运用", "病句辨析", "论述类文本", "实用类文本", "语言运用", "议论文写作", "记叙文写作"],
|
||||
数学: ["集合与逻辑", "函数与导数", "三角函数", "数列", "立体几何", "解析几何", "概率统计", "不等式", "复数", "向量"],
|
||||
英语: ["听力", "阅读理解", "完形填空", "语法填空", "短文改错", "书面表达", "七选五", "词汇运用", "句型转换", "翻译"],
|
||||
物理: ["力学", "运动学", "动力学", "能量守恒", "动量", "电学", "磁学", "光学", "热学", "近代物理"],
|
||||
化学: ["化学键", "化学反应", "化学平衡", "电离", "水解", "氧化还原", "元素周期", "有机化学", "实验化学", "计算化学"],
|
||||
生物: ["细胞", "遗传", "变异", "进化", "生态", "植物激素", "人体生理", "免疫", "微生物", "生物技术"],
|
||||
};
|
||||
|
||||
/** 知识点薄弱度模板(每学科 10 知识点,雷达图用) */
|
||||
const KP_TEMPLATES: Record<string, string[]> = {
|
||||
语文: ["主旨概括", "细节理解", "推理判断", "词义猜测", "论证分析", "情感把握", "手法鉴赏", "结构梳理", "语言品味", "文化常识"],
|
||||
数学: ["函数定义域", "单调性", "奇偶性", "导数运算", "极值最值", "数列求和", "向量运算", "概率计算", "几何证明", "参数方程"],
|
||||
英语: ["主旨题", "细节题", "推理题", "词义题", "作者态度", "完形填空", "语法结构", "词汇辨析", "句型转换", "书面表达"],
|
||||
物理: ["受力分析", "运动方程", "能量守恒", "动量守恒", "电场", "磁场", "电磁感应", "光学成像", "热学定律", "原子结构"],
|
||||
化学: ["化学键类型", "反应速率", "化学平衡", "电离平衡", "水解平衡", "氧化剂", "还原剂", "元素周期", "官能团", "实验操作"],
|
||||
生物: ["细胞结构", "光合作用", "细胞呼吸", "有丝分裂", "减数分裂", "遗传规律", "基因突变", "生态系统", "激素调节", "免疫机制"],
|
||||
};
|
||||
|
||||
/** 班级对比 */
|
||||
export function getClassErrorComparison(
|
||||
subject: string,
|
||||
): ErrorBookClassComparison[] {
|
||||
return CLASSES.map((c) => {
|
||||
// 学科偏移:理科偏多错题
|
||||
const subjOffset = subject === "数学" || subject === "物理" ? 20 : 0;
|
||||
const subjOffset2 = subject === "语文" || subject === "英语" ? -10 : 0;
|
||||
return {
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
errorCount: c.base + subjOffset + subjOffset2,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 章节薄弱度(10 章节) */
|
||||
export function getChapterWeakness(
|
||||
subject: string,
|
||||
): ErrorBookChapterWeakness[] {
|
||||
const chapters = CHAPTER_TEMPLATES[subject] ?? CHAPTER_TEMPLATES["数学"]!;
|
||||
return chapters.map((ch, i) => ({
|
||||
chapter: ch,
|
||||
errorCount: 20 + i * 5 + ((subject.length + i) % 7) * 3,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 知识点薄弱度(10 知识点,雷达图用) */
|
||||
export function getKpWeakness(subject: string): ErrorBookKpWeakness[] {
|
||||
const kps = KP_TEMPLATES[subject] ?? KP_TEMPLATES["数学"]!;
|
||||
return kps.map((kp, i) => ({
|
||||
knowledgePoint: kp,
|
||||
errorRate: Math.max(20, Math.min(85, 30 + i * 4 + ((subject.length + i) % 5) * 3)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 学生错题分组(前 5 学生) */
|
||||
export function getStudentGroups(
|
||||
subject: string,
|
||||
): ErrorBookStudentGroup[] {
|
||||
const students = [
|
||||
{ id: "stu-001", name: "学生01", base: 28 },
|
||||
{ id: "stu-002", name: "学生02", base: 25 },
|
||||
{ id: "stu-003", name: "学生03", base: 22 },
|
||||
{ id: "stu-004", name: "学生04", base: 18 },
|
||||
{ id: "stu-005", name: "学生05", base: 15 },
|
||||
];
|
||||
return students.map((s) => {
|
||||
const subjOffset = subject === "数学" ? 5 : 0;
|
||||
return {
|
||||
studentId: s.id,
|
||||
studentName: s.name,
|
||||
errorCount: s.base + subjOffset,
|
||||
topErrors: [
|
||||
{
|
||||
questionId: `q-${s.id}-1`,
|
||||
content: `${subject}高频错题示例:${subject}基础知识应用题(题干较长时显示前 100 字)`,
|
||||
errorRate: 0.75,
|
||||
},
|
||||
{
|
||||
questionId: `q-${s.id}-2`,
|
||||
content: `${subject}中档题:${subject}综合题分析题(含图形或长文本描述)`,
|
||||
errorRate: 0.62,
|
||||
},
|
||||
{
|
||||
questionId: `q-${s.id}-3`,
|
||||
content: `${subject}概念辨析题:${subject}易错概念对比题`,
|
||||
errorRate: 0.55,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Top10 高频错题 */
|
||||
export function getTopQuestions(subject: string): ErrorBookTopQuestion[] {
|
||||
const items: ErrorBookTopQuestion[] = [];
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
items.push({
|
||||
questionId: `q-top-${subject}-${i}`,
|
||||
content: `【${subject}·高频】第 ${i} 题:${subject}综合应用题示例(题干内容较长,此处显示前 100 字以便预览,完整题干在详情页展示)`,
|
||||
errorRate: 0.85 - i * 0.05,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 完整错题本(聚合查询) */
|
||||
export function getErrorBook(
|
||||
subject: string,
|
||||
classId?: string | null,
|
||||
): ErrorBook {
|
||||
const classComparison = getClassErrorComparison(subject);
|
||||
const targetClass = classId
|
||||
? classComparison.find((c) => c.classId === classId)
|
||||
: null;
|
||||
const totalErrors = targetClass
|
||||
? targetClass.errorCount
|
||||
: classComparison.reduce((acc, c) => acc + c.errorCount, 0);
|
||||
|
||||
const summary: ErrorBookSummary = {
|
||||
totalErrors,
|
||||
highFreqErrors: Math.floor(totalErrors * 0.15),
|
||||
weakKpCount: 6,
|
||||
avgErrorRate: 0.42,
|
||||
trendUp: subject === "数学" || subject === "物理",
|
||||
};
|
||||
|
||||
return {
|
||||
subject,
|
||||
classId: classId ?? "",
|
||||
summary,
|
||||
classComparison,
|
||||
chapterWeakness: getChapterWeakness(subject),
|
||||
kpWeakness: getKpWeakness(subject),
|
||||
studentGroups: getStudentGroups(subject),
|
||||
topQuestions: getTopQuestions(subject),
|
||||
};
|
||||
}
|
||||
353
apps/teacher-portal/src/mocks/fixtures/exam-analytics.ts
Normal file
353
apps/teacher-portal/src/mocks/fixtures/exam-analytics.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Mock 考试分析数据(core-edu 域,P7 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 场考试 × 30 学生 × 20 题
|
||||
* - 分数分布、及格率、每题正确率、知识点掌握度、班级对比、历次趋势、学生排名
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExamAnalytics,
|
||||
ExamAnalyticsSummary,
|
||||
ExamScoreBand,
|
||||
ExamQuestionAccuracy,
|
||||
ExamKpMastery,
|
||||
ExamClassComparison,
|
||||
ExamHistoryTrend,
|
||||
ExamStudentRank,
|
||||
ExamBuildStructure,
|
||||
ExamBuildNode,
|
||||
} from "@/lib/graphql-p7-exams";
|
||||
import type { QuestionItem } from "@/lib/graphql-p7-exams";
|
||||
import type { ExamDetail } from "@/lib/graphql";
|
||||
import { MOCK_QUESTIONS } from "./questions";
|
||||
import { EXTENDED_CLASSES } from "./grades-extended";
|
||||
|
||||
/** 5 场考试定义 */
|
||||
interface ExamDef {
|
||||
examId: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
examDate: string;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
const NOW = Date.now();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const EXAM_DEFS: ExamDef[] = [
|
||||
{
|
||||
examId: "exam-ana-001",
|
||||
title: "2026 春季期中考试",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "c-001",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "高三(1)班",
|
||||
examDate: new Date(NOW - 7 * DAY).toISOString(),
|
||||
totalScore: 150,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-002",
|
||||
title: "单元测试 - 函数与导数",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "c-001",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "高三(1)班",
|
||||
examDate: new Date(NOW - 14 * DAY).toISOString(),
|
||||
totalScore: 100,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-003",
|
||||
title: "月考 - 数学综合",
|
||||
classId: EXTENDED_CLASSES[1]?.id ?? "c-002",
|
||||
className: EXTENDED_CLASSES[1]?.name ?? "高三(2)班",
|
||||
examDate: new Date(NOW - 21 * DAY).toISOString(),
|
||||
totalScore: 120,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-004",
|
||||
title: "周测 - 解析几何",
|
||||
classId: EXTENDED_CLASSES[2]?.id ?? "c-003",
|
||||
className: EXTENDED_CLASSES[2]?.name ?? "高三(3)班",
|
||||
examDate: new Date(NOW - 3 * DAY).toISOString(),
|
||||
totalScore: 80,
|
||||
},
|
||||
{
|
||||
examId: "exam-ana-005",
|
||||
title: "阶段测验 - 概率统计",
|
||||
classId: EXTENDED_CLASSES[3]?.id ?? "c-004",
|
||||
className: EXTENDED_CLASSES[3]?.name ?? "高三(4)班",
|
||||
examDate: new Date(NOW - 1 * DAY).toISOString(),
|
||||
totalScore: 100,
|
||||
},
|
||||
];
|
||||
|
||||
/** 确定性伪随机(与 grades-extended 一致) */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 计算等级 */
|
||||
function scoreToLevel(score: number, total: number): ExamStudentRank["level"] {
|
||||
const ratio = total > 0 ? score / total : 0;
|
||||
if (ratio >= 0.9) return "A";
|
||||
if (ratio >= 0.8) return "B";
|
||||
if (ratio >= 0.7) return "C";
|
||||
if (ratio >= 0.6) return "D";
|
||||
return "E";
|
||||
}
|
||||
|
||||
/** 生成 30 个学生成绩(按考试 ID 稳定) */
|
||||
function generateStudentScores(
|
||||
examId: string,
|
||||
totalScore: number,
|
||||
): Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
}> {
|
||||
const base = totalScore * 0.7; // 均分约 70%
|
||||
const result: Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
}> = [];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const seed = strToSeed(`${examId}-stu-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.4) * totalScore * 0.4);
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.min(totalScore, Math.round(base + delta)),
|
||||
);
|
||||
result.push({
|
||||
studentId: `stu-${examId.slice(-3)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
totalScore: score,
|
||||
});
|
||||
}
|
||||
result.sort((a, b) => b.totalScore - a.totalScore);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 生成 5 段分数分布 */
|
||||
function generateDistribution(
|
||||
scores: number[],
|
||||
total: number,
|
||||
): ExamScoreBand[] {
|
||||
const bands: ExamScoreBand[] = [
|
||||
{ label: "0-59", min: 0, max: 59, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
];
|
||||
// 按 0-100 百分比归一化(total != 100 时换算)
|
||||
scores.forEach((s) => {
|
||||
const pct = total > 0 ? (s / total) * 100 : 0;
|
||||
if (pct < 60) bands[0]!.count += 1;
|
||||
else if (pct < 70) bands[1]!.count += 1;
|
||||
else if (pct < 80) bands[2]!.count += 1;
|
||||
else if (pct < 90) bands[3]!.count += 1;
|
||||
else bands[4]!.count += 1;
|
||||
});
|
||||
return bands;
|
||||
}
|
||||
|
||||
/** 选取前 20 题(不足 20 则循环补齐) */
|
||||
function pick20Questions(): QuestionItem[] {
|
||||
const items: QuestionItem[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const q = MOCK_QUESTIONS[i % MOCK_QUESTIONS.length];
|
||||
if (q) items.push(q);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const EXAM_QUESTIONS = pick20Questions();
|
||||
|
||||
/** 生成每题正确率 */
|
||||
function generateQuestionAccuracy(
|
||||
examId: string,
|
||||
): ExamQuestionAccuracy[] {
|
||||
return EXAM_QUESTIONS.map((q, i) => {
|
||||
const seed = strToSeed(`${examId}-qa-${i}`);
|
||||
const correctRate = Math.round(40 + seededRandom(seed) * 55); // 40-95
|
||||
const avgScore = Math.round((q.score * correctRate) / 100);
|
||||
return {
|
||||
questionId: q.questionId,
|
||||
questionTitle: q.content.slice(0, 40),
|
||||
order: i + 1,
|
||||
correctRate,
|
||||
avgScore,
|
||||
maxScore: q.score,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 5 个知识点(用于雷达图) */
|
||||
const KP_NAMES = [
|
||||
"函数与导数",
|
||||
"数列",
|
||||
"立体几何",
|
||||
"解析几何",
|
||||
"概率统计",
|
||||
];
|
||||
|
||||
/** 生成知识点掌握度 */
|
||||
function generateKpMastery(examId: string): ExamKpMastery[] {
|
||||
return KP_NAMES.map((name, i) => {
|
||||
const seed = strToSeed(`${examId}-kp-${i}`);
|
||||
const mastery = Math.round(50 + seededRandom(seed) * 45); // 50-95
|
||||
return { knowledgePoint: name, mastery };
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级对比 */
|
||||
function generateClassComparison(
|
||||
targetClassId: string,
|
||||
avg: number,
|
||||
): ExamClassComparison[] {
|
||||
return EXTENDED_CLASSES.map((c) => {
|
||||
const seed = strToSeed(`cc-${c.id}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 16);
|
||||
const classAvg = Math.max(0, Math.round(avg + delta));
|
||||
return {
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
avg: classAvg,
|
||||
significant: c.id === targetClassId ? false : Math.abs(delta) >= 7,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成历次考试趋势 */
|
||||
function generateHistoryTrend(): ExamHistoryTrend[] {
|
||||
return EXAM_DEFS.map((e) => {
|
||||
const seed = strToSeed(`ht-${e.examId}`);
|
||||
const avg = Math.round(60 + seededRandom(seed) * 25); // 60-85
|
||||
return {
|
||||
examTitle: e.title,
|
||||
examDate: e.examDate,
|
||||
avg,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成考试分析完整数据 */
|
||||
export function generateExamAnalytics(examId: string): ExamAnalytics | null {
|
||||
const def = EXAM_DEFS.find((e) => e.examId === examId);
|
||||
if (!def) return null;
|
||||
const studentScores = generateStudentScores(examId, def.totalScore);
|
||||
const scores = studentScores.map((s) => s.totalScore);
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0);
|
||||
const avg = Math.round((sum / scores.length) * 10) / 10;
|
||||
const max = Math.max(...scores);
|
||||
const min = Math.min(...scores);
|
||||
const passCount = scores.filter(
|
||||
(s) => s / def.totalScore >= 0.6,
|
||||
).length;
|
||||
const passRate = Math.round((passCount / scores.length) * 1000) / 10;
|
||||
|
||||
const summary: ExamAnalyticsSummary = {
|
||||
expectedCount: 30,
|
||||
attendedCount: 30,
|
||||
avgScore: avg,
|
||||
maxScore: max,
|
||||
minScore: min,
|
||||
passRate,
|
||||
};
|
||||
|
||||
const distribution = generateDistribution(scores, def.totalScore);
|
||||
const questionAccuracy = generateQuestionAccuracy(examId);
|
||||
const knowledgeMastery = generateKpMastery(examId);
|
||||
const classComparison = generateClassComparison(def.classId, avg);
|
||||
const historyTrend = generateHistoryTrend();
|
||||
|
||||
const rankings: ExamStudentRank[] = studentScores.map((s, i) => ({
|
||||
rank: i + 1,
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName,
|
||||
studentNo: s.studentNo,
|
||||
totalScore: s.totalScore,
|
||||
level: scoreToLevel(s.totalScore, def.totalScore),
|
||||
}));
|
||||
|
||||
return {
|
||||
examId,
|
||||
examTitle: def.title,
|
||||
summary,
|
||||
distribution,
|
||||
questionAccuracy,
|
||||
knowledgeMastery,
|
||||
classComparison,
|
||||
historyTrend,
|
||||
rankings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成试卷结构(组卷页用)
|
||||
*
|
||||
* - 从题库取前 20 题作为候选分页
|
||||
* - 已选题目:前 4 题(默认排序)
|
||||
*/
|
||||
export function generateExamBuildStructure(
|
||||
examId: string,
|
||||
examDetail: ExamDetail | null,
|
||||
candidatePage: number,
|
||||
candidatePageSize: number,
|
||||
): ExamBuildStructure | null {
|
||||
// 默认 4 题已选,分数按 examDetail.totalScore 平均分摊
|
||||
const selectedCount = 4;
|
||||
const totalScore = examDetail?.totalScore ?? 100;
|
||||
const passScore = Math.round(totalScore * 0.6);
|
||||
const duration = examDetail?.duration ?? 120;
|
||||
const title = examDetail?.title ?? "未命名考试";
|
||||
|
||||
// 取前 4 道题作为已选
|
||||
const picked = EXAM_QUESTIONS.slice(0, selectedCount);
|
||||
const perScore = Math.floor(totalScore / selectedCount);
|
||||
const remainder = totalScore - perScore * selectedCount;
|
||||
const selected: ExamBuildNode[] = picked.map((q, i) => ({
|
||||
questionId: q.questionId,
|
||||
score: perScore + (i === 0 ? remainder : 0),
|
||||
sortOrder: i + 1,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
}));
|
||||
|
||||
// 题库候选分页(前 20 题)
|
||||
const start = (candidatePage - 1) * candidatePageSize;
|
||||
const candidatesItems = EXAM_QUESTIONS.slice(start, start + candidatePageSize);
|
||||
const candidates = {
|
||||
items: candidatesItems,
|
||||
total: EXAM_QUESTIONS.length,
|
||||
page: candidatePage,
|
||||
pageSize: candidatePageSize,
|
||||
};
|
||||
|
||||
return {
|
||||
examId,
|
||||
title,
|
||||
totalScore,
|
||||
passScore,
|
||||
duration,
|
||||
selected,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有 mock 考试定义(外部可消费) */
|
||||
export const MOCK_EXAM_ANALYTICS_DEFS = EXAM_DEFS;
|
||||
@@ -4,7 +4,7 @@
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { ExamItem } from "@/lib/graphql";
|
||||
import type { ExamItem, ExamDetail, ExamQuestion, CreateExamInput } from "@/lib/graphql";
|
||||
|
||||
export function generateMockExams(classId: string): ExamItem[] {
|
||||
const now = Date.now();
|
||||
@@ -41,3 +41,70 @@ export function generateMockExams(classId: string): ExamItem[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 examId 反查 classId(mock 数据 ID 规约:${classId}-exam-XXX)
|
||||
* 兜底:未匹配时返回 "mock-class"。
|
||||
*/
|
||||
function extractClassIdFromExamId(examId: string): string {
|
||||
const match = examId.match(/^(.+)-exam-\d+$/);
|
||||
return match?.[1] ?? "mock-class";
|
||||
}
|
||||
|
||||
/** 生成 mock 题目列表(固定 4 题) */
|
||||
function generateMockQuestions(examId: string): ExamQuestion[] {
|
||||
const defs: Array<{
|
||||
type: ExamQuestion["type"];
|
||||
title: string;
|
||||
score: number;
|
||||
}> = [
|
||||
{ type: "SINGLE_CHOICE", title: "单项选择:函数的定义域", score: 20 },
|
||||
{ type: "MULTIPLE_CHOICE", title: "多项选择:导数的几何意义", score: 20 },
|
||||
{ type: "SHORT_ANSWER", title: "简答:求解方程的实根", score: 30 },
|
||||
{ type: "ESSAY", title: "论述:函数与不等式的综合应用", score: 50 },
|
||||
];
|
||||
return defs.map((d, i) => ({
|
||||
id: `${examId}-q-${String(i + 1).padStart(3, "0")}`,
|
||||
examId,
|
||||
title: d.title,
|
||||
type: d.type,
|
||||
score: d.score,
|
||||
order: i + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按考试 ID 查询详情(含题目列表,MSW mock) */
|
||||
export function findMockExamDetail(examId: string): ExamDetail {
|
||||
const classId = extractClassIdFromExamId(examId);
|
||||
const all = generateMockExams(classId);
|
||||
const matched = all.find((e) => e.id === examId);
|
||||
const base = matched ?? {
|
||||
id: examId,
|
||||
classId,
|
||||
title: "考试详情(mock 兜底)",
|
||||
description: "Mock 数据兜底:未匹配到列表中的考试",
|
||||
examDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
duration: 120,
|
||||
totalScore: 150,
|
||||
status: "DRAFT" as const,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
questions: generateMockQuestions(examId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建新考试:基于 input 生成 ExamItem(含随机 ID) */
|
||||
export function createMockExam(input: CreateExamInput): ExamItem {
|
||||
const randomId = Math.random().toString(36).slice(2, 10);
|
||||
return {
|
||||
id: `${input.classId}-exam-${randomId}`,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
examDate: input.examDate,
|
||||
duration: input.duration,
|
||||
totalScore: input.totalScore,
|
||||
status: "DRAFT",
|
||||
};
|
||||
}
|
||||
|
||||
403
apps/teacher-portal/src/mocks/fixtures/grades-extended.ts
Normal file
403
apps/teacher-portal/src/mocks/fixtures/grades-extended.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Mock 扩展成绩数据(P7:成绩完整子模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 班级成绩统计(5 个班级 × 6 学科)
|
||||
* - 学生成绩趋势(6 个月,3 个学生)
|
||||
* - 学科对比、班级对比、知识点掌握度
|
||||
* - 报告卡数据(5 学生 × 4 学期 × 6 学科)
|
||||
* - 成绩录入列表生成器
|
||||
*/
|
||||
|
||||
import type {
|
||||
GradeEntryItem,
|
||||
GradeStats,
|
||||
GradeRanking,
|
||||
GradeAnalytics,
|
||||
GradeTrendPoint,
|
||||
GradeDistributionBand,
|
||||
SubjectComparisonItem,
|
||||
ClassComparisonItem,
|
||||
KnowledgeMasteryItem,
|
||||
ReportCard,
|
||||
ReportCardSubject,
|
||||
SaveGradeEntriesInput,
|
||||
SaveGradeEntriesResult,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
|
||||
/** 6 个学科 */
|
||||
export const SUBJECTS = [
|
||||
"语文",
|
||||
"数学",
|
||||
"英语",
|
||||
"物理",
|
||||
"化学",
|
||||
"生物",
|
||||
] as const;
|
||||
|
||||
/** 5 个班级(含 classes.ts 现有 3 个 + 扩展 2 个) */
|
||||
export const EXTENDED_CLASSES = [
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440010", name: "高三(1)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440011", name: "高三(2)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440012", name: "高三(3)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440013", name: "高三(4)班", gradeId: "grade-12" },
|
||||
{ id: "550e8400-e29b-41d4-a716-446655440014", name: "高三(5)班", gradeId: "grade-12" },
|
||||
] as const;
|
||||
|
||||
/** 6 个月标签(趋势 X 轴) */
|
||||
const MONTHS = ["1月", "2月", "3月", "4月", "5月", "6月"] as const;
|
||||
|
||||
/** 5 个知识点 */
|
||||
const KNOWLEDGE_POINTS = [
|
||||
"函数与导数",
|
||||
"数列",
|
||||
"立体几何",
|
||||
"解析几何",
|
||||
"概率统计",
|
||||
] as const;
|
||||
|
||||
/** 班级平均分基准(用于生成稳定 mock 数据) */
|
||||
const CLASS_AVG_BASE: Record<string, number> = {
|
||||
"550e8400-e29b-41d4-a716-446655440010": 78,
|
||||
"550e8400-e29b-41d4-a716-446655440011": 82,
|
||||
"550e8400-e29b-41d4-a716-446655440012": 75,
|
||||
"550e8400-e29b-41d4-a716-446655440013": 80,
|
||||
"550e8400-e29b-41d4-a716-446655440014": 85,
|
||||
};
|
||||
|
||||
/** 学科难度系数(用于在班级平均基础上调整学科均分) */
|
||||
const SUBJECT_DIFFICULTY: Record<string, number> = {
|
||||
语文: 0,
|
||||
数学: -5,
|
||||
英语: 2,
|
||||
物理: -8,
|
||||
化学: -3,
|
||||
生物: 3,
|
||||
};
|
||||
|
||||
/** 确定性伪随机(基于 seed,保证同输入同输出) */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/** 基于字符串生成 seed */
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 计算等级 */
|
||||
function scoreToLevel(score: number): GradeRanking["level"] {
|
||||
if (score >= 90) return "A";
|
||||
if (score >= 80) return "B";
|
||||
if (score >= 70) return "C";
|
||||
if (score >= 60) return "D";
|
||||
return "E";
|
||||
}
|
||||
|
||||
/** 计算中位数 */
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? (sorted[mid] ?? 0)
|
||||
: ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
||||
}
|
||||
|
||||
/** 计算标准差 */
|
||||
function stdDev(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const avg = values.reduce((acc, v) => acc + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((acc, v) => acc + (v - avg) ** 2, 0) / values.length;
|
||||
return Math.round(Math.sqrt(variance) * 10) / 10;
|
||||
}
|
||||
|
||||
/** 生成班级学生成绩排名(30 名学生) */
|
||||
function generateClassRankings(
|
||||
classId: string,
|
||||
subject: string,
|
||||
): Array<{ studentId: string; studentName: string; studentNo: string; totalScore: number }> {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
const adj = SUBJECT_DIFFICULTY[subject] ?? 0;
|
||||
const target = base + adj;
|
||||
const students: Array<{
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
totalScore: number;
|
||||
}> = [];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const seed = strToSeed(`${classId}-${subject}-${i}`);
|
||||
// 围绕 target 上下波动 ±15
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const score = Math.max(0, Math.min(100, target + delta));
|
||||
const studentNo = `2026${String(i).padStart(3, "0")}`;
|
||||
students.push({
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo,
|
||||
totalScore: score,
|
||||
});
|
||||
}
|
||||
students.sort((a, b) => b.totalScore - a.totalScore);
|
||||
return students;
|
||||
}
|
||||
|
||||
/** 按班级 + 学科生成成绩统计 */
|
||||
export function generateGradeStats(
|
||||
classId: string,
|
||||
subject: string,
|
||||
): GradeStats {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
const className = cls?.name ?? "未知班级";
|
||||
const ranked = generateClassRankings(classId, subject);
|
||||
const scores = ranked.map((s) => s.totalScore);
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0);
|
||||
const avg = Math.round((sum / scores.length) * 10) / 10;
|
||||
const passCount = scores.filter((s) => s >= 60).length;
|
||||
const rankings: GradeRanking[] = ranked.map((s, i) => ({
|
||||
rank: i + 1,
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName,
|
||||
studentNo: s.studentNo,
|
||||
totalScore: s.totalScore,
|
||||
level: scoreToLevel(s.totalScore),
|
||||
}));
|
||||
return {
|
||||
classId,
|
||||
className,
|
||||
subject,
|
||||
avg,
|
||||
median: Math.round(median(scores) * 10) / 10,
|
||||
max: Math.max(...scores),
|
||||
min: Math.min(...scores),
|
||||
passRate: Math.round((passCount / scores.length) * 1000) / 10,
|
||||
stdDev: stdDev(scores),
|
||||
totalCount: scores.length,
|
||||
rankings,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成成绩录入列表(按 examId + classId) */
|
||||
export function generateGradeEntries(
|
||||
examId: string,
|
||||
classId: string,
|
||||
): GradeEntryItem[] {
|
||||
const studentCount = 30;
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
const items: GradeEntryItem[] = [];
|
||||
for (let i = 1; i <= studentCount; i++) {
|
||||
const seed = strToSeed(`${examId}-${classId}-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 30);
|
||||
const score = Math.max(0, Math.min(100, base + delta));
|
||||
const feedback =
|
||||
score < 60 ? "需加强基础" : score >= 90 ? "表现优秀" : null;
|
||||
items.push({
|
||||
studentId: `stu-${classId.slice(-4)}-${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
score: i <= 20 ? score : null, // 后 10 名未录入
|
||||
feedback: i <= 20 ? feedback : null,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 批量保存成绩录入(mock:返回 savedCount) */
|
||||
export function saveGradeEntries(
|
||||
input: SaveGradeEntriesInput,
|
||||
): SaveGradeEntriesResult {
|
||||
return {
|
||||
examId: input.examId,
|
||||
classId: input.classId,
|
||||
savedCount: input.entries.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 生成趋势数据(6 个月,含 avg/max/min) */
|
||||
function generateTrend(classId: string): GradeTrendPoint[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return MONTHS.map((m, i) => {
|
||||
const seed = strToSeed(`${classId}-trend-${i}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 6);
|
||||
const avg = Math.max(0, Math.min(100, base + delta));
|
||||
return {
|
||||
month: m,
|
||||
avg,
|
||||
max: Math.min(100, avg + 12),
|
||||
min: Math.max(0, avg - 15),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成分数分布(5 段) */
|
||||
function generateDistribution(classId: string): GradeDistributionBand[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
const bands = [
|
||||
{ label: "0-59", min: 0, max: 59 },
|
||||
{ label: "60-69", min: 60, max: 69 },
|
||||
{ label: "70-79", min: 70, max: 79 },
|
||||
{ label: "80-89", min: 80, max: 89 },
|
||||
{ label: "90-100", min: 90, max: 100 },
|
||||
];
|
||||
// 班级越强,高分段越多
|
||||
const skew = (base - 75) / 10;
|
||||
return bands.map((b, i) => {
|
||||
const seed = strToSeed(`${classId}-dist-${i}`);
|
||||
const baseCount = [4, 6, 9, 8, 3][i] ?? 5;
|
||||
const adj = Math.round(skew * (i - 2) * 1.5);
|
||||
const count = Math.max(0, baseCount + adj + Math.round((seededRandom(seed) - 0.5) * 2));
|
||||
return { ...b, count };
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成学科对比 */
|
||||
function generateSubjectComparison(classId: string): SubjectComparisonItem[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return SUBJECTS.map((s) => {
|
||||
const adj = SUBJECT_DIFFICULTY[s] ?? 0;
|
||||
const seed = strToSeed(`${classId}-subj-${s}`);
|
||||
const noise = Math.round((seededRandom(seed) - 0.5) * 4);
|
||||
return { subject: s, avg: Math.max(0, Math.min(100, base + adj + noise)) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成班级对比(含显著性) */
|
||||
function generateClassComparison(classId: string): ClassComparisonItem[] {
|
||||
const targetAvg = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return EXTENDED_CLASSES.map((c) => {
|
||||
const clsAvg = CLASS_AVG_BASE[c.id] ?? 78;
|
||||
const diff = Math.abs(clsAvg - targetAvg);
|
||||
// 差距 ≥ 6 视为显著
|
||||
const significant = diff >= 6;
|
||||
return {
|
||||
classId: c.id,
|
||||
className: c.name,
|
||||
avg: clsAvg,
|
||||
significant,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 生成知识点掌握度 */
|
||||
function generateKnowledgeMastery(classId: string): KnowledgeMasteryItem[] {
|
||||
const base = CLASS_AVG_BASE[classId] ?? 78;
|
||||
return KNOWLEDGE_POINTS.map((kp) => {
|
||||
const seed = strToSeed(`${classId}-kp-${kp}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 20);
|
||||
return {
|
||||
knowledgePoint: kp,
|
||||
mastery: Math.max(30, Math.min(98, base + delta)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按班级生成成绩分析聚合 */
|
||||
export function generateGradeAnalytics(classId: string): GradeAnalytics {
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === classId);
|
||||
return {
|
||||
classId,
|
||||
className: cls?.name ?? "未知班级",
|
||||
trend: generateTrend(classId),
|
||||
distribution: generateDistribution(classId),
|
||||
subjectComparison: generateSubjectComparison(classId),
|
||||
classComparison: generateClassComparison(classId),
|
||||
knowledgeMastery: generateKnowledgeMastery(classId),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 报告卡数据(5 学生 × 4 学期 × 6 学科) ============
|
||||
|
||||
/** 5 名学生(默认从高三(1)班抽取) */
|
||||
const REPORT_STUDENTS = [
|
||||
{ id: "stu-0010-001", name: "张明", no: "2026001" },
|
||||
{ id: "stu-0010-002", name: "李华", no: "2026002" },
|
||||
{ id: "stu-0010-003", name: "王芳", no: "2026003" },
|
||||
{ id: "stu-0010-004", name: "赵磊", no: "2026004" },
|
||||
{ id: "stu-0010-005", name: "陈静", no: "2026005" },
|
||||
] as const;
|
||||
|
||||
/** 4 个学期 */
|
||||
const SEMESTERS = [
|
||||
{ year: "2025-2026", semester: "1" },
|
||||
{ year: "2025-2026", semester: "2" },
|
||||
{ year: "2026-2027", semester: "1" },
|
||||
{ year: "2026-2027", semester: "2" },
|
||||
] as const;
|
||||
|
||||
const TEACHER_NAMES = ["王老师", "刘老师", "陈老师", "杨老师", "周老师", "吴老师"];
|
||||
|
||||
const COMMENTS = [
|
||||
"学习态度端正,基础扎实,望继续保持。",
|
||||
"本学期进步明显,望戒骄戒躁,再接再厉。",
|
||||
"理科较强,语文需加强阅读积累。",
|
||||
"全面发展,各科均衡,表现优异。",
|
||||
"数理偏弱,建议加强逻辑训练。",
|
||||
];
|
||||
|
||||
/** 按学生 ID + 学年 + 学期生成报告卡 */
|
||||
export function generateReportCard(
|
||||
studentId: string,
|
||||
academicYear?: string,
|
||||
semester?: string,
|
||||
): ReportCard | null {
|
||||
const stu = REPORT_STUDENTS.find((s) => s.id === studentId);
|
||||
if (!stu) {
|
||||
// 未匹配:用兜底学生数据
|
||||
return null;
|
||||
}
|
||||
const term = SEMESTERS.find(
|
||||
(t) =>
|
||||
(academicYear ? t.year === academicYear : true) &&
|
||||
(semester ? t.semester === semester : true),
|
||||
) ?? SEMESTERS[0];
|
||||
if (!term) return null;
|
||||
|
||||
const baseSeed = strToSeed(`${stu.id}-${term.year}-${term.semester}`);
|
||||
const baseScore = 70 + Math.round(seededRandom(baseSeed) * 20);
|
||||
|
||||
const subjects: ReportCardSubject[] = SUBJECTS.map((s, i) => {
|
||||
const seed = strToSeed(`${stu.id}-${s}-${term.year}`);
|
||||
const delta = Math.round((seededRandom(seed) - 0.5) * 16);
|
||||
const score = Math.max(0, Math.min(100, baseScore + delta));
|
||||
return {
|
||||
subject: s,
|
||||
score,
|
||||
rank: 1 + ((i + Math.floor(seededRandom(seed) * 10)) % 30),
|
||||
teacherName: TEACHER_NAMES[i] ?? "未知",
|
||||
};
|
||||
});
|
||||
|
||||
const totalScore = subjects.reduce((acc, s) => acc + s.score, 0);
|
||||
const commentIdx = Math.floor(seededRandom(baseSeed) * COMMENTS.length);
|
||||
|
||||
return {
|
||||
studentId: stu.id,
|
||||
studentName: stu.name,
|
||||
studentNo: stu.no,
|
||||
className: "高三(1)班",
|
||||
academicYear: term.year,
|
||||
semester: term.semester,
|
||||
subjects,
|
||||
totalScore,
|
||||
classRank: 1 + (Math.floor(seededRandom(baseSeed + 1) * 30)),
|
||||
classSize: 32,
|
||||
gradeRank: 1 + (Math.floor(seededRandom(baseSeed + 2) * 150)),
|
||||
gradeSize: 150,
|
||||
teacherComment: COMMENTS[commentIdx] ?? COMMENTS[0] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** 报告卡可用学生列表 */
|
||||
export const REPORT_CARD_STUDENTS = REPORT_STUDENTS;
|
||||
|
||||
/** 报告卡可用学期列表 */
|
||||
export const REPORT_CARD_SEMESTERS = SEMESTERS;
|
||||
@@ -4,7 +4,7 @@
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { GradeItem } from "@/lib/graphql";
|
||||
import type { GradeItem, RecordGradeInput } from "@/lib/graphql";
|
||||
|
||||
export function generateMockGrades(examId: string): GradeItem[] {
|
||||
const grades: GradeItem[] = [];
|
||||
@@ -24,3 +24,18 @@ export function generateMockGrades(examId: string): GradeItem[] {
|
||||
}
|
||||
return grades;
|
||||
}
|
||||
|
||||
/** 录入成绩:基于 input 生成 GradeItem(含随机 ID + 学生名兜底) */
|
||||
export function recordMockGrade(input: RecordGradeInput): GradeItem {
|
||||
const randomId = Math.random().toString(36).slice(2, 10);
|
||||
const idx = Math.floor(Math.random() * 30) + 1;
|
||||
return {
|
||||
id: `grade-${randomId}`,
|
||||
studentId: input.studentId,
|
||||
studentName: `学生${String(idx).padStart(2, "0")}`,
|
||||
examId: input.examId ?? null,
|
||||
homeworkId: input.homeworkId ?? null,
|
||||
score: input.score,
|
||||
feedback: input.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
344
apps/teacher-portal/src/mocks/fixtures/homework-submissions.ts
Normal file
344
apps/teacher-portal/src/mocks/fixtures/homework-submissions.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Mock 作业提交数据(P7:作业批改链路)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 10 个作业 × 30 学生提交记录(统计维度)
|
||||
* - 单份提交含题目作答 + AI 评分建议
|
||||
* - 按 submissionId 查找详情(含上一份/下一份导航)
|
||||
*/
|
||||
|
||||
import type {
|
||||
HomeworkSubmissionStat,
|
||||
HomeworkSubmissionDetail,
|
||||
SubmissionAnswer,
|
||||
GradeSubmissionInput,
|
||||
GradeSubmissionResult,
|
||||
} from "@/lib/graphql-p7-grades";
|
||||
import { EXTENDED_CLASSES } from "./grades-extended";
|
||||
|
||||
/** 10 个作业定义(跨 5 个班级) */
|
||||
const HOMEWORK_DEFS: Array<{
|
||||
homeworkId: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
status: HomeworkSubmissionStat["status"];
|
||||
totalCount: number;
|
||||
submittedCount: number;
|
||||
gradedCount: number;
|
||||
avgScore: number | null;
|
||||
}> = [
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440010-hw-001",
|
||||
title: "函数练习题",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 32,
|
||||
submittedCount: 28,
|
||||
gradedCount: 20,
|
||||
avgScore: 82,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440010-hw-002",
|
||||
title: "导数应用题",
|
||||
classId: EXTENDED_CLASSES[0]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[0]?.name ?? "",
|
||||
status: "graded",
|
||||
totalCount: 32,
|
||||
submittedCount: 30,
|
||||
gradedCount: 30,
|
||||
avgScore: 78,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440011-hw-001",
|
||||
title: "数列综合练习",
|
||||
classId: EXTENDED_CLASSES[1]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[1]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 30,
|
||||
submittedCount: 25,
|
||||
gradedCount: 10,
|
||||
avgScore: 85,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440011-hw-002",
|
||||
title: "立体几何作业",
|
||||
classId: EXTENDED_CLASSES[1]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[1]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 30,
|
||||
submittedCount: 22,
|
||||
gradedCount: 5,
|
||||
avgScore: null,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440012-hw-001",
|
||||
title: "解析几何专题",
|
||||
classId: EXTENDED_CLASSES[2]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[2]?.name ?? "",
|
||||
status: "draft",
|
||||
totalCount: 28,
|
||||
submittedCount: 0,
|
||||
gradedCount: 0,
|
||||
avgScore: null,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440012-hw-002",
|
||||
title: "概率统计练习",
|
||||
classId: EXTENDED_CLASSES[2]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[2]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 28,
|
||||
submittedCount: 26,
|
||||
gradedCount: 26,
|
||||
avgScore: 80,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440013-hw-001",
|
||||
title: "三角函数复习",
|
||||
classId: EXTENDED_CLASSES[3]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[3]?.name ?? "",
|
||||
status: "graded",
|
||||
totalCount: 30,
|
||||
submittedCount: 30,
|
||||
gradedCount: 30,
|
||||
avgScore: 88,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440013-hw-002",
|
||||
title: "向量与复数",
|
||||
classId: EXTENDED_CLASSES[3]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[3]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 30,
|
||||
submittedCount: 18,
|
||||
gradedCount: 8,
|
||||
avgScore: 75,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440014-hw-001",
|
||||
title: "不等式练习",
|
||||
classId: EXTENDED_CLASSES[4]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[4]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 32,
|
||||
submittedCount: 30,
|
||||
gradedCount: 12,
|
||||
avgScore: 90,
|
||||
},
|
||||
{
|
||||
homeworkId: "550e8400-e29b-41d4-a716-446655440014-hw-002",
|
||||
title: "圆锥曲线综合",
|
||||
classId: EXTENDED_CLASSES[4]?.id ?? "",
|
||||
className: EXTENDED_CLASSES[4]?.name ?? "",
|
||||
status: "published",
|
||||
totalCount: 32,
|
||||
submittedCount: 27,
|
||||
gradedCount: 3,
|
||||
avgScore: null,
|
||||
},
|
||||
];
|
||||
|
||||
/** 按作业维度统计提交(可按 classId / status 过滤) */
|
||||
export function generateHomeworkSubmissionStats(
|
||||
classId?: string,
|
||||
status?: string,
|
||||
): HomeworkSubmissionStat[] {
|
||||
const now = Date.now();
|
||||
return HOMEWORK_DEFS.filter((h) => {
|
||||
if (classId && h.classId !== classId) return false;
|
||||
if (status && h.status !== status) return false;
|
||||
return true;
|
||||
}).map((h) => ({
|
||||
homeworkId: h.homeworkId,
|
||||
title: h.title,
|
||||
classId: h.classId,
|
||||
className: h.className,
|
||||
status: h.status,
|
||||
dueDate: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
totalCount: h.totalCount,
|
||||
submittedCount: h.submittedCount,
|
||||
gradedCount: h.gradedCount,
|
||||
avgScore: h.avgScore,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 4 道题目模板(用于生成作答详情) */
|
||||
const QUESTION_DEFS: Array<{
|
||||
title: string;
|
||||
type: SubmissionAnswer["questionType"];
|
||||
maxScore: number;
|
||||
correctAnswer: string;
|
||||
}> = [
|
||||
{
|
||||
title: "求函数 f(x) = x² - 2x + 1 在 [0, 2] 上的极值",
|
||||
type: "SHORT_ANSWER",
|
||||
maxScore: 25,
|
||||
correctAnswer: "极小值 f(1) = 0,极大值 f(0) = f(2) = 1",
|
||||
},
|
||||
{
|
||||
title: "选择题:函数 y = ln(x) 的导数是",
|
||||
type: "SINGLE_CHOICE",
|
||||
maxScore: 20,
|
||||
correctAnswer: "B. y = 1/x",
|
||||
},
|
||||
{
|
||||
title: "计算定积分 ∫₀¹ (2x + 1) dx",
|
||||
type: "SHORT_ANSWER",
|
||||
maxScore: 25,
|
||||
correctAnswer: "∫₀¹ (2x + 1) dx = [x² + x]₀¹ = 1 + 1 = 2",
|
||||
},
|
||||
{
|
||||
title: "论述:导数在实际问题中的应用",
|
||||
type: "ESSAY",
|
||||
maxScore: 30,
|
||||
correctAnswer: "(参考答案)导数可用于求极值、最值、变化率分析等……",
|
||||
},
|
||||
];
|
||||
|
||||
const STUDENT_ANSWERS = [
|
||||
"f(1) = 0 为极小值",
|
||||
"B",
|
||||
"积分结果为 2",
|
||||
"导数可应用于最优化问题、物理学中的瞬时速度等",
|
||||
];
|
||||
|
||||
const AI_SUGGESTIONS = [
|
||||
"答案正确,步骤清晰。建议补充极值点验证过程。",
|
||||
"选择题正确。",
|
||||
"计算正确,但缺少积分上下限代入步骤。",
|
||||
"论述较为简略,建议补充具体实例说明。",
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按作业 ID 生成所有提交详情列表
|
||||
*
|
||||
* 每份提交包含 4 道题作答 + AI 评分建议。
|
||||
* 同一作业下的提交列表共享 prevSubmissionId / nextSubmissionId 链。
|
||||
*/
|
||||
export function generateAssignmentSubmissions(
|
||||
homeworkId: string,
|
||||
): HomeworkSubmissionDetail[] {
|
||||
const def = HOMEWORK_DEFS.find((h) => h.homeworkId === homeworkId);
|
||||
const totalCount = def?.submittedCount ?? 5;
|
||||
const now = Date.now();
|
||||
const list: HomeworkSubmissionDetail[] = [];
|
||||
|
||||
for (let i = 1; i <= totalCount; i++) {
|
||||
const studentId = `stu-${homeworkId.slice(-4)}-${String(i).padStart(3, "0")}`;
|
||||
const submissionId = `${homeworkId}-sub-${String(i).padStart(3, "0")}`;
|
||||
const isGraded = def ? i <= def.gradedCount : false;
|
||||
|
||||
const answers: SubmissionAnswer[] = QUESTION_DEFS.map((q, qi) => {
|
||||
const seed = strToSeed(`${submissionId}-${qi}`);
|
||||
const isCorrect = seededRandom(seed) > 0.3;
|
||||
const score = isCorrect ? q.maxScore : Math.floor(q.maxScore * 0.5);
|
||||
return {
|
||||
questionId: `${submissionId}-q-${String(qi + 1).padStart(3, "0")}`,
|
||||
questionTitle: q.title,
|
||||
questionType: q.type,
|
||||
maxScore: q.maxScore,
|
||||
studentAnswer: STUDENT_ANSWERS[qi] ?? "(未作答)",
|
||||
correctAnswer: q.correctAnswer,
|
||||
score: isGraded ? score : null,
|
||||
feedback: isGraded ? AI_SUGGESTIONS[qi] ?? null : null,
|
||||
aiSuggestion: AI_SUGGESTIONS[qi] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const totalScore = isGraded
|
||||
? answers.reduce((acc, a) => acc + (a.score ?? 0), 0)
|
||||
: null;
|
||||
|
||||
list.push({
|
||||
submissionId,
|
||||
homeworkId,
|
||||
homeworkTitle: def?.title ?? "作业详情",
|
||||
classId: def?.classId ?? "",
|
||||
className: def?.className ?? "",
|
||||
studentId,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
studentNo: `2026${String(i).padStart(3, "0")}`,
|
||||
status: isGraded ? "GRADED" : "SUBMITTED",
|
||||
submittedAt: new Date(now - i * 60 * 60 * 1000).toISOString(),
|
||||
totalScore,
|
||||
answers,
|
||||
prevSubmissionId: null,
|
||||
nextSubmissionId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// 建立 prev / next 链
|
||||
list.forEach((item, idx) => {
|
||||
item.prevSubmissionId = idx > 0 ? (list[idx - 1]?.submissionId ?? null) : null;
|
||||
item.nextSubmissionId =
|
||||
idx < list.length - 1 ? (list[idx + 1]?.submissionId ?? null) : null;
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 按 submissionId 查找单份提交详情 */
|
||||
export function findSubmissionDetail(
|
||||
submissionId: string,
|
||||
): HomeworkSubmissionDetail | null {
|
||||
// submissionId 规约:${homeworkId}-sub-XXX
|
||||
const match = submissionId.match(/^(.+)-sub-\d+$/);
|
||||
if (!match) return null;
|
||||
const homeworkId = match[1] ?? "";
|
||||
const list = generateAssignmentSubmissions(homeworkId);
|
||||
return list.find((s) => s.submissionId === submissionId) ?? null;
|
||||
}
|
||||
|
||||
/** 批改单份提交(mock:返回 GRADED 状态 + totalScore) */
|
||||
export function gradeSubmission(
|
||||
input: GradeSubmissionInput,
|
||||
): GradeSubmissionResult {
|
||||
// 若提供 questionScores,汇总;否则用 input.score
|
||||
const totalScore = input.questionScores
|
||||
? input.questionScores.reduce((acc, q) => acc + q.score, 0)
|
||||
: input.score;
|
||||
return {
|
||||
submissionId: input.submissionId,
|
||||
status: "GRADED",
|
||||
totalScore,
|
||||
feedback: input.feedback ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** AI 批量评分建议(mock:对每份提交返回 AI 建议分数) */
|
||||
export function generateAiBatchGrading(
|
||||
homeworkId: string,
|
||||
): Array<{ submissionId: string; suggestedScore: number; suggestion: string }> {
|
||||
const list = generateAssignmentSubmissions(homeworkId);
|
||||
return list
|
||||
.filter((s) => s.status === "SUBMITTED")
|
||||
.map((s) => {
|
||||
const suggestedScore = s.answers.reduce(
|
||||
(acc, a) => acc + Math.floor(a.maxScore * 0.8),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
submissionId: s.submissionId,
|
||||
suggestedScore,
|
||||
suggestion: "AI 建议分数基于作答完整度评估,请教师复核。",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { HomeworkItem } from "@/lib/graphql";
|
||||
import type { HomeworkItem, HomeworkDetail, HomeworkSubmission, AssignHomeworkInput } from "@/lib/graphql";
|
||||
|
||||
export function generateMockHomework(classId: string): HomeworkItem[] {
|
||||
const now = Date.now();
|
||||
@@ -35,3 +35,84 @@ export function generateMockHomework(classId: string): HomeworkItem[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 homeworkId 反查 classId(mock 数据 ID 规约:${classId}-hw-XXX)
|
||||
* 兜底:未匹配时返回 "mock-class"。
|
||||
*/
|
||||
function extractClassIdFromHomeworkId(homeworkId: string): string {
|
||||
const match = homeworkId.match(/^(.+)-hw-\d+$/);
|
||||
return match?.[1] ?? "mock-class";
|
||||
}
|
||||
|
||||
/** 生成 mock 提交列表(固定 5 个学生提交,混合状态) */
|
||||
function generateMockSubmissions(homeworkId: string): HomeworkSubmission[] {
|
||||
const now = Date.now();
|
||||
const defs: Array<{
|
||||
status: HomeworkSubmission["status"];
|
||||
score: number | null;
|
||||
feedback: string | null;
|
||||
}> = [
|
||||
{
|
||||
status: "GRADED",
|
||||
score: Math.floor(Math.random() * 20) + 80,
|
||||
feedback: "思路清晰,过程完整",
|
||||
},
|
||||
{
|
||||
status: "GRADED",
|
||||
score: Math.floor(Math.random() * 20) + 80,
|
||||
feedback: "注意步骤书写规范",
|
||||
},
|
||||
{ status: "SUBMITTED", score: null, feedback: null },
|
||||
{ status: "SUBMITTED", score: null, feedback: null },
|
||||
{ status: "NOT_SUBMITTED", score: null, feedback: null },
|
||||
];
|
||||
return defs.map((d, i) => {
|
||||
const studentId = `${homeworkId.slice(0, -3)}${String(i + 1).padStart(3, "0")}`;
|
||||
return {
|
||||
id: `${homeworkId}-sub-${String(i + 1).padStart(3, "0")}`,
|
||||
homeworkId,
|
||||
studentId,
|
||||
studentName: `学生${String(i + 1).padStart(2, "0")}`,
|
||||
status: d.status,
|
||||
score: d.score,
|
||||
feedback: d.feedback,
|
||||
submittedAt:
|
||||
d.status !== "NOT_SUBMITTED"
|
||||
? new Date(now - i * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 按作业 ID 查询详情(含提交列表,MSW mock) */
|
||||
export function findMockHomeworkDetail(homeworkId: string): HomeworkDetail {
|
||||
const classId = extractClassIdFromHomeworkId(homeworkId);
|
||||
const all = generateMockHomework(classId);
|
||||
const matched = all.find((h) => h.id === homeworkId);
|
||||
const base = matched ?? {
|
||||
id: homeworkId,
|
||||
classId,
|
||||
title: "作业详情(mock 兜底)",
|
||||
description: "Mock 数据兜底:未匹配到列表中的作业",
|
||||
dueDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "SUBMITTED" as const,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
submissions: generateMockSubmissions(homeworkId),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建新作业:基于 input 生成 HomeworkItem(含随机 ID) */
|
||||
export function createMockHomework(input: AssignHomeworkInput): HomeworkItem {
|
||||
const randomId = Math.random().toString(36).slice(2, 10);
|
||||
return {
|
||||
id: `${input.classId}-hw-${randomId}`,
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
dueDate: input.dueDate,
|
||||
status: "NOT_SUBMITTED",
|
||||
};
|
||||
}
|
||||
|
||||
146
apps/teacher-portal/src/mocks/fixtures/knowledge-graph.ts
Normal file
146
apps/teacher-portal/src/mocks/fixtures/knowledge-graph.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Mock 知识图谱数据(core-edu / ai 域,P4 扩展)
|
||||
*
|
||||
* 包含数学、语文、英语三科知识点节点 + 前置依赖边。
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { KnowledgeGraphData } from "@/lib/graphql-p4";
|
||||
|
||||
/** 全量知识图谱 mock(按科目分布,13 节点 + 10 边) */
|
||||
export const mockKnowledgeGraph: KnowledgeGraphData = {
|
||||
nodes: [
|
||||
// ---- 数学(5 节点) ----
|
||||
{
|
||||
id: "math-01",
|
||||
name: "实数与运算",
|
||||
subject: "数学",
|
||||
description: "有理数、无理数、实数的基本运算",
|
||||
masteryLevel: 88,
|
||||
},
|
||||
{
|
||||
id: "math-02",
|
||||
name: "代数式",
|
||||
subject: "数学",
|
||||
description: "整式、分式、根式的化简与求值",
|
||||
masteryLevel: 76,
|
||||
},
|
||||
{
|
||||
id: "math-03",
|
||||
name: "方程与不等式",
|
||||
subject: "数学",
|
||||
description: "一元二次方程、不等式组求解",
|
||||
masteryLevel: 62,
|
||||
},
|
||||
{
|
||||
id: "math-04",
|
||||
name: "函数",
|
||||
subject: "数学",
|
||||
description: "一次函数、二次函数、指数对数函数",
|
||||
masteryLevel: 45,
|
||||
},
|
||||
{
|
||||
id: "math-05",
|
||||
name: "导数",
|
||||
subject: "数学",
|
||||
description: "导数的几何意义与运算应用",
|
||||
masteryLevel: 32,
|
||||
},
|
||||
// ---- 语文(4 节点) ----
|
||||
{
|
||||
id: "chinese-01",
|
||||
name: "文言文基础",
|
||||
subject: "语文",
|
||||
description: "常见实词虚词、句式辨析",
|
||||
masteryLevel: 81,
|
||||
},
|
||||
{
|
||||
id: "chinese-02",
|
||||
name: "古诗词鉴赏",
|
||||
subject: "语文",
|
||||
description: "意象、手法、情感分析",
|
||||
masteryLevel: 68,
|
||||
},
|
||||
{
|
||||
id: "chinese-03",
|
||||
name: "现代文阅读",
|
||||
subject: "语文",
|
||||
description: "论述类与文学类文本阅读策略",
|
||||
masteryLevel: 55,
|
||||
},
|
||||
{
|
||||
id: "chinese-04",
|
||||
name: "议论文写作",
|
||||
subject: "语文",
|
||||
description: "论点提炼、论据组织、论证结构",
|
||||
masteryLevel: 48,
|
||||
},
|
||||
// ---- 英语(4 节点) ----
|
||||
{
|
||||
id: "english-01",
|
||||
name: "词汇基础",
|
||||
subject: "英语",
|
||||
description: "核心 3500 词汇与词组搭配",
|
||||
masteryLevel: 90,
|
||||
},
|
||||
{
|
||||
id: "english-02",
|
||||
name: "语法结构",
|
||||
subject: "英语",
|
||||
description: "从句、非谓语动词、虚拟语气",
|
||||
masteryLevel: 72,
|
||||
},
|
||||
{
|
||||
id: "english-03",
|
||||
name: "阅读理解",
|
||||
subject: "英语",
|
||||
description: "主旨、细节、推理题型策略",
|
||||
masteryLevel: 58,
|
||||
},
|
||||
{
|
||||
id: "english-04",
|
||||
name: "书面表达",
|
||||
subject: "英语",
|
||||
description: "应用文与议论文写作模板",
|
||||
masteryLevel: 40,
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
// 数学依赖链:实数 → 代数式 → 方程 → 函数 → 导数
|
||||
{ from: "math-01", to: "math-02", type: "PREREQUISITE" },
|
||||
{ from: "math-02", to: "math-03", type: "PREREQUISITE" },
|
||||
{ from: "math-03", to: "math-04", type: "PREREQUISITE" },
|
||||
{ from: "math-04", to: "math-05", type: "PREREQUISITE" },
|
||||
// 语文依赖链:文言文 → 古诗词 → 现代文 → 写作
|
||||
{ from: "chinese-01", to: "chinese-02", type: "PREREQUISITE" },
|
||||
{ from: "chinese-02", to: "chinese-03", type: "RELATED" },
|
||||
{ from: "chinese-03", to: "chinese-04", type: "PREREQUISITE" },
|
||||
// 英语依赖链:词汇 → 语法 → 阅读 → 写作
|
||||
{ from: "english-01", to: "english-02", type: "PREREQUISITE" },
|
||||
{ from: "english-02", to: "english-03", type: "RELATED" },
|
||||
{ from: "english-03", to: "english-04", type: "PREREQUISITE" },
|
||||
],
|
||||
};
|
||||
|
||||
/** 可选科目列表 */
|
||||
export const KNOWLEDGE_SUBJECTS = ["全部", "数学", "语文", "英语"] as const;
|
||||
|
||||
/**
|
||||
* 按科目过滤知识图谱
|
||||
* subject 为空、"全部" 或 undefined 时返回全量
|
||||
*/
|
||||
export function filterKnowledgeGraph(
|
||||
subject: string | null | undefined,
|
||||
): KnowledgeGraphData {
|
||||
if (!subject || subject === "全部") {
|
||||
return mockKnowledgeGraph;
|
||||
}
|
||||
const nodes = mockKnowledgeGraph.nodes.filter(
|
||||
(n) => n.subject === subject,
|
||||
);
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const edges = mockKnowledgeGraph.edges.filter(
|
||||
(e) => nodeIds.has(e.from) && nodeIds.has(e.to),
|
||||
);
|
||||
return { nodes, edges };
|
||||
}
|
||||
458
apps/teacher-portal/src/mocks/fixtures/leave-requests.ts
Normal file
458
apps/teacher-portal/src/mocks/fixtures/leave-requests.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Mock 请假申请 + 调课申请数据(P7-admin:请假/调课子模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 15 条请假申请(含 pending/approved/rejected 三种状态)
|
||||
* - 10 条调课申请(含 ADJUST/SUBSTITUTE 两种类型)
|
||||
*/
|
||||
|
||||
import type {
|
||||
LeaveRequest,
|
||||
LeaveRequestsFilter,
|
||||
LeaveRequestsResult,
|
||||
LeaveStatus,
|
||||
LeaveType,
|
||||
ApproveLeaveRequestInput,
|
||||
ApproveLeaveRequestResult,
|
||||
SubmitLeaveRequestInput,
|
||||
SubmitLeaveRequestResult,
|
||||
ScheduleChange,
|
||||
ScheduleChangeType,
|
||||
ScheduleChangeStatus,
|
||||
ScheduleChangesResult,
|
||||
SubmitScheduleChangeInput,
|
||||
SubmitScheduleChangeResult,
|
||||
} from "@/lib/graphql-p7-admin";
|
||||
import type { DataScope } from "@/lib/graphql";
|
||||
import { EXTENDED_CLASSES, SUBJECTS } from "./grades-extended";
|
||||
|
||||
/** 教师 ID 列表 */
|
||||
const TEACHERS = [
|
||||
{ id: "u-001", name: "王老师", role: "teacher" },
|
||||
{ id: "u-002", name: "刘老师", role: "teacher" },
|
||||
{ id: "u-003", name: "陈老师", role: "teacher" },
|
||||
{ id: "u-004", name: "杨老师", role: "teacher" },
|
||||
{ id: "u-005", name: "周老师", role: "teacher" },
|
||||
{ id: "u-006", name: "吴老师", role: "head_teacher" },
|
||||
{ id: "u-007", name: "郑老师", role: "teacher" },
|
||||
{ id: "u-008", name: "孙老师", role: "teacher" },
|
||||
] as const;
|
||||
|
||||
/** 审批人 ID(教务主任) */
|
||||
const REVIEWER = { id: "u-admin-01", name: "教务主任" };
|
||||
|
||||
/** 请假原因模板 */
|
||||
const LEAVE_REASONS = [
|
||||
"感冒发烧,需在家休养",
|
||||
"家中突发急事",
|
||||
"年度体检",
|
||||
"陪同家人就医",
|
||||
"参加校外培训",
|
||||
"处理个人事务",
|
||||
"感冒咳嗽",
|
||||
"事务处理",
|
||||
"婚假",
|
||||
"事假",
|
||||
];
|
||||
|
||||
/** 生成日期 YYYY-MM-DD */
|
||||
function dateOffset(daysAgo: number): string {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 请假类型轮转 */
|
||||
const LEAVE_TYPES: LeaveType[] = [
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
"ANNUAL",
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
"OTHER",
|
||||
"SICK",
|
||||
"ANNUAL",
|
||||
"PERSONAL",
|
||||
"OTHER",
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
"ANNUAL",
|
||||
"SICK",
|
||||
"PERSONAL",
|
||||
];
|
||||
|
||||
/** 请假状态轮转 */
|
||||
const LEAVE_STATUSES: LeaveStatus[] = [
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
];
|
||||
|
||||
/** 生成 15 条请假申请 */
|
||||
function generateLeaveRequests(): LeaveRequest[] {
|
||||
const list: LeaveRequest[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const teacher = TEACHERS[i % TEACHERS.length];
|
||||
if (!teacher) continue;
|
||||
const type = LEAVE_TYPES[i] ?? "PERSONAL";
|
||||
const status = LEAVE_STATUSES[i] ?? "PENDING";
|
||||
const submittedAt = dateOffset(15 - i);
|
||||
const startDate = dateOffset(13 - i);
|
||||
const endDate = dateOffset(12 - i);
|
||||
const reason = LEAVE_REASONS[i % LEAVE_REASONS.length] ?? "个人事务";
|
||||
const reviewed =
|
||||
status === "PENDING"
|
||||
? null
|
||||
: {
|
||||
reviewedAt: dateOffset(14 - i),
|
||||
reviewerId: REVIEWER.id,
|
||||
reviewerName: REVIEWER.name,
|
||||
reviewComment:
|
||||
status === "APPROVED" ? "已批准" : "理由不充分,请补充材料",
|
||||
};
|
||||
list.push({
|
||||
id: `lr-2026-${String(i + 1).padStart(3, "0")}`,
|
||||
applicantId: teacher.id,
|
||||
applicantName: teacher.name,
|
||||
applicantRole: teacher.role,
|
||||
type,
|
||||
startDate,
|
||||
endDate,
|
||||
reason,
|
||||
status,
|
||||
submittedAt,
|
||||
reviewedAt: reviewed?.reviewedAt ?? null,
|
||||
reviewerId: reviewed?.reviewerId ?? null,
|
||||
reviewerName: reviewed?.reviewerName ?? null,
|
||||
reviewComment: reviewed?.reviewComment ?? null,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量请假申请缓存 */
|
||||
const ALL_LEAVE_REQUESTS: LeaveRequest[] = generateLeaveRequests();
|
||||
|
||||
/** 保存的请假覆盖(按 requestId 索引) */
|
||||
const LEAVE_OVERRIDES = new Map<string, LeaveRequest>();
|
||||
|
||||
/** 应用覆盖后的请假申请读取 */
|
||||
function resolveLeave(id: string): LeaveRequest | undefined {
|
||||
const override = LEAVE_OVERRIDES.get(id);
|
||||
if (override) return override;
|
||||
return ALL_LEAVE_REQUESTS.find((r) => r.id === id);
|
||||
}
|
||||
|
||||
/** 请假列表查询 */
|
||||
export function queryLeaveRequests(
|
||||
filter: LeaveRequestsFilter,
|
||||
): LeaveRequestsResult {
|
||||
const { dataScope, status, page, pageSize } = filter;
|
||||
const safePage = page ?? 1;
|
||||
const safePageSize = pageSize ?? 50;
|
||||
// 应用覆盖后的列表
|
||||
const merged = ALL_LEAVE_REQUESTS.map(
|
||||
(r) => LEAVE_OVERRIDES.get(r.id) ?? r,
|
||||
);
|
||||
const filtered = merged.filter((r) => {
|
||||
if (status && r.status !== status) return false;
|
||||
// dataScope 过滤:SELF 只看自己的(mock 里全部 teacher 角色,模拟当前教师 = u-001)
|
||||
if (dataScope === "SELF" && r.applicantId !== "u-001") return false;
|
||||
return true;
|
||||
});
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / safePageSize));
|
||||
const currentPage = Math.min(Math.max(1, safePage), totalPages);
|
||||
const start = (currentPage - 1) * safePageSize;
|
||||
const items = filtered.slice(start, start + safePageSize);
|
||||
return { items, total, page: currentPage, pageSize: safePageSize };
|
||||
}
|
||||
|
||||
/** 审批请假 */
|
||||
export function approveLeaveRequest(
|
||||
input: ApproveLeaveRequestInput,
|
||||
): ApproveLeaveRequestResult {
|
||||
const original = resolveLeave(input.requestId);
|
||||
const newStatus: LeaveStatus =
|
||||
input.action === "APPROVE" ? "APPROVED" : "REJECTED";
|
||||
const now = new Date().toISOString();
|
||||
const updated: LeaveRequest = {
|
||||
...(original ?? {
|
||||
id: input.requestId,
|
||||
applicantId: "unknown",
|
||||
applicantName: "未知",
|
||||
applicantRole: "teacher",
|
||||
type: "PERSONAL" as LeaveType,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
reason: "",
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
reviewComment: null,
|
||||
}),
|
||||
status: newStatus,
|
||||
reviewedAt: now,
|
||||
reviewerId: REVIEWER.id,
|
||||
reviewerName: REVIEWER.name,
|
||||
reviewComment: input.comment ?? null,
|
||||
};
|
||||
LEAVE_OVERRIDES.set(input.requestId, updated);
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
status: newStatus,
|
||||
reviewedAt: now,
|
||||
reviewerId: REVIEWER.id,
|
||||
reviewerName: REVIEWER.name,
|
||||
reviewComment: input.comment ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 提交请假申请 */
|
||||
export function submitLeaveRequest(
|
||||
input: SubmitLeaveRequestInput,
|
||||
): SubmitLeaveRequestResult {
|
||||
const now = new Date().toISOString();
|
||||
const seq = String(ALL_LEAVE_REQUESTS.length + LEAVE_OVERRIDES.size + 1).padStart(
|
||||
3,
|
||||
"0",
|
||||
);
|
||||
const requestId = `lr-2026-${seq}`;
|
||||
const newRequest: LeaveRequest = {
|
||||
id: requestId,
|
||||
applicantId: "u-001",
|
||||
applicantName: "王老师",
|
||||
applicantRole: "teacher",
|
||||
type: input.type,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
reason: input.reason,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
reviewComment: null,
|
||||
};
|
||||
LEAVE_OVERRIDES.set(requestId, newRequest);
|
||||
return {
|
||||
requestId,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 调课申请 ============
|
||||
|
||||
/** 调课原因模板 */
|
||||
const CHANGE_REASONS = [
|
||||
"出差需调课",
|
||||
"请同事代课",
|
||||
"教务临时调整",
|
||||
"学科组活动",
|
||||
"家中有事",
|
||||
"培训冲突",
|
||||
"会议冲突",
|
||||
"外出学习",
|
||||
"代同事上课",
|
||||
"校际教研",
|
||||
];
|
||||
|
||||
/** 调课类型轮转 */
|
||||
const CHANGE_TYPES: ScheduleChangeType[] = [
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
"SUBSTITUTE",
|
||||
"ADJUST",
|
||||
];
|
||||
|
||||
/** 调课状态轮转 */
|
||||
const CHANGE_STATUSES: ScheduleChangeStatus[] = [
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"PENDING",
|
||||
"APPROVED",
|
||||
"REJECTED",
|
||||
"PENDING",
|
||||
];
|
||||
|
||||
/** 生成 10 条调课申请 */
|
||||
function generateScheduleChanges(): ScheduleChange[] {
|
||||
const list: ScheduleChange[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const teacher = TEACHERS[i % TEACHERS.length];
|
||||
if (!teacher) continue;
|
||||
const cls = EXTENDED_CLASSES[i % EXTENDED_CLASSES.length];
|
||||
if (!cls) continue;
|
||||
const subject = SUBJECTS[i % SUBJECTS.length] ?? "数学";
|
||||
const type = CHANGE_TYPES[i] ?? "ADJUST";
|
||||
const status = CHANGE_STATUSES[i] ?? "PENDING";
|
||||
const submittedAt = dateOffset(10 - i);
|
||||
const originalDate = dateOffset(8 - i);
|
||||
const adjustedDate = dateOffset(6 - i);
|
||||
const reason = CHANGE_REASONS[i % CHANGE_REASONS.length] ?? "调课";
|
||||
const substitute =
|
||||
type === "SUBSTITUTE"
|
||||
? {
|
||||
substituteTeacherId: TEACHERS[(i + 1) % TEACHERS.length]?.id ?? null,
|
||||
substituteTeacherName:
|
||||
TEACHERS[(i + 1) % TEACHERS.length]?.name ?? null,
|
||||
}
|
||||
: { substituteTeacherId: null, substituteTeacherName: null };
|
||||
const reviewed =
|
||||
status === "PENDING"
|
||||
? null
|
||||
: {
|
||||
reviewedAt: dateOffset(9 - i),
|
||||
reviewComment:
|
||||
status === "APPROVED" ? "已批准" : "原因不充分,请重新提交",
|
||||
};
|
||||
list.push({
|
||||
id: `sc-2026-${String(i + 1).padStart(3, "0")}`,
|
||||
requesterId: teacher.id,
|
||||
requesterName: teacher.name,
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
originalDate,
|
||||
originalPeriod: (i % 8) + 1,
|
||||
originalSubject: subject,
|
||||
adjustedDate,
|
||||
adjustedPeriod: ((i + 2) % 8) + 1,
|
||||
adjustedSubject: subject,
|
||||
substituteTeacherId: substitute.substituteTeacherId,
|
||||
substituteTeacherName: substitute.substituteTeacherName,
|
||||
type,
|
||||
reason,
|
||||
status,
|
||||
submittedAt,
|
||||
reviewedAt: reviewed?.reviewedAt ?? null,
|
||||
reviewComment: reviewed?.reviewComment ?? null,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 全量调课申请缓存 */
|
||||
const ALL_SCHEDULE_CHANGES: ScheduleChange[] = generateScheduleChanges();
|
||||
|
||||
/** 保存的调课覆盖(按 requestId 索引) */
|
||||
const CHANGE_OVERRIDES = new Map<string, ScheduleChange>();
|
||||
|
||||
/** 调课列表查询 */
|
||||
export function queryScheduleChanges(
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
): ScheduleChangesResult {
|
||||
const merged = ALL_SCHEDULE_CHANGES.map(
|
||||
(r) => CHANGE_OVERRIDES.get(r.id) ?? r,
|
||||
);
|
||||
const total = merged.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(Math.max(1, page), totalPages);
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const items = merged.slice(start, start + pageSize);
|
||||
return { items, total, page: currentPage, pageSize };
|
||||
}
|
||||
|
||||
/** 提交调课申请 */
|
||||
export function submitScheduleChange(
|
||||
input: SubmitScheduleChangeInput,
|
||||
): SubmitScheduleChangeResult {
|
||||
const now = new Date().toISOString();
|
||||
const seq = String(
|
||||
ALL_SCHEDULE_CHANGES.length + CHANGE_OVERRIDES.size + 1,
|
||||
).padStart(3, "0");
|
||||
const requestId = `sc-2026-${seq}`;
|
||||
const cls = EXTENDED_CLASSES.find((c) => c.id === input.classId);
|
||||
const newRequest: ScheduleChange = {
|
||||
id: requestId,
|
||||
requesterId: "u-001",
|
||||
requesterName: "王老师",
|
||||
classId: input.classId,
|
||||
className: cls?.name ?? "未知班级",
|
||||
originalDate: input.originalDate,
|
||||
originalPeriod: input.originalPeriod,
|
||||
originalSubject: input.originalSubject,
|
||||
adjustedDate: input.adjustedDate,
|
||||
adjustedPeriod: input.adjustedPeriod,
|
||||
adjustedSubject: input.adjustedSubject,
|
||||
substituteTeacherId: input.substituteTeacherId ?? null,
|
||||
substituteTeacherName: null,
|
||||
type: input.type,
|
||||
reason: input.reason,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
};
|
||||
CHANGE_OVERRIDES.set(requestId, newRequest);
|
||||
return {
|
||||
requestId,
|
||||
status: "PENDING",
|
||||
submittedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前教师可访问的 dataScope 列表(用于 UI 下拉) */
|
||||
export const DATA_SCOPES: Array<{ value: DataScope; label: string }> = [
|
||||
{ value: "SELF", label: "我的申请" },
|
||||
{ value: "CLASS", label: "本班" },
|
||||
{ value: "GRADE", label: "本年级" },
|
||||
{ value: "SCHOOL", label: "全校" },
|
||||
];
|
||||
|
||||
/** 请假类型映射 */
|
||||
export const LEAVE_TYPE_LABEL: Record<LeaveType, string> = {
|
||||
SICK: "病假",
|
||||
PERSONAL: "事假",
|
||||
ANNUAL: "年假",
|
||||
OTHER: "其他",
|
||||
};
|
||||
|
||||
/** 请假状态映射 */
|
||||
export const LEAVE_STATUS_LABEL: Record<LeaveStatus, string> = {
|
||||
PENDING: "待审批",
|
||||
APPROVED: "已批准",
|
||||
REJECTED: "已拒绝",
|
||||
};
|
||||
|
||||
/** 调课类型映射 */
|
||||
export const SCHEDULE_CHANGE_TYPE_LABEL: Record<ScheduleChangeType, string> = {
|
||||
ADJUST: "调课",
|
||||
SUBSTITUTE: "代课",
|
||||
};
|
||||
|
||||
/** 调课状态映射 */
|
||||
export const SCHEDULE_CHANGE_STATUS_LABEL: Record<ScheduleChangeStatus, string> =
|
||||
{
|
||||
PENDING: "待审批",
|
||||
APPROVED: "已批准",
|
||||
REJECTED: "已拒绝",
|
||||
};
|
||||
157
apps/teacher-portal/src/mocks/fixtures/lesson-plan-heatmap.ts
Normal file
157
apps/teacher-portal/src/mocks/fixtures/lesson-plan-heatmap.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Mock 课标覆盖热力图数据(P7-advanced:热力图模块)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 5 教材 × 50 知识点 × 20 课案覆盖矩阵
|
||||
* - 按 textbookId 查询热力图
|
||||
*/
|
||||
|
||||
import type {
|
||||
LessonPlanHeatmap,
|
||||
HeatmapKnowledgePoint,
|
||||
HeatmapLessonPlan,
|
||||
HeatmapCell,
|
||||
} from "@/lib/graphql-p7-advanced";
|
||||
|
||||
/** 5 个教材定义 */
|
||||
const TEXTBOOK_DEFS: Array<{
|
||||
textbookId: string;
|
||||
textbookTitle: string;
|
||||
subject: string;
|
||||
}> = [
|
||||
{ textbookId: "tb-001", textbookTitle: "高中数学必修一", subject: "数学" },
|
||||
{ textbookId: "tb-002", textbookTitle: "高中数学必修二", subject: "数学" },
|
||||
{ textbookId: "tb-003", textbookTitle: "高中物理必修一", subject: "物理" },
|
||||
{ textbookId: "tb-004", textbookTitle: "高中化学必修一", subject: "化学" },
|
||||
{ textbookId: "tb-005", textbookTitle: "高中生物必修一", subject: "生物" },
|
||||
];
|
||||
|
||||
/** 章节名池 */
|
||||
const CHAPTER_NAMES = [
|
||||
"第一章 集合与函数",
|
||||
"第二章 基本初等函数",
|
||||
"第三章 函数的应用",
|
||||
"第四章 空间几何",
|
||||
"第五章 点线面位置关系",
|
||||
];
|
||||
|
||||
/** 课案标题池(20 个) */
|
||||
const LESSON_PLAN_TITLES = [
|
||||
"集合的概念",
|
||||
"集合间的基本关系",
|
||||
"函数及其表示",
|
||||
"函数的单调性",
|
||||
"函数的奇偶性",
|
||||
"指数函数",
|
||||
"对数函数",
|
||||
"幂函数",
|
||||
"函数与方程",
|
||||
"函数模型及应用",
|
||||
"空间几何体",
|
||||
"空间点线面位置关系",
|
||||
"直线方程",
|
||||
"圆的方程",
|
||||
"空间直角坐标系",
|
||||
"函数综合复习",
|
||||
"三角函数初步",
|
||||
"数列初步",
|
||||
"不等式",
|
||||
"导数初步",
|
||||
];
|
||||
|
||||
/** 知识点名池(50 个,简化生成) */
|
||||
const KP_NAME_POOL = [
|
||||
"集合概念", "集合运算", "并集交集", "补集", "全集",
|
||||
"函数定义", "定义域值域", "单调性", "奇偶性", "周期性",
|
||||
"指数运算", "指数函数图象", "对数运算", "对数函数图象", "换底公式",
|
||||
"幂函数", "幂函数性质", "函数零点", "二分法", "函数模型",
|
||||
"几何体结构", "三视图", "直观图", "表面积", "体积",
|
||||
"点线面关系", "线线平行", "线面平行", "面面平行", "线线垂直",
|
||||
"线面垂直", "面面垂直", "直线倾斜角", "直线斜率", "直线方程形式",
|
||||
"圆的定义", "圆的标准方程", "圆的一般方程", "直线与圆位置", "圆与圆位置",
|
||||
"空间坐标", "距离公式", "三角函数定义", "正弦定理", "余弦定理",
|
||||
"数列概念", "等差数列", "等比数列", "不等式性质", "导数定义",
|
||||
];
|
||||
|
||||
/** 确定性伪随机 */
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function strToSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h) + 1;
|
||||
}
|
||||
|
||||
/** 生成 50 个知识点 */
|
||||
function generateKnowledgePoints(textbookId: string): HeatmapKnowledgePoint[] {
|
||||
return KP_NAME_POOL.map((name, i) => ({
|
||||
kpId: `kp-${textbookId}-${String(i + 1).padStart(3, "0")}`,
|
||||
name,
|
||||
chapterName: CHAPTER_NAMES[i % CHAPTER_NAMES.length] ?? "未分类",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 生成 20 个课案 */
|
||||
function generateLessonPlans(textbookId: string): HeatmapLessonPlan[] {
|
||||
return LESSON_PLAN_TITLES.map((title, i) => ({
|
||||
planId: `plan-${textbookId}-${String(i + 1).padStart(3, "0")}`,
|
||||
title,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 生成覆盖矩阵单元格(50 × 20 = 1000 个) */
|
||||
function generateCells(
|
||||
textbookId: string,
|
||||
knowledgePoints: HeatmapKnowledgePoint[],
|
||||
lessonPlans: HeatmapLessonPlan[],
|
||||
): HeatmapCell[] {
|
||||
const cells: HeatmapCell[] = [];
|
||||
for (const kp of knowledgePoints) {
|
||||
for (const plan of lessonPlans) {
|
||||
const seed = strToSeed(`${kp.kpId}-${plan.planId}`);
|
||||
const r = seededRandom(seed);
|
||||
// 0-3 覆盖数:约 40% 为 0,25% 为 1,20% 为 2,15% 为 3
|
||||
let coverage: number;
|
||||
if (r > 0.85) coverage = 3;
|
||||
else if (r > 0.65) coverage = 2;
|
||||
else if (r > 0.4) coverage = 1;
|
||||
else coverage = 0;
|
||||
cells.push({ kpId: kp.kpId, planId: plan.planId, coverage });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 按 textbookId 查询热力图 */
|
||||
export function findLessonPlanHeatmap(
|
||||
textbookId: string,
|
||||
): LessonPlanHeatmap | null {
|
||||
const def = TEXTBOOK_DEFS.find((t) => t.textbookId === textbookId);
|
||||
if (!def) return null;
|
||||
const knowledgePoints = generateKnowledgePoints(textbookId);
|
||||
const lessonPlans = generateLessonPlans(textbookId);
|
||||
const cells = generateCells(textbookId, knowledgePoints, lessonPlans);
|
||||
return {
|
||||
textbookId: def.textbookId,
|
||||
textbookTitle: def.textbookTitle,
|
||||
knowledgePoints,
|
||||
lessonPlans,
|
||||
cells,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取所有教材列表(供页面 select 使用) */
|
||||
export function listHeatmapTextbooks(): Array<{
|
||||
textbookId: string;
|
||||
textbookTitle: string;
|
||||
subject: string;
|
||||
}> {
|
||||
return TEXTBOOK_DEFS;
|
||||
}
|
||||
448
apps/teacher-portal/src/mocks/fixtures/lesson-plans.ts
Normal file
448
apps/teacher-portal/src/mocks/fixtures/lesson-plans.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Mock 课案数据(lesson-preparation 域,P7-insights 扩展)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 10 个我自己的课案(语文/数学/英语,覆盖小学/初中/高中)
|
||||
* - 20 个校内库课案(他人发布)
|
||||
* - 5 课案模板
|
||||
* - 课案日历 / 热力图
|
||||
* - CRUD:创建 / 更新 / Fork
|
||||
*/
|
||||
|
||||
import type {
|
||||
LessonPlanItem,
|
||||
LessonPlanDetail,
|
||||
LessonPlanLibraryItem,
|
||||
LessonPlanCalendarMonth,
|
||||
LessonPlanCalendarDay,
|
||||
LessonPlanHeatmap,
|
||||
LessonPlanTemplate,
|
||||
CreateLessonPlanInput,
|
||||
UpdateLessonPlanInput,
|
||||
AIGenerateLessonPlanInput,
|
||||
AIGenerateLessonPlanResult,
|
||||
} from "@/lib/graphql-p7-insights";
|
||||
|
||||
/** 学科 */
|
||||
const SUBJECTS = ["语文", "数学", "英语"] as const;
|
||||
|
||||
/** 年级 */
|
||||
const GRADES = ["小学三年级", "小学六年级", "初中一年级", "高中三年级"] as const;
|
||||
|
||||
/** 模板元信息 */
|
||||
export const LESSON_PLAN_TEMPLATES: {
|
||||
id: LessonPlanTemplate;
|
||||
name: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ id: "REGULAR", name: "常规教案", description: "标准课时教案:教学目标→重难点→教学过程→作业布置" },
|
||||
{ id: "UNIT", name: "单元教案", description: "单元整体设计:单元目标→课时分配→阶段评估" },
|
||||
{ id: "REVIEW", name: "复习教案", description: "复习课教案:知识梳理→典型例题→巩固练习" },
|
||||
{ id: "ACTIVITY", name: "活动教案", description: "活动课教案:情境导入→合作探究→成果展示" },
|
||||
{ id: "PERSONALIZED", name: "个性化教案", description: "分层教学:基础目标→进阶目标→差异化辅导" },
|
||||
];
|
||||
|
||||
/** 课案内容模板(按学科生成 markdown) */
|
||||
function planContent(
|
||||
title: string,
|
||||
subject: string,
|
||||
grade: string,
|
||||
chapter: string,
|
||||
): string {
|
||||
return `# ${title}
|
||||
|
||||
## 基本信息
|
||||
- 学科:${subject}
|
||||
- 年级:${grade}
|
||||
- 章节:${chapter}
|
||||
- 课时:1 课时(40 分钟)
|
||||
|
||||
## 教学目标
|
||||
1. 知识与技能:掌握 ${chapter} 的核心概念与基本运算
|
||||
2. 过程与方法:通过情境导入与例题分析,培养逻辑推理能力
|
||||
3. 情感态度价值观:激发学习兴趣,建立学科自信
|
||||
|
||||
## 教学重难点
|
||||
- 重点:${chapter} 的定义与基本性质
|
||||
- 难点:综合应用与变式训练
|
||||
|
||||
## 教学过程
|
||||
### 一、情境导入(5 分钟)
|
||||
通过生活实例引出 ${chapter} 的学习必要性。
|
||||
|
||||
### 二、新知讲解(15 分钟)
|
||||
1. 概念引入:给出 ${chapter} 的规范定义
|
||||
2. 性质剖析:列表对比关键性质
|
||||
3. 典型例题:精选 3 道分层例题
|
||||
|
||||
### 三、巩固练习(12 分钟)
|
||||
- 基础题:5 道
|
||||
- 提升题:3 道
|
||||
- 拓展题:1 道
|
||||
|
||||
### 四、课堂小结(5 分钟)
|
||||
学生口述本节收获,教师补充完善。
|
||||
|
||||
### 五、作业布置(3 分钟)
|
||||
- 必做:教材 P12 第 1-5 题
|
||||
- 选做:探究题 1 道
|
||||
|
||||
## 板书设计
|
||||
左侧:${chapter} 定义 | 中间:例题区 | 右侧:学生板演区
|
||||
|
||||
## 教学反思
|
||||
课后填写:达成度 / 改进点 / 后续衔接`;
|
||||
}
|
||||
|
||||
/** 10 个我的课案(基础定义) */
|
||||
const MY_PLANS: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
chapter: string;
|
||||
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
textbookId: string | null;
|
||||
textbookTitle: string | null;
|
||||
classIds: string[];
|
||||
updatedAt: string;
|
||||
}> = [
|
||||
{
|
||||
id: "lp-001",
|
||||
title: "《岳阳楼记》第一课时",
|
||||
subject: "语文",
|
||||
grade: "初中一年级",
|
||||
chapter: "第二单元·岳阳楼记",
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-001",
|
||||
textbookTitle: "小学语文三年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
|
||||
updatedAt: "2026-07-01T09:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-002",
|
||||
title: "分数的初步认识",
|
||||
subject: "数学",
|
||||
grade: "小学三年级",
|
||||
chapter: "第七单元·分数的初步认识",
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-002",
|
||||
textbookTitle: "小学数学六年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
|
||||
updatedAt: "2026-07-02T14:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-003",
|
||||
title: "My Family 单元导入",
|
||||
subject: "英语",
|
||||
grade: "初中一年级",
|
||||
chapter: "Unit 1·My Family",
|
||||
status: "DRAFT",
|
||||
textbookId: "tb-003",
|
||||
textbookTitle: "初中英语七年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440011"],
|
||||
updatedAt: "2026-07-03T10:15:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-004",
|
||||
title: "函数的单调性与极值",
|
||||
subject: "数学",
|
||||
grade: "高中三年级",
|
||||
chapter: "选修 2-2·导数应用",
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-05T08:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-005",
|
||||
title: "现代文阅读·论述类文本",
|
||||
subject: "语文",
|
||||
grade: "高中三年级",
|
||||
chapter: "第一轮复习·现代文阅读",
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-06T16:20:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-006",
|
||||
title: "Reading Strategies for Main Idea",
|
||||
subject: "英语",
|
||||
grade: "高中三年级",
|
||||
chapter: "高三复习·阅读理解",
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-07T11:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-007",
|
||||
title: "古诗词鉴赏·意象分析",
|
||||
subject: "语文",
|
||||
grade: "初中一年级",
|
||||
chapter: "第三单元·古诗词",
|
||||
status: "ARCHIVED",
|
||||
textbookId: "tb-001",
|
||||
textbookTitle: "小学语文三年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440010"],
|
||||
updatedAt: "2026-06-28T09:30:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-008",
|
||||
title: "方程与不等式复习课",
|
||||
subject: "数学",
|
||||
grade: "小学六年级",
|
||||
chapter: "总复习·方程与不等式",
|
||||
status: "PUBLISHED",
|
||||
textbookId: "tb-002",
|
||||
textbookTitle: "小学数学六年级上册",
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440013"],
|
||||
updatedAt: "2026-07-08T13:45:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-009",
|
||||
title: "书面表达·议论文结构",
|
||||
subject: "英语",
|
||||
grade: "高中三年级",
|
||||
chapter: "高三复习·书面表达",
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440012"],
|
||||
updatedAt: "2026-07-09T15:10:00Z",
|
||||
},
|
||||
{
|
||||
id: "lp-010",
|
||||
title: "实数与运算·总复习",
|
||||
subject: "数学",
|
||||
grade: "初中一年级",
|
||||
chapter: "第一章·实数",
|
||||
status: "PUBLISHED",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: ["550e8400-e29b-41d4-a716-446655440011"],
|
||||
updatedAt: "2026-07-10T10:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
/** 转 List item */
|
||||
function toItem(p: typeof MY_PLANS[number]): LessonPlanItem {
|
||||
return { ...p };
|
||||
}
|
||||
|
||||
/** 我的课案列表 */
|
||||
export const mockLessonPlans: LessonPlanItem[] = MY_PLANS.map(toItem);
|
||||
|
||||
/** 按 ID 查询课案详情 */
|
||||
export function findLessonPlanDetail(planId: string): LessonPlanDetail | null {
|
||||
const found = MY_PLANS.find((p) => p.id === planId);
|
||||
if (!found) return null;
|
||||
return {
|
||||
...found,
|
||||
content: planContent(found.title, found.subject, found.grade, found.chapter),
|
||||
aiHistory: [
|
||||
{
|
||||
id: `ai-${found.id}-1`,
|
||||
prompt: `为「${found.title}」生成教学过程`,
|
||||
generatedAt: found.updatedAt,
|
||||
model: "edu-llm-p7-v1",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-06-20T08:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
/** 按学科筛选我的课案 */
|
||||
export function filterLessonPlans(subject?: string | null): LessonPlanItem[] {
|
||||
if (!subject || subject === "全部") return mockLessonPlans;
|
||||
return mockLessonPlans.filter((p) => p.subject === subject);
|
||||
}
|
||||
|
||||
/** 创建课案 */
|
||||
export function createMockLessonPlan(
|
||||
input: CreateLessonPlanInput,
|
||||
): LessonPlanItem {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `lp-${String(Date.now()).slice(-6)}`,
|
||||
title: input.title,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
chapter: input.chapter,
|
||||
status: "DRAFT",
|
||||
textbookId: input.textbookId ?? null,
|
||||
textbookTitle: null,
|
||||
classIds: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新课案(mock:返回更新后的最小信息) */
|
||||
export function updateMockLessonPlan(
|
||||
input: UpdateLessonPlanInput,
|
||||
): LessonPlanItem | null {
|
||||
const found = MY_PLANS.find((p) => p.id === input.planId);
|
||||
if (!found) return null;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...toItem(found),
|
||||
title: input.title ?? found.title,
|
||||
status: input.status ?? found.status,
|
||||
textbookId: input.textbookId !== undefined ? input.textbookId : found.textbookId,
|
||||
chapter: input.chapter ?? found.chapter,
|
||||
classIds: input.classIds ?? found.classIds,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** AI 生成课案(mock:返回一段 markdown) */
|
||||
export function generateMockLessonPlan(
|
||||
input: AIGenerateLessonPlanInput,
|
||||
): AIGenerateLessonPlanResult {
|
||||
const now = new Date().toISOString();
|
||||
const seq = String(Date.now()).slice(-6);
|
||||
const content = `# AI 生成教案:${input.chapter}
|
||||
|
||||
## 学科 ${input.subject} · 年级 ${input.grade}
|
||||
|
||||
## 教学目标
|
||||
1. 掌握 ${input.chapter} 的核心概念
|
||||
2. 能够运用相关知识解决典型问题
|
||||
3. 培养学科思维与迁移能力
|
||||
|
||||
## 教学过程
|
||||
### 一、导入(5 分钟)
|
||||
${input.prompt}
|
||||
|
||||
### 二、新知讲解(15 分钟)
|
||||
- 概念定义与示例
|
||||
- 关键性质剖析
|
||||
- 典型例题分层讲解
|
||||
|
||||
### 三、巩固练习(12 分钟)
|
||||
- 基础题 / 提升题 / 拓展题
|
||||
|
||||
### 四、课堂小结(5 分钟)
|
||||
### 五、作业布置(3 分钟)`;
|
||||
return {
|
||||
id: `ai-lp-${seq}`,
|
||||
content,
|
||||
summary: `围绕「${input.chapter}」生成的 ${input.subject} 教案,含教学目标、教学过程、作业布置。`,
|
||||
model: "edu-llm-p7-v1",
|
||||
generatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 20 个校内库课案(他人发布) */
|
||||
const AUTHORS = [
|
||||
"王老师", "李老师", "张老师", "陈老师", "刘老师",
|
||||
"赵老师", "黄老师", "周老师", "吴老师", "徐老师",
|
||||
];
|
||||
|
||||
function buildLibraryItem(idx: number): LessonPlanLibraryItem {
|
||||
const subject = SUBJECTS[idx % SUBJECTS.length] ?? "数学";
|
||||
const grade = GRADES[idx % GRADES.length] ?? "初中一年级";
|
||||
const author = AUTHORS[idx % AUTHORS.length] ?? "佚名";
|
||||
const titles: Record<string, string[]> = {
|
||||
语文: ["《荷塘月色》赏析", "《背影》情感分析", "议论文写作入门", "古诗词意象专题"],
|
||||
数学: ["二次函数图像", "概率统计初步", "立体几何证明", "导数综合应用"],
|
||||
英语: ["Reading for Theme", "Writing a Narrative", "Grammar in Context", "Listening Strategies"],
|
||||
};
|
||||
const subjectTitles = titles[subject] ?? ["通用教案"];
|
||||
const title = subjectTitles[idx % subjectTitles.length] ?? `${subject} 教案 ${idx}`;
|
||||
return {
|
||||
id: `lp-lib-${String(idx).padStart(3, "0")}`,
|
||||
title,
|
||||
author,
|
||||
subject,
|
||||
grade,
|
||||
chapter: `第 ${idx + 1} 章`,
|
||||
rating: Math.round((3.5 + (idx % 1.5)) * 10) / 10,
|
||||
forkCount: Math.floor(((idx * 7) % 50) + 5),
|
||||
updatedAt: `2026-06-${String(28 - (idx % 27)).padStart(2, "0")}T09:00:00Z`,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockLessonPlanLibrary: LessonPlanLibraryItem[] = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => buildLibraryItem(i),
|
||||
);
|
||||
|
||||
/** 按学科 + 年级筛选校内库 */
|
||||
export function filterLessonPlanLibrary(
|
||||
subject?: string | null,
|
||||
grade?: string | null,
|
||||
): LessonPlanLibraryItem[] {
|
||||
return mockLessonPlanLibrary.filter((p) => {
|
||||
if (subject && subject !== "全部" && p.subject !== subject) return false;
|
||||
if (grade && grade !== "全部" && p.grade !== grade) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fork 课案(mock:复制为新课案) */
|
||||
export function forkMockLessonPlan(planId: string): LessonPlanItem | null {
|
||||
const lib = mockLessonPlanLibrary.find((p) => p.id === planId);
|
||||
if (!lib) return null;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `lp-${String(Date.now()).slice(-6)}`,
|
||||
title: `${lib.title}(Fork)`,
|
||||
subject: lib.subject,
|
||||
grade: lib.grade,
|
||||
chapter: lib.chapter,
|
||||
status: "DRAFT",
|
||||
textbookId: null,
|
||||
textbookTitle: null,
|
||||
classIds: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/** 课案日历视图(按月份) */
|
||||
export function getLessonPlanCalendar(
|
||||
year: number,
|
||||
month: number,
|
||||
): LessonPlanCalendarMonth {
|
||||
// 当月每天有 0-3 个课案(确定性 mock)
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const days: LessonPlanCalendarDay[] = [];
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const seed = (year * 100 + month) * 31 + d;
|
||||
const planCount = seed % 4 === 0 ? 2 : seed % 3 === 0 ? 1 : 0;
|
||||
if (planCount === 0) {
|
||||
days.push({ date: `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`, planCount: 0, planTitles: [] });
|
||||
} else {
|
||||
const titles: string[] = [];
|
||||
for (let i = 0; i < planCount; i++) {
|
||||
titles.push(`课案 ${d}-${i + 1}`);
|
||||
}
|
||||
days.push({
|
||||
date: `${year}-${String(month).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
|
||||
planCount,
|
||||
planTitles: titles,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { year, month, days };
|
||||
}
|
||||
|
||||
/** 课标覆盖热力图(教材知识点 × 课案覆盖矩阵) */
|
||||
export function getLessonPlanHeatmap(): LessonPlanHeatmap {
|
||||
const rows = ["函数", "方程", "几何", "概率", "统计", "数列", "导数", "实数"];
|
||||
const cols = ["lp-001", "lp-002", "lp-004", "lp-008", "lp-010"];
|
||||
const cells = rows.map((r) =>
|
||||
cols.map((c) => ({
|
||||
textbookKp: r,
|
||||
planIds: (r.charCodeAt(0) + c.charCodeAt(3)) % 3 === 0 ? [c] : [],
|
||||
covered: (r.charCodeAt(0) + c.charCodeAt(3)) % 3 === 0,
|
||||
})),
|
||||
).flat();
|
||||
return { rows, cols, cells };
|
||||
}
|
||||
77
apps/teacher-portal/src/mocks/fixtures/notifications.ts
Normal file
77
apps/teacher-portal/src/mocks/fixtures/notifications.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Mock 通知数据(push-gateway + msg 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送
|
||||
*/
|
||||
|
||||
import type { NotificationItem } from "@/lib/graphql-p5";
|
||||
|
||||
const now = Date.now();
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
export const mockNotifications: NotificationItem[] = [
|
||||
{
|
||||
id: "ntf-001",
|
||||
type: "HOMEWORK_SUBMITTED",
|
||||
title: "新作业提交",
|
||||
message: "高三(1)班 李明 提交了《函数与导数练习》",
|
||||
read: false,
|
||||
createdAt: new Date(now - 5 * minute).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-002",
|
||||
type: "EXAM_GRADED",
|
||||
title: "考试已评分",
|
||||
message: "高三(2)班《2026 春季期中考试》成绩已发布,平均分 112.5",
|
||||
read: false,
|
||||
createdAt: new Date(now - 35 * minute).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-003",
|
||||
type: "BROADCAST",
|
||||
title: "学校公告",
|
||||
message: "本周五下午 14:00 召开教研组会议,请准时参加",
|
||||
read: false,
|
||||
createdAt: new Date(now - 2 * hour).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-004",
|
||||
type: "HOMEWORK_SUBMITTED",
|
||||
title: "新作业提交",
|
||||
message: "高三(1)班 王芳 提交了《三角函数复习》",
|
||||
read: true,
|
||||
createdAt: new Date(now - 5 * hour).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-005",
|
||||
type: "ROLE_CHANGED",
|
||||
title: "角色变更",
|
||||
message: "您的角色已更新为:年级组长(GRADE 数据范围)",
|
||||
read: true,
|
||||
createdAt: new Date(now - 1 * day).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-006",
|
||||
type: "EXAM_GRADED",
|
||||
title: "考试已评分",
|
||||
message: "高三(3)班《月考 - 数学综合》成绩已发布,平均分 98.2",
|
||||
read: false,
|
||||
createdAt: new Date(now - 1 * day - 3 * hour).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "ntf-007",
|
||||
type: "BROADCAST",
|
||||
title: "系统通知",
|
||||
message: "成绩录入截止日期为本周日 23:59,请及时完成",
|
||||
read: true,
|
||||
createdAt: new Date(now - 2 * day).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
/** 按 id 查找 mock 通知 */
|
||||
export function findNotification(id: string): NotificationItem | null {
|
||||
return mockNotifications.find((n) => n.id === id) ?? null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user