feat(student-portal): 实现参考项目差距功能 - 手写板/图片上传/教师批注/课表/设置/作业分析/加入班级/延期申请/多维筛选

This commit is contained in:
SpecialX
2026-07-13 12:54:43 +08:00
parent d6cc70e02e
commit fad6e43b47
23 changed files with 4110 additions and 105 deletions

View File

@@ -3,22 +3,41 @@
/** /**
* Dashboard — 学生仪表盘ai14 * Dashboard — 学生仪表盘ai14
* *
* 数据源useStudentDashboard GraphQL hookstudent-bff 聚合) * 数据源useStudentDashboard + useMySchedule + useStudentGrowth GraphQL hook
* 视图模型avgScore / classRank / attendanceRate / learningStreakDays / * 视图模型avgScore / classRank / attendanceRate / learningStreakDays /
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3) * upcomingHomework(3) / upcomingExams(2) / recentGrades(3) /
* todaySchedule / growthCurve(myScore vs classAverage)
* *
* 设计依据01-understanding.md §7dashboard 视口) * 设计依据01-understanding.md §7dashboard 视口)
* 02-architecture-design.md §14.4dashboard SSR + CSR * 02-architecture-design.md §14.4dashboard SSR + CSR
*/ */
import { useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useStudentDashboard, useCurrentUser } from "@/lib/graphql/hooks"; import {
useStudentDashboard,
useCurrentUser,
useMySchedule,
useStudentGrowth,
} from "@/lib/graphql/hooks";
import type { import type {
StudentDashboard, StudentDashboard,
DashboardHomework, DashboardHomework,
DashboardExam, DashboardExam,
DashboardGrade, DashboardGrade,
ScheduleItem,
GrowthDataPoint,
} from "@/lib/graphql/types"; } from "@/lib/graphql/types";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// 日期格式化(中文短格式) // 日期格式化(中文短格式)
const dateFormatter = new Intl.DateTimeFormat("zh-CN", { const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
@@ -39,6 +58,17 @@ function formatPercent(rate: number): string {
export default function DashboardPage(): JSX.Element { export default function DashboardPage(): JSX.Element {
const { data, fetching, error } = useStudentDashboard(); const { data, fetching, error } = useStudentDashboard();
const { data: currentUser } = useCurrentUser(); const { data: currentUser } = useCurrentUser();
const { data: scheduleData } = useMySchedule();
const { data: growthData } = useStudentGrowth();
// 筛选今日课表
const todaySchedule = useMemo(() => {
if (!scheduleData) return [];
const today = new Date().getDay();
return scheduleData
.filter((s) => s.dayOfWeek === today)
.sort((a, b) => a.startTime.localeCompare(b.startTime));
}, [scheduleData]);
return ( return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto"> <div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
@@ -58,6 +88,10 @@ export default function DashboardPage(): JSX.Element {
<UpcomingHomeworkCard items={data.upcomingHomework ?? []} /> <UpcomingHomeworkCard items={data.upcomingHomework ?? []} />
<UpcomingExamsCard items={data.upcomingExams ?? []} /> <UpcomingExamsCard items={data.upcomingExams ?? []} />
</div> </div>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<TodayScheduleCard items={todaySchedule} />
<GrowthCurveCard items={growthData ?? []} />
</div>
<div className="mt-6"> <div className="mt-6">
<RecentGradesCard items={data.recentGrades ?? []} /> <RecentGradesCard items={data.recentGrades ?? []} />
</div> </div>
@@ -353,6 +387,171 @@ function RecentGradesCard({ items }: { items: DashboardGrade[] }): JSX.Element {
); );
} }
// === 今日课表 ===
function TodayScheduleCard({
items,
}: {
items: ScheduleItem[];
}): JSX.Element {
return (
<section aria-label="今日课表" className="card-paper p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
</h2>
<Link
href="/schedule"
className="text-xs hover:underline"
style={{ color: "var(--color-accent)" }}
>
</Link>
</div>
<div className="rule-thin mb-4" />
{items.length === 0 ? (
<p
className="text-sm py-6 text-center"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item.id}
className="flex items-center gap-md py-xs"
style={{ borderLeft: "2px solid var(--color-accent)" }}
>
<span
className="font-mono text-xs flex-shrink-0 pl-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{item.startTime}{item.endTime}
</span>
<div className="min-w-0 flex-1">
<p
className="text-sm font-medium text-truncate"
style={{ color: "var(--color-ink)" }}
>
{item.subject}
</p>
<p
className="text-xs text-truncate"
style={{ color: "var(--color-ink-muted)" }}
>
{item.teacher} · {item.classroom}
</p>
</div>
</li>
))}
</ul>
)}
</section>
);
}
// === 成长曲线 ===
function GrowthCurveCard({
items,
}: {
items: GrowthDataPoint[];
}): JSX.Element {
const chartData = useMemo(
() =>
items.map((p) => ({
date: p.date.slice(5),
myScore: p.myScore,
classAverage: p.classAverage,
})),
[items],
);
return (
<section aria-label="成长曲线" className="card-paper p-6">
<div className="flex items-baseline justify-between mb-4">
<h2
className="text-lg font-serif"
style={{ color: "var(--color-ink)" }}
>
线
</h2>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
vs
</span>
</div>
<div className="rule-thin mb-4" />
{items.length === 0 ? (
<p
className="text-sm py-6 text-center"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
) : (
<div style={{ width: "100%", height: 240 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 8, right: 16, bottom: 8, left: 0 }}
>
<CartesianGrid
stroke="var(--color-rule)"
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tick={{ fill: "var(--color-ink-muted)", fontSize: 11 }}
/>
<YAxis
tick={{ fill: "var(--color-ink-muted)", fontSize: 11 }}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
borderRadius: "var(--radius-base)",
fontSize: 12,
}}
labelStyle={{ color: "var(--color-ink)" }}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
/>
<Line
type="monotone"
dataKey="myScore"
name="我的成绩"
stroke="var(--color-accent)"
strokeWidth={2}
dot={{ r: 3, fill: "var(--color-accent)" }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="classAverage"
name="班级平均"
stroke="var(--color-ink-muted)"
strokeWidth={1.5}
strokeDasharray="4 4"
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
);
}
// === 加载骨架 === // === 加载骨架 ===
function LoadingSkeleton(): JSX.Element { function LoadingSkeleton(): JSX.Element {
return ( return (

View File

@@ -3,63 +3,188 @@
/** /**
* MyClasses — 我的班级ai14 * MyClasses — 我的班级ai14
* *
* 数据源useMyClasses GraphQL hook * 数据源useMyClasses + useLeaveClass GraphQL hook
* 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年) * 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年)
* 功能加入班级JoinClassModal/ 退出班级(确认后调用 leaveClass
* *
* 设计依据01-understanding.md §7 L1 视口my-classes * 设计依据01-understanding.md §7 L1 视口my-classes
* 02-architecture-design.md §14.4CSR * 02-architecture-design.md §14.4CSR
*/ */
import { useMyClasses } from "@/lib/graphql/hooks"; import { useState, useCallback, useEffect } from "react";
import {
useMyClasses,
useLeaveClass,
} from "@/lib/graphql/hooks";
import type { ClassInfo } from "@/lib/graphql/types"; import type { ClassInfo } from "@/lib/graphql/types";
import { JoinClassModal } from "@/components/join-class-modal";
export default function MyClassesPage(): JSX.Element { export default function MyClassesPage(): JSX.Element {
const { data, fetching, error } = useMyClasses(); const { data, fetching, error, reexecute } = useMyClasses();
const classes = data ?? []; const classes = data ?? [];
const [modalOpen, setModalOpen] = useState(false);
const [leavingClassId, setLeavingClassId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const {
execute: executeLeave,
fetching: leaveFetching,
data: leaveData,
success: leaveSuccess,
errors: leaveErrors,
} = useLeaveClass();
// 响应退出班级结果
useEffect(() => {
if (!leaveFetching && leaveData !== null && leavingClassId !== null) {
if (leaveSuccess) {
setFeedback({ type: "success", message: "已退出班级" });
setLeavingClassId(null);
reexecute();
} else if (!leaveSuccess) {
const errMsg = leaveErrors[0]?.message ?? "退出失败";
setFeedback({ type: "error", message: errMsg });
setLeavingClassId(null);
}
}
}, [
leaveData,
leaveSuccess,
leaveErrors,
leaveFetching,
leavingClassId,
reexecute,
]);
const handleOpenModal = useCallback((): void => {
setModalOpen(true);
}, []);
const handleCloseModal = useCallback((): void => {
setModalOpen(false);
}, []);
const handleJoined = useCallback(
(_className: string): void => {
setFeedback({ type: "success", message: "加入班级成功" });
reexecute();
},
[reexecute],
);
const handleLeaveClass = useCallback((classId: string): void => {
setLeavingClassId(classId);
executeLeave({ classId });
}, [executeLeave]);
const handleConfirmLeave = useCallback(
(classId: string): void => {
if (
window.confirm("确定要退出该班级吗?退出后将无法查看该班级的作业与通知。")
) {
handleLeaveClass(classId);
}
},
[handleLeaveClass],
);
return ( return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto"> <div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6"> <header className="mb-6">
<p <div className="flex items-start justify-between gap-md">
className="text-xs uppercase tracking-wider" <div className="min-w-0 flex-1">
style={{ color: "var(--color-ink-muted)" }} <p
> className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
</p> >
<h1
className="mt-2 text-3xl lg:text-4xl font-serif" </p>
style={{ color: "var(--color-ink)" }} <h1
> className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
</h1> >
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
</h1>
</p> <p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
<button
type="button"
onClick={handleOpenModal}
className="btn-primary flex-shrink-0"
>
</button>
</div>
</header> </header>
<div className="rule-thin mb-8" /> <div className="rule-thin mb-8" />
{feedback && (
<div
className="mb-md p-sm rounded text-sm"
role="alert"
style={{
color:
feedback.type === "success"
? "var(--color-success)"
: "var(--color-danger)",
background:
feedback.type === "success"
? "hsl(var(--hue-success), var(--sat-success), 95%)"
: "hsl(var(--hue-danger), var(--sat-danger), 95%)",
}}
>
{feedback.message}
</div>
)}
{fetching ? ( {fetching ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : error ? ( ) : error ? (
<ErrorState message={error.message} /> <ErrorState message={error.message} />
) : classes.length === 0 ? ( ) : classes.length === 0 ? (
<EmptyState /> <EmptyState onJoin={handleOpenModal} />
) : ( ) : (
<section <section
aria-label="班级列表" aria-label="班级列表"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
> >
{classes.map((cls) => ( {classes.map((cls) => (
<ClassCard key={cls.id} cls={cls} /> <ClassCard
key={cls.id}
cls={cls}
onLeave={handleConfirmLeave}
leaving={leavingClassId === cls.id && leaveFetching}
/>
))} ))}
</section> </section>
)} )}
<JoinClassModal
open={modalOpen}
onClose={handleCloseModal}
onJoined={handleJoined}
/>
</div> </div>
); );
} }
function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element { function ClassCard({
cls,
onLeave,
leaving,
}: {
cls: ClassInfo;
onLeave: (classId: string) => void;
leaving: boolean;
}): JSX.Element {
return ( return (
<article className="card-paper p-6 flex flex-col"> <article className="card-paper p-6 flex flex-col">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -98,6 +223,18 @@ function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element {
<dd style={{ color: "var(--color-ink)" }}>{cls.homeroomTeacher}</dd> <dd style={{ color: "var(--color-ink)" }}>{cls.homeroomTeacher}</dd>
</div> </div>
</dl> </dl>
<div className="mt-auto pt-4">
<button
type="button"
onClick={() => onLeave(cls.id)}
disabled={leaving}
className="btn-secondary text-xs"
aria-label={`退出班级 ${cls.name}`}
>
{leaving ? "退出中…" : "退出班级"}
</button>
</div>
</article> </article>
); );
} }
@@ -161,7 +298,7 @@ function ErrorState({ message }: { message: string }): JSX.Element {
); );
} }
function EmptyState(): JSX.Element { function EmptyState({ onJoin }: { onJoin: () => void }): JSX.Element {
return ( return (
<div className="card-paper p-8 text-center"> <div className="card-paper p-8 text-center">
<p <p
@@ -178,8 +315,15 @@ function EmptyState(): JSX.Element {
</p> </p>
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}> <p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
使
</p> </p>
<button
type="button"
onClick={onJoin}
className="mt-4 btn-primary"
>
</button>
</div> </div>
); );
} }

View File

@@ -16,6 +16,10 @@ import type {
ExamResultSummary, ExamResultSummary,
QuestionResult, QuestionResult,
} from "@/lib/exam-types"; } from "@/lib/exam-types";
import {
TeacherAnnotationOverlay,
type TeacherAnnotation,
} from "@/components/teacher-annotation-overlay";
const GRAPHQL_ENDPOINT = const GRAPHQL_ENDPOINT =
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql"; process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
@@ -102,6 +106,42 @@ function createMockExamResult(examId: string): ExamResult {
}; };
} }
/** 模拟教师批注数据P5 阶段,后续接入 teacher-bff 批注 API */
const MOCK_ANNOTATIONS: TeacherAnnotation[] = [
{
id: "ann-001",
questionId: "q2",
type: "comment",
teacherName: "李老师",
content: "注意 9 不是质数9 = 3 × 3。多选题需要逐项验证。",
createdAt: new Date().toISOString(),
},
{
id: "ann-002",
questionId: "q4",
type: "correction",
teacherName: "李老师",
content: "定义表述不够完整,应强调极限概念。导数的严格定义需要用到极限运算。",
createdAt: new Date().toISOString(),
},
{
id: "ann-003",
questionId: "q4",
type: "comment",
teacherName: "李老师",
content: "思路正确但建议补充数学符号表示f'(x₀) = lim[Δx→0] (f(x₀+Δx) - f(x₀)) / Δx",
createdAt: new Date().toISOString(),
},
{
id: "ann-004",
questionId: "q5",
type: "comment",
teacherName: "李老师",
content: "本题未作答,建议复习微积分基本定理及其在物理学中的位移-速度关系应用。",
createdAt: new Date().toISOString(),
},
];
/** 获取考试结果API 或 Mock */ /** 获取考试结果API 或 Mock */
async function fetchExamResult(examId: string): Promise<ExamResult> { async function fetchExamResult(examId: string): Promise<ExamResult> {
if (IS_MOCKING) { if (IS_MOCKING) {
@@ -380,6 +420,18 @@ export default function ExamResultPage(): JSX.Element {
<p className="mt-xs text-sm text-ink">{qr.explanation}</p> <p className="mt-xs text-sm text-ink">{qr.explanation}</p>
</div> </div>
)} )}
{/* 教师批注 */}
<TeacherAnnotationOverlay
questionId={qr.questionId}
answerText={
qr.studentAnswer?.type === "short-answer" ||
qr.studentAnswer?.type === "essay"
? qr.studentAnswer.text
: undefined
}
annotations={MOCK_ANNOTATIONS}
/>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,275 @@
"use client";
/**
* AssignmentAnalysis — 作业分析ai14P5路由 /my-homework/[id]/analysis
*
* 数据源useAssignmentAnalysis GraphQL hook
* 视图:
* - 汇总统计(平均分、最高分、最低分、提交人数)
* - 题目分析列表(正确率、参考答案、知识点)
* - 正确率进度条可视化
*
* 设计依据纸感设计card-paper + mark-left 知识点标签)
*/
import { useParams } from "next/navigation";
import { useAssignmentAnalysis } from "@/lib/graphql/hooks";
import type { AssignmentAnalysisQuestion } from "@/lib/graphql/types";
/** 正确率对应的颜色令牌 */
function correctRateColor(rate: number): string {
if (rate >= 0.8) return "var(--color-success)";
if (rate >= 0.6) return "var(--color-warning)";
return "var(--color-danger)";
}
/** 正确率标签 */
function correctRateLabel(rate: number): string {
const percent = Math.round(rate * 100);
return `${percent}%`;
}
/** 题型中文标签 */
const QUESTION_TYPE_LABELS: Record<string, string> = {
"single-choice": "单选题",
"multiple-choice": "多选题",
"fill-blank": "填空题",
"short-answer": "简答题",
essay: "论述题",
};
export default function AssignmentAnalysisPage(): JSX.Element {
const params = useParams();
const homeworkId = String(params?.id ?? "");
const { data, fetching, error, reexecute } = useAssignmentAnalysis(homeworkId);
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-4xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
{data?.title ?? "作业分析"}
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</header>
<div className="rule-thin mb-6" />
{fetching ? (
<p
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
aria-live="polite"
>
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p
className="text-sm"
style={{ color: "var(--color-ink)" }}
>
{error.message || "加载失败"}
</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : !data ? (
<p
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
role="status"
>
</p>
) : (
<>
{/* 汇总统计 */}
<section
className="grid grid-cols-2 sm:grid-cols-4 gap-md mb-lg"
aria-label="作业汇总"
>
<StatCard label="总分" value={String(data.totalScore)} />
<StatCard label="平均分" value={String(data.avgScore)} />
<StatCard label="最高分" value={String(data.maxScore)} />
<StatCard label="最低分" value={String(data.minScore)} />
</section>
<div
className="mb-lg text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
<span style={{ color: "var(--color-ink)" }}>{data.submitCount}</span>
</div>
{/* 题目分析列表 */}
<section aria-label="题目分析">
<h2
className="font-serif text-lg mb-md"
style={{ color: "var(--color-ink)" }}
>
</h2>
<div className="space-y-md">
{data.questions.map((q, idx) => (
<QuestionAnalysisCard
key={q.questionId}
question={q}
index={idx + 1}
/>
))}
</div>
</section>
</>
)}
</div>
);
}
/** 统计卡片 */
function StatCard({
label,
value,
}: {
label: string;
value: string;
}): JSX.Element {
return (
<div className="card-paper p-md">
<p
className="text-xs"
style={{ color: "var(--color-ink-subtle)" }}
>
{label}
</p>
<p
className="font-serif text-2xl mt-xs"
style={{ color: "var(--color-ink)" }}
>
{value}
</p>
</div>
);
}
/** 题目分析卡片 */
function QuestionAnalysisCard({
question,
index,
}: {
question: AssignmentAnalysisQuestion;
index: number;
}): JSX.Element {
const rateColor = correctRateColor(question.correctRate);
const ratePercent = Math.round(question.correctRate * 100);
return (
<div className="card-paper p-md">
{/* 题目头部 */}
<div className="flex items-start justify-between gap-md mb-sm">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-sm mb-xs">
<span
className="font-serif text-sm"
style={{ color: "var(--color-ink)" }}
>
{index}
</span>
<span
className="badge badge-info"
>
{QUESTION_TYPE_LABELS[question.questionType] ?? question.questionType}
</span>
</div>
<p
className="text-sm"
style={{ color: "var(--color-ink)" }}
>
{question.questionText}
</p>
</div>
<div className="text-right flex-shrink-0">
<p
className="text-xs"
style={{ color: "var(--color-ink-subtle)" }}
>
</p>
<p
className="font-serif text-xl"
style={{ color: rateColor }}
>
{correctRateLabel(question.correctRate)}
</p>
</div>
</div>
{/* 正确率进度条 */}
<div
className="w-full h-1.5 rounded-full overflow-hidden mb-md"
style={{ background: "var(--color-rule)" }}
role="progressbar"
aria-valuenow={ratePercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`正确率 ${ratePercent}%`}
>
<div
className="h-full rounded-full transition-all"
style={{
width: `${ratePercent}%`,
background: rateColor,
}}
/>
</div>
{/* 参考答案 */}
<div className="mark-left">
<p
className="text-xs mb-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="text-sm leading-relaxed"
style={{ color: "var(--color-ink)" }}
>
{question.referenceAnswer}
</p>
</div>
{/* 知识点 */}
{question.knowledgePoints.length > 0 && (
<div className="mt-sm flex flex-wrap gap-xs">
{question.knowledgePoints.map((kp) => (
<span
key={kp}
className="badge badge-info"
>
{kp}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -3,17 +3,23 @@
/** /**
* MyHomework — 我的作业列表ai14 * MyHomework — 我的作业列表ai14
* *
* 数据源useMyHomework GraphQL hook * 数据源useMyHomework + useRequestExtension GraphQL hook
* 视图按状态分组pending / submitted / graded * 视图按状态分组pending / overdue / submitted / graded
* 每张卡片:标题 + 学科 + 截止时间 + 状态徽标 * 每张卡片:标题 + 学科 + 截止时间 + 状态徽标
* 功能:学科筛选 / 申请延期pending & overdue/ 查看分析graded
* 链接pending → /my-homework/[id]/submit * 链接pending → /my-homework/[id]/submit
* graded → /my-homework/[id]/analysis
* *
* 设计依据01-understanding.md §8L2 路由表) * 设计依据01-understanding.md §8L2 路由表)
* 02-architecture-design.md §15详细组件设计 * 02-architecture-design.md §15详细组件设计
*/ */
import { useState, useMemo, useCallback, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useMyHomework } from "@/lib/graphql/hooks"; import {
useMyHomework,
useRequestExtension,
} from "@/lib/graphql/hooks";
import type { HomeworkListItem, HomeworkStatus } from "@/lib/graphql/types"; import type { HomeworkListItem, HomeworkStatus } from "@/lib/graphql/types";
const dateFormatter = new Intl.DateTimeFormat("zh-CN", { const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
@@ -51,16 +57,93 @@ const STATUS_GROUPS: Array<{
}, },
]; ];
type SubjectFilter = "all" | string;
export default function MyHomeworkPage(): JSX.Element { export default function MyHomeworkPage(): JSX.Element {
const { data, fetching, error } = useMyHomework(); const { data, fetching, error, reexecute } = useMyHomework();
const homework = data ?? []; const homework = data ?? [];
const [subjectFilter, setSubjectFilter] = useState<SubjectFilter>("all");
const [extensionTarget, setExtensionTarget] = useState<HomeworkListItem | null>(null);
const [extensionReason, setExtensionReason] = useState("");
const [feedback, setFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const {
execute: executeExtension,
fetching: extFetching,
data: extData,
success: extSuccess,
errors: extErrors,
} = useRequestExtension();
// 提取所有学科选项(去重)
const subjectOptions = useMemo(() => {
const map = new Map<string, string>();
homework.forEach((h) => {
map.set(h.subject.id, h.subject.name);
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [homework]);
// 按学科筛选
const filteredHomework = useMemo(() => {
if (subjectFilter === "all") return homework;
return homework.filter((h) => h.subject.id === subjectFilter);
}, [homework, subjectFilter]);
// 按状态分组 // 按状态分组
const grouped = STATUS_GROUPS.map((g) => ({ const grouped = STATUS_GROUPS.map((g) => ({
...g, ...g,
items: homework.filter((h) => h.status === g.key), items: filteredHomework.filter((h) => h.status === g.key),
})); }));
// 响应延期申请结果
useEffect(() => {
if (!extFetching && extData !== null && extensionTarget) {
if (extSuccess && extData) {
setFeedback({
type: "success",
message: `延期申请已通过,新截止时间:${dateFormatter.format(
new Date(extData.newDueDate),
)}`,
});
setExtensionTarget(null);
setExtensionReason("");
reexecute();
} else if (!extSuccess) {
const errMsg = extErrors[0]?.message ?? "延期申请失败";
setFeedback({ type: "error", message: errMsg });
setExtensionTarget(null);
}
}
}, [extData, extSuccess, extErrors, extFetching, extensionTarget, reexecute]);
const handleRequestExtension = useCallback(
(hw: HomeworkListItem): void => {
setExtensionTarget(hw);
setExtensionReason("");
},
[],
);
const handleSubmitExtension = useCallback((): void => {
if (!extensionTarget) return;
if (!extensionReason.trim()) {
setFeedback({ type: "error", message: "请填写延期原因" });
return;
}
executeExtension({
homeworkId: extensionTarget.id,
reason: extensionReason.trim(),
});
}, [extensionTarget, extensionReason, executeExtension]);
const handleCloseExtension = useCallback((): void => {
setExtensionTarget(null);
setExtensionReason("");
}, []);
return ( return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto"> <div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6"> <header className="mb-6">
@@ -81,13 +164,89 @@ export default function MyHomeworkPage(): JSX.Element {
</p> </p>
</header> </header>
{/* 学科筛选 */}
{homework.length > 0 && (
<div
className="mb-md flex items-center gap-sm flex-wrap"
role="group"
aria-label="学科筛选"
>
<span
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</span>
<button
type="button"
onClick={() => setSubjectFilter("all")}
className="px-sm py-xs text-xs rounded-sm"
style={{
background:
subjectFilter === "all"
? "var(--color-accent)"
: "transparent",
color:
subjectFilter === "all"
? "var(--color-paper)"
: "var(--color-ink)",
border: "1px solid var(--color-rule)",
}}
aria-pressed={subjectFilter === "all"}
>
</button>
{subjectOptions.map((subj) => (
<button
key={subj.id}
type="button"
onClick={() => setSubjectFilter(subj.id)}
className="px-sm py-xs text-xs rounded-sm"
style={{
background:
subjectFilter === subj.id
? "var(--color-accent)"
: "transparent",
color:
subjectFilter === subj.id
? "var(--color-paper)"
: "var(--color-ink)",
border: "1px solid var(--color-rule)",
}}
aria-pressed={subjectFilter === subj.id}
>
{subj.name}
</button>
))}
</div>
)}
<div className="rule-thin mb-8" /> <div className="rule-thin mb-8" />
{feedback && (
<div
className="mb-md p-sm rounded text-sm"
role="alert"
style={{
color:
feedback.type === "success"
? "var(--color-success)"
: "var(--color-danger)",
background:
feedback.type === "success"
? "hsl(var(--hue-success), var(--sat-success), 95%)"
: "hsl(var(--hue-danger), var(--sat-danger), 95%)",
}}
>
{feedback.message}
</div>
)}
{fetching ? ( {fetching ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : error ? ( ) : error ? (
<ErrorState message={error.message} /> <ErrorState message={error.message} />
) : homework.length === 0 ? ( ) : filteredHomework.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<div className="space-y-10"> <div className="space-y-10">
@@ -113,7 +272,10 @@ export default function MyHomeworkPage(): JSX.Element {
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{group.items.map((hw) => ( {group.items.map((hw) => (
<li key={hw.id}> <li key={hw.id}>
<HomeworkCard homework={hw} /> <HomeworkCard
homework={hw}
onRequestExtension={handleRequestExtension}
/>
</li> </li>
))} ))}
</ul> </ul>
@@ -122,34 +284,107 @@ export default function MyHomeworkPage(): JSX.Element {
)} )}
</div> </div>
)} )}
{/* 延期申请对话框 */}
{extensionTarget && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-md"
style={{ background: "rgba(0,0,0,0.5)" }}
role="dialog"
aria-modal="true"
aria-labelledby="extension-title"
onClick={(e) => {
if (e.target === e.currentTarget && !extFetching) {
handleCloseExtension();
}
}}
>
<div
className="w-full max-w-md card-paper p-lg"
style={{ background: "var(--color-paper)" }}
onClick={(e) => e.stopPropagation()}
>
<h3
id="extension-title"
className="font-serif text-xl mb-md"
style={{ color: "var(--color-ink)" }}
>
</h3>
<p
className="text-sm mb-md"
style={{ color: "var(--color-ink-muted)" }}
>
{extensionTarget.title}
</p>
<label
htmlFor="ext-reason"
className="block text-xs mb-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
id="ext-reason"
value={extensionReason}
onChange={(e) => setExtensionReason(e.target.value)}
rows={3}
placeholder="请说明延期原因…"
className="w-full input-paper"
aria-label="延期原因"
/>
<div className="mt-md flex justify-end gap-sm">
<button
type="button"
onClick={handleCloseExtension}
disabled={extFetching}
className="btn-secondary"
>
</button>
<button
type="button"
onClick={handleSubmitExtension}
disabled={extFetching || !extensionReason.trim()}
className="btn-primary"
>
{extFetching ? "申请中…" : "提交申请"}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
function HomeworkCard({ function HomeworkCard({
homework, homework,
onRequestExtension,
}: { }: {
homework: HomeworkListItem; homework: HomeworkListItem;
onRequestExtension: (hw: HomeworkListItem) => void;
}): JSX.Element { }): JSX.Element {
const dueAt = new Date(homework.dueAt); const dueAt = new Date(homework.dueAt);
const isPending = homework.status === "pending"; const isPending = homework.status === "pending";
const linkHref = isPending ? `/my-homework/${homework.id}/submit` : null; const isOverdue = homework.status === "overdue";
const isGraded = homework.status === "graded";
const canRequestExtension = isPending || isOverdue;
const submitHref = isPending || isOverdue ? `/my-homework/${homework.id}/submit` : null;
const analysisHref = isGraded ? `/my-homework/${homework.id}/analysis` : null;
const badgeClass = getBadgeClassForStatus(homework.status); const badgeClass = getBadgeClassForStatus(homework.status);
// 截止时间紧迫度(仅 pending 显示) // 截止时间紧迫度(仅 pending 显示)
const now = new Date(); const now = new Date();
const isOverdue = isPending && dueAt < now;
const isUrgent = const isUrgent =
isPending && isPending &&
!isOverdue && dueAt.getTime() - now.getTime() > 0 &&
dueAt.getTime() - now.getTime() < 24 * 60 * 60 * 1000; dueAt.getTime() - now.getTime() < 24 * 60 * 60 * 1000;
const inner = ( const inner = (
<article <article
className={`card-paper p-5 h-full transition-shadow ${ className="card-paper p-5 h-full"
linkHref ? "hover:shadow-card motion-safe" : ""
}`}
aria-label={`${homework.title} - ${homework.status}`} aria-label={`${homework.title} - ${homework.status}`}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -207,7 +442,7 @@ function HomeworkCard({
{homework.questionCount} {homework.questionCount}
</dd> </dd>
</div> </div>
{homework.status === "graded" && homework.score !== undefined && ( {isGraded && homework.score !== undefined && (
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<dt <dt
className="uppercase tracking-wider" className="uppercase tracking-wider"
@@ -232,24 +467,45 @@ function HomeworkCard({
)} )}
</dl> </dl>
{linkHref && ( {/* 操作按钮区 */}
<p className="mt-4 text-xs" style={{ color: "var(--color-accent)" }}> <div className="mt-4 flex items-center gap-sm">
{submitHref && (
</p> <Link
)} href={submitHref}
className="btn-primary text-xs"
aria-label={`${homework.title} - 进入提交`}
>
</Link>
)}
{canRequestExtension && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRequestExtension(homework);
}}
className="btn-secondary text-xs"
aria-label={`申请延期 ${homework.title}`}
>
</button>
)}
{analysisHref && (
<Link
href={analysisHref}
className="btn-secondary text-xs"
aria-label={`查看分析 ${homework.title}`}
>
</Link>
)}
</div>
</article> </article>
); );
if (!linkHref) return inner; return inner;
return (
<Link
href={linkHref}
className="block h-full focus-visible:outline-2"
aria-label={`${homework.title} - 进入提交`}
>
{inner}
</Link>
);
} }
function getBadgeClassForStatus(status: HomeworkStatus): string { function getBadgeClassForStatus(status: HomeworkStatus): string {

View File

@@ -0,0 +1,279 @@
"use client";
/**
* Schedule — 我的课表ai14P5路由 /schedule
*
* 数据源useMySchedule GraphQL hook
* 视图:周课表网格(周一到周五,按时间段排列)
* - 移动端:单日纵向列表,可切换日期
* - 桌面端5 列网格,每列一天
*
* 设计依据纸感设计card-paper + rule 分隔线)
*/
import { useState, useMemo } from "react";
import { useMySchedule } from "@/lib/graphql/hooks";
import type { ScheduleItem } from "@/lib/graphql/types";
const DAY_LABELS: Record<number, string> = {
1: "周一",
2: "周二",
3: "周三",
4: "周四",
5: "周五",
6: "周六",
0: "周日",
};
const WEEKDAYS = [1, 2, 3, 4, 5];
/** 获取今日 dayOfWeek */
function getTodayDayOfWeek(): number {
return new Date().getDay();
}
/** 按天分组 */
function groupByDay(items: ScheduleItem[]): Map<number, ScheduleItem[]> {
const map = new Map<number, ScheduleItem[]>();
for (const item of items) {
const list = map.get(item.dayOfWeek) ?? [];
list.push(item);
map.set(item.dayOfWeek, list);
}
// 每天按 startTime 排序
for (const list of map.values()) {
list.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
return map;
}
export default function SchedulePage(): JSX.Element {
const { data, fetching, error, reexecute } = useMySchedule();
const items = data ?? [];
const [selectedDay, setSelectedDay] = useState<number>(getTodayDayOfWeek());
const grouped = useMemo(() => groupByDay(items), [items]);
const todayLabel = DAY_LABELS[selectedDay] ?? "";
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</header>
<div className="rule-thin mb-8" />
{fetching ? (
<p
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
aria-live="polite"
>
</p>
) : error ? (
<div className="mark-left py-md px-md mb-md" aria-live="assertive">
<p
className="text-sm"
style={{ color: "var(--color-ink)" }}
>
{error.message || "加载失败"}
</p>
<button
type="button"
className="btn-secondary mt-sm"
onClick={() => reexecute()}
>
</button>
</div>
) : items.length === 0 ? (
<p
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
role="status"
>
</p>
) : (
<>
{/* 移动端:日期切换 */}
<div
className="lg:hidden mb-md flex gap-xs overflow-x-auto"
role="tablist"
aria-label="选择日期"
>
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
role="tab"
aria-selected={selectedDay === day}
onClick={() => setSelectedDay(day)}
className="px-md py-sm text-sm rounded whitespace-nowrap"
style={{
background:
selectedDay === day
? "var(--color-accent)"
: "transparent",
color:
selectedDay === day
? "var(--color-paper)"
: "var(--color-ink)",
border: "1px solid var(--color-rule)",
}}
>
{DAY_LABELS[day]}
</button>
))}
</div>
{/* 移动端:单日列表 */}
<div className="lg:hidden space-y-sm">
{(grouped.get(selectedDay) ?? []).map((item) => (
<ScheduleCard key={item.id} item={item} showDay={false} />
))}
{(grouped.get(selectedDay) ?? []).length === 0 && (
<p
className="text-sm py-lg text-center"
style={{ color: "var(--color-ink-muted)" }}
>
{todayLabel}
</p>
)}
</div>
{/* 桌面端5 列网格 */}
<div
className="hidden lg:grid grid-cols-5 gap-md"
role="grid"
aria-label="周课表"
>
{WEEKDAYS.map((day) => {
const dayItems = grouped.get(day) ?? [];
const isToday = day === getTodayDayOfWeek();
return (
<div
key={day}
role="row"
className="flex flex-col"
>
<div
className="mb-sm pb-xs text-center border-b"
style={{
borderColor: isToday
? "var(--color-accent)"
: "var(--color-rule)",
borderBottomWidth: isToday ? "2px" : "1px",
}}
>
<span
className="font-serif text-sm"
style={{
color: isToday
? "var(--color-accent)"
: "var(--color-ink)",
}}
>
{DAY_LABELS[day]}
</span>
{isToday && (
<span
className="ml-xs text-xs"
style={{ color: "var(--color-accent)" }}
>
·
</span>
)}
</div>
<div className="space-y-sm flex-1">
{dayItems.map((item) => (
<ScheduleCard
key={item.id}
item={item}
showDay={false}
/>
))}
{dayItems.length === 0 && (
<p
className="text-xs py-md text-center"
style={{
color: "var(--color-ink-subtle)",
}}
>
</p>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
}
/** 单节课卡片 */
function ScheduleCard({
item,
showDay,
}: {
item: ScheduleItem;
showDay: boolean;
}): JSX.Element {
return (
<div
className="card-paper p-sm"
role="gridcell"
style={{ borderLeft: "2px solid var(--color-accent)" }}
>
<div className="flex items-baseline justify-between mb-xs">
<span
className="font-mono text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{item.startTime}{item.endTime}
</span>
{showDay && (
<span
className="text-xs"
style={{ color: "var(--color-ink-subtle)" }}
>
{DAY_LABELS[item.dayOfWeek] ?? ""}
</span>
)}
</div>
<p
className="font-serif text-sm"
style={{ color: "var(--color-ink)" }}
>
{item.subject}
</p>
<p
className="text-xs mt-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{item.teacher} · {item.classroom}
</p>
</div>
);
}

View File

@@ -0,0 +1,460 @@
"use client";
/**
* Settings — 系统设置ai14P5路由 /settings
*
* 数据源useMyProfile + useUpdateProfile + useChangePassword
* 视图:
* - Tab 1个人资料姓名、邮箱、手机、性别、简介、偏好学科
* - Tab 2修改密码当前密码、新密码、确认新密码
*
* 设计依据纸感设计card-paper + mark-left 表单区)
*/
import { useState, useCallback, useEffect } from "react";
import {
useMyProfile,
useUpdateProfile,
useChangePassword,
} from "@/lib/graphql/hooks";
import type { StudentProfile, UpdateProfileInput } from "@/lib/graphql/types";
import { cn } from "@/lib/exam-types";
type TabKey = "profile" | "password";
interface ProfileForm {
name: string;
email: string;
phone: string;
gender: string;
bio: string;
preferredSubjectId: string;
}
interface PasswordForm {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
const EMPTY_PROFILE_FORM: ProfileForm = {
name: "",
email: "",
phone: "",
gender: "",
bio: "",
preferredSubjectId: "",
};
const EMPTY_PASSWORD_FORM: PasswordForm = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
export default function SettingsPage(): JSX.Element {
const [activeTab, setActiveTab] = useState<TabKey>("profile");
const { data: profile, fetching, error } = useMyProfile();
return (
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-3xl mx-auto">
<header className="mb-6">
<p
className="text-xs uppercase tracking-wider"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<h1
className="mt-2 text-3xl lg:text-4xl font-serif"
style={{ color: "var(--color-ink)" }}
>
</h1>
<p
className="mt-2 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</header>
<div className="rule-thin mb-6" />
{/* Tab 切换 */}
<div
className="flex gap-md mb-lg"
role="tablist"
aria-label="设置分类"
>
<button
type="button"
role="tab"
aria-selected={activeTab === "profile"}
onClick={() => setActiveTab("profile")}
className={cn(
"px-md py-sm text-sm rounded-sm transition-colors",
activeTab === "profile" ? "font-serif" : "",
)}
style={{
color:
activeTab === "profile"
? "var(--color-accent)"
: "var(--color-ink-muted)",
borderBottom:
activeTab === "profile"
? "2px solid var(--color-accent)"
: "2px solid transparent",
}}
>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "password"}
onClick={() => setActiveTab("password")}
className={cn(
"px-md py-sm text-sm rounded-sm transition-colors",
activeTab === "password" ? "font-serif" : "",
)}
style={{
color:
activeTab === "password"
? "var(--color-accent)"
: "var(--color-ink-muted)",
borderBottom:
activeTab === "password"
? "2px solid var(--color-accent)"
: "2px solid transparent",
}}
>
</button>
</div>
{fetching && !profile ? (
<p
className="text-sm"
style={{ color: "var(--color-ink-muted)" }}
aria-live="polite"
>
</p>
) : error ? (
<div className="mark-left py-md px-md" aria-live="assertive">
<p
className="text-sm"
style={{ color: "var(--color-ink)" }}
>
{error.message || "加载失败"}
</p>
</div>
) : activeTab === "profile" ? (
<ProfileTab initialProfile={profile} />
) : (
<PasswordTab />
)}
</div>
);
}
/** 个人资料 Tab */
function ProfileTab({
initialProfile,
}: {
initialProfile: StudentProfile | null;
}): JSX.Element {
const [form, setForm] = useState<ProfileForm>(EMPTY_PROFILE_FORM);
const [feedback, setFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const { execute, fetching, data, success, errors } = useUpdateProfile();
// 同步 profile 数据到表单
useEffect(() => {
if (initialProfile) {
setForm({
name: initialProfile.name,
email: initialProfile.email,
phone: initialProfile.phone,
gender: initialProfile.gender,
bio: initialProfile.bio,
preferredSubjectId: initialProfile.preferredSubjectId,
});
}
}, [initialProfile]);
// 响应 mutation 结果
useEffect(() => {
if (!fetching && data !== null) {
if (success) {
setFeedback({ type: "success", message: "个人资料已更新" });
} else if (!success) {
const errMsg = errors[0]?.message ?? "更新失败";
setFeedback({ type: "error", message: errMsg });
}
}
}, [data, success, errors, fetching]);
const handleChange = useCallback(
(field: keyof ProfileForm, value: string): void => {
setForm((prev) => ({ ...prev, [field]: value }));
setFeedback(null);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent): void => {
e.preventDefault();
const input: UpdateProfileInput = {
name: form.name,
email: form.email,
phone: form.phone,
gender: form.gender,
bio: form.bio,
preferredSubjectId: form.preferredSubjectId,
};
execute(input);
},
[form, execute],
);
return (
<form onSubmit={handleSubmit} className="space-y-md" aria-label="个人资料表单">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-md">
<FormField
label="姓名"
id="name"
value={form.name}
onChange={(v) => handleChange("name", v)}
/>
<FormField
label="学号"
id="studentNo"
value={initialProfile?.studentNo ?? "—"}
onChange={() => {}}
disabled
/>
<FormField
label="邮箱"
id="email"
type="email"
value={form.email}
onChange={(v) => handleChange("email", v)}
/>
<FormField
label="手机"
id="phone"
value={form.phone}
onChange={(v) => handleChange("phone", v)}
/>
<FormField
label="性别"
id="gender"
value={form.gender}
onChange={(v) => handleChange("gender", v)}
/>
<FormField
label="偏好学科 ID"
id="preferredSubjectId"
value={form.preferredSubjectId}
onChange={(v) => handleChange("preferredSubjectId", v)}
/>
</div>
<div>
<label
htmlFor="bio"
className="block text-xs mb-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</label>
<textarea
id="bio"
value={form.bio}
onChange={(e) => handleChange("bio", e.target.value)}
rows={3}
className="w-full input-paper"
aria-label="个人简介"
/>
</div>
{feedback && (
<p
className="text-sm"
role="alert"
style={{
color:
feedback.type === "success"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{feedback.message}
</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={fetching}
className="btn-primary"
>
{fetching ? "保存中…" : "保存"}
</button>
</div>
</form>
);
}
/** 修改密码 Tab */
function PasswordTab(): JSX.Element {
const [form, setForm] = useState<PasswordForm>(EMPTY_PASSWORD_FORM);
const [feedback, setFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const { execute, fetching, data, success, errors } = useChangePassword();
useEffect(() => {
if (!fetching && data !== null) {
if (success) {
setFeedback({ type: "success", message: "密码已修改" });
setForm(EMPTY_PASSWORD_FORM);
} else if (!success) {
const errMsg = errors[0]?.message ?? "修改失败";
setFeedback({ type: "error", message: errMsg });
}
}
}, [data, success, errors, fetching]);
const handleChange = useCallback(
(field: keyof PasswordForm, value: string): void => {
setForm((prev) => ({ ...prev, [field]: value }));
setFeedback(null);
},
[],
);
const handleSubmit = useCallback(
(e: React.FormEvent): void => {
e.preventDefault();
if (form.newPassword !== form.confirmPassword) {
setFeedback({ type: "error", message: "两次输入的新密码不一致" });
return;
}
if (form.newPassword.length < 8) {
setFeedback({ type: "error", message: "新密码至少 8 位" });
return;
}
execute({
currentPassword: form.currentPassword,
newPassword: form.newPassword,
});
},
[form, execute],
);
return (
<form
onSubmit={handleSubmit}
className="space-y-md max-w-md"
aria-label="修改密码表单"
>
<FormField
label="当前密码"
id="currentPassword"
type="password"
value={form.currentPassword}
onChange={(v) => handleChange("currentPassword", v)}
required
/>
<FormField
label="新密码"
id="newPassword"
type="password"
value={form.newPassword}
onChange={(v) => handleChange("newPassword", v)}
required
/>
<FormField
label="确认新密码"
id="confirmPassword"
type="password"
value={form.confirmPassword}
onChange={(v) => handleChange("confirmPassword", v)}
required
/>
{feedback && (
<p
className="text-sm"
role="alert"
style={{
color:
feedback.type === "success"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{feedback.message}
</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={fetching}
className="btn-primary"
>
{fetching ? "修改中…" : "修改密码"}
</button>
</div>
</form>
);
}
/** 表单字段组件 */
function FormField({
label,
id,
value,
onChange,
type = "text",
disabled = false,
required = false,
}: {
label: string;
id: string;
value: string;
onChange: (value: string) => void;
type?: string;
disabled?: boolean;
required?: boolean;
}): JSX.Element {
return (
<div>
<label
htmlFor={id}
className="block text-xs mb-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{label}
{required && (
<span style={{ color: "var(--color-danger)" }}> *</span>
)}
</label>
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={required}
className="w-full input-paper"
/>
</div>
);
}

View File

@@ -4,7 +4,7 @@
* AppShell — 学生端应用壳ai14 * AppShell — 学生端应用壳ai14
* *
* 职责: * 职责:
* - 左侧导航栏:学生端 11 项菜单(含 P3-P5 阶段) * - 左侧导航栏:学生端 13 项菜单(含 P3-P5 阶段)
* - 顶栏:学生姓名 + 头像 + 退出登录 * - 顶栏:学生姓名 + 头像 + 退出登录
* - 主内容区:渲染 children * - 主内容区:渲染 children
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉 * - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
@@ -30,11 +30,13 @@ const NAV_ITEMS: NavItem[] = [
{ label: "我的作业", route: "/my-homework" }, { label: "我的作业", route: "/my-homework" },
{ label: "我的成绩", route: "/my-grades" }, { label: "我的成绩", route: "/my-grades" },
{ label: "我的考勤", route: "/my-attendance" }, { label: "我的考勤", route: "/my-attendance" },
{ label: "我的课表", route: "/schedule" },
{ label: "学习路径", route: "/learning-path" }, { label: "学习路径", route: "/learning-path" },
{ label: "学情诊断", route: "/diagnostic" }, { label: "学情诊断", route: "/diagnostic" },
{ label: "教材", route: "/textbooks" }, { label: "教材", route: "/textbooks" },
{ label: "通知中心", route: "/notifications" }, { label: "通知中心", route: "/notifications" },
{ label: "AI辅学", route: "/ai-tutor" }, { label: "AI辅学", route: "/ai-tutor" },
{ label: "系统设置", route: "/settings" },
]; ];
export default function AppShell({ export default function AppShell({

View File

@@ -15,6 +15,8 @@ import { cn } from "@/lib/exam-types";
import type { AnswerInput, ExamDetail, ExamQuestion } from "@/lib/exam-types"; import type { AnswerInput, ExamDetail, ExamQuestion } from "@/lib/exam-types";
import { CountdownTimer } from "./countdown-timer"; import { CountdownTimer } from "./countdown-timer";
import { DraftRecovery } from "./draft-recovery"; import { DraftRecovery } from "./draft-recovery";
import { HandwritingPad } from "@/components/handwriting-pad";
import { ImageUpload } from "@/components/image-upload";
import { useExamStateMachine } from "@/hooks/use-exam-state-machine"; import { useExamStateMachine } from "@/hooks/use-exam-state-machine";
import { useServerTimeSync } from "@/hooks/use-server-time-sync"; import { useServerTimeSync } from "@/hooks/use-server-time-sync";
import { useExamDraft } from "@/hooks/use-exam-draft"; import { useExamDraft } from "@/hooks/use-exam-draft";
@@ -780,14 +782,11 @@ function QuestionInput({
case "essay": case "essay":
return ( return (
<textarea <EssayInput
value={answer?.type === "essay" ? answer.text : ""} question={question}
onChange={(e) => onChange({ type: "essay", text: e.target.value })} answer={answer}
onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
rows={8}
placeholder="请输入论述内容"
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
aria-label="论述题答案"
/> />
); );
@@ -795,3 +794,165 @@ function QuestionInput({
return <div className="text-sm text-ink-muted"></div>; return <div className="text-sm text-ink-muted"></div>;
} }
} }
// === 论述题输入组件(支持文本/手写/图片)===
type EssayMode = "text" | "handwriting" | "image";
interface EssayInputProps {
question: ExamQuestion;
answer: AnswerInput | null;
onChange: (answer: AnswerInput) => void;
onBlur: () => void;
}
function EssayInput({
question,
answer,
onChange,
onBlur,
}: EssayInputProps): JSX.Element {
const [mode, setMode] = useState<EssayMode>("text");
const textValue = answer?.type === "essay" ? answer.text : "";
const handleTextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>): void => {
onChange({ type: "essay", text: e.target.value });
},
[onChange],
);
const handleHandwritingExport = useCallback(
(dataUrl: string): void => {
if (!dataUrl) return;
// 将手写图片以标记形式追加到文本
const marker = `\n[handwriting:${dataUrl}]\n`;
onChange({ type: "essay", text: textValue + marker });
},
[textValue, onChange],
);
const handleImageChange = useCallback(
(images: string[]): void => {
// 将图片以标记形式追加到文本
const markers = images
.map((img) => `[image:${img}]`)
.join("\n");
onChange({ type: "essay", text: markers });
},
[onChange],
);
const modeTabs: Array<{ key: EssayMode; label: string }> = [
{ key: "text", label: "文本" },
{ key: "handwriting", label: "手写" },
{ key: "image", label: "图片" },
];
return (
<div className="space-y-sm">
{/* 模式切换 */}
<div
className="flex gap-xs"
role="tablist"
aria-label="论述题作答方式"
>
{modeTabs.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={mode === tab.key}
onClick={() => setMode(tab.key)}
className={cn(
"px-md py-xs text-xs rounded-sm transition-colors",
mode === tab.key ? "font-serif" : "",
)}
style={{
background:
mode === tab.key
? "var(--color-accent)"
: "transparent",
color:
mode === tab.key
? "var(--color-paper)"
: "var(--color-ink-muted)",
border: "1px solid var(--color-rule)",
}}
>
{tab.label}
</button>
))}
</div>
{/* 文本输入 */}
{mode === "text" && (
<textarea
value={textValue}
onChange={handleTextChange}
onBlur={onBlur}
rows={8}
placeholder="请输入论述内容"
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
aria-label="论述题文本答案"
/>
)}
{/* 手写板 */}
{mode === "handwriting" && (
<div>
<HandwritingPad
onExport={handleHandwritingExport}
height={280}
ariaLabel={`${question.id} 手写作答区`}
/>
<p
className="mt-xs text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
</div>
)}
{/* 图片上传 */}
{mode === "image" && (
<div>
<ImageUpload
value={[]}
onChange={handleImageChange}
maxCount={3}
ariaLabel={`${question.id} 图片作答上传`}
/>
<p
className="mt-xs text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
3
</p>
</div>
)}
{/* 当前文本答案预览 */}
{textValue && mode !== "text" && (
<div
className="mt-sm p-sm rounded border border-rule"
style={{ background: "var(--color-paper)" }}
>
<p
className="text-xs mb-xs"
style={{ color: "var(--color-ink-muted)" }}
>
</p>
<p
className="text-xs text-clamp-2"
style={{ color: "var(--color-ink)" }}
>
{textValue.replace(/\[(handwriting|image):[^\]]+\]/g, "[附件]")}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,241 @@
"use client";
/**
* HandwritingPad — 手写板组件ai14P5
*
* 基于 Canvas 实现手写作答能力,支持鼠标与触摸输入。
* - 笔迹平滑(二次贝塞尔曲线)
* - 可调笔触粗细与颜色
* - 清空 / 撤销
* - 导出为 PNG data URLbase64供答案附件上传
*
* 用于论述题essay场景学生可用手写板输入公式 / 证明过程。
*/
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { cn } from "@/lib/exam-types";
export interface HandwritingPadProps {
/** 导出图片回调base64 data URL */
onExport?: (dataUrl: string) => void;
/** 笔触粗细(默认 3 */
strokeWidth?: number;
/** 画布宽度(默认 100% */
width?: number | string;
/** 画布高度(默认 240 */
height?: number;
/** 画布 aria 标签 */
ariaLabel?: string;
/** 受控禁用 */
disabled?: boolean;
}
interface Point {
x: number;
y: number;
}
interface Stroke {
points: Point[];
width: number;
}
/** 默认笔触颜色(使用 ink 令牌,避免硬编码) */
const PEN_COLOR = "var(--color-ink)";
/** 将 Pointer 坐标转换为画布相对坐标 */
function getRelativePoint(
e: ReactPointerEvent<HTMLCanvasElement>,
canvas: HTMLCanvasElement,
): Point {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
}
export function HandwritingPad({
onExport,
strokeWidth = 3,
width = "100%",
height = 240,
ariaLabel = "手写作答区",
disabled = false,
}: HandwritingPadProps): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [currentStroke, setCurrentStroke] = useState<Stroke | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
/** 重绘画布 */
const redraw = useCallback((): void => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = PEN_COLOR;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const drawStroke = (stroke: Stroke): void => {
if (stroke.points.length === 0) return;
ctx.lineWidth = stroke.width;
ctx.beginPath();
const first = stroke.points[0];
if (!first) return;
ctx.moveTo(first.x, first.y);
for (let i = 1; i < stroke.points.length; i++) {
const prev = stroke.points[i - 1];
const curr = stroke.points[i];
if (!prev || !curr) continue;
// 二次贝塞尔平滑
const midX = (prev.x + curr.x) / 2;
const midY = (prev.y + curr.y) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
}
const last = stroke.points[stroke.points.length - 1];
if (last) ctx.lineTo(last.x, last.y);
ctx.stroke();
};
strokes.forEach(drawStroke);
if (currentStroke) drawStroke(currentStroke);
}, [strokes, currentStroke]);
useEffect(() => {
redraw();
}, [redraw]);
/** 导出当前画布为 PNG data URL */
const exportImage = useCallback((): string => {
const canvas = canvasRef.current;
if (!canvas) return "";
return canvas.toDataURL("image/png");
}, []);
// strokes 变化时自动导出
useEffect(() => {
if (onExport && strokes.length > 0) {
onExport(exportImage());
}
}, [strokes, onExport, exportImage]);
const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>): void => {
if (disabled) return;
e.preventDefault();
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
const point = getRelativePoint(e, e.currentTarget);
setIsDrawing(true);
setCurrentStroke({ points: [point], width: strokeWidth });
},
[disabled, strokeWidth],
);
const handlePointerMove = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>): void => {
if (!isDrawing || disabled) return;
e.preventDefault();
const point = getRelativePoint(e, e.currentTarget);
setCurrentStroke((prev) =>
prev ? { ...prev, points: [...prev.points, point] } : prev,
);
},
[isDrawing, disabled],
);
const handlePointerUp = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>): void => {
if (!isDrawing) return;
e.preventDefault();
(e.target as HTMLCanvasElement).releasePointerCapture(e.pointerId);
setIsDrawing(false);
setCurrentStroke((prev) => {
if (prev) {
setStrokes((s) => [...s, prev]);
}
return null;
});
},
[isDrawing],
);
const handleClear = useCallback((): void => {
setStrokes([]);
setCurrentStroke(null);
if (onExport) onExport("");
}, [onExport]);
const handleUndo = useCallback((): void => {
setStrokes((s) => s.slice(0, -1));
}, []);
return (
<div className="w-full" role="group" aria-label="手写板控件">
<div
className={cn(
"rounded border border-rule bg-paper overflow-hidden",
disabled && "opacity-60 pointer-events-none",
)}
style={{ background: "var(--color-paper)" }}
>
<canvas
ref={canvasRef}
width={800}
height={height}
style={{
width,
height,
display: "block",
touchAction: "none",
cursor: disabled ? "not-allowed" : "crosshair",
}}
role="img"
aria-label={ariaLabel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerLeave={handlePointerUp}
/>
</div>
<div className="mt-xs flex items-center gap-sm">
<button
type="button"
onClick={handleUndo}
disabled={strokes.length === 0 || disabled}
className="btn-secondary text-xs"
aria-label="撤销上一笔"
>
</button>
<button
type="button"
onClick={handleClear}
disabled={(strokes.length === 0 && !currentStroke) || disabled}
className="btn-secondary text-xs"
aria-label="清空手写板"
>
</button>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
aria-live="polite"
>
{strokes.length}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,280 @@
"use client";
/**
* ImageUpload — 图片上传组件ai14P5
*
* 支持拖拽与点击选择图片,多图预览,移除单张。
* - 受控组件value 为 data URL 数组onChange 回传最新数组
* - 限制:单张 ≤ 5MB最多 maxCount 张,类型 image/*
* - 预览:缩略图网格,点击可全屏查看
*
* 用于论述题essay附件上传场景。
*/
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from "react";
import { cn } from "@/lib/exam-types";
export interface ImageUploadProps {
/** 当前图片列表data URL 数组) */
value: string[];
/** 变更回调 */
onChange: (images: string[]) => void;
/** 最大张数(默认 5 */
maxCount?: number;
/** 单张最大字节(默认 5MB */
maxSizeBytes?: number;
/** 受控禁用 */
disabled?: boolean;
/** aria 标签 */
ariaLabel?: string;
}
const DEFAULT_MAX_COUNT = 5;
const DEFAULT_MAX_SIZE = 5 * 1024 * 1024;
/** 将 File 转为 data URL */
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function ImageUpload({
value,
onChange,
maxCount = DEFAULT_MAX_COUNT,
maxSizeBytes = DEFAULT_MAX_SIZE,
disabled = false,
ariaLabel = "图片上传",
}: ImageUploadProps): JSX.Element {
const inputRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const addFiles = useCallback(
async (files: FileList | File[]): Promise<void> => {
setError(null);
const fileArray = Array.from(files).filter((f) =>
f.type.startsWith("image/"),
);
if (fileArray.length === 0) {
setError("请选择图片文件");
return;
}
const remaining = maxCount - value.length;
if (remaining <= 0) {
setError(`最多上传 ${maxCount} 张图片`);
return;
}
const toProcess = fileArray.slice(0, remaining);
const oversized = toProcess.filter((f) => f.size > maxSizeBytes);
if (oversized.length > 0) {
setError(
`单张图片不能超过 ${Math.round(maxSizeBytes / 1024 / 1024)}MB`,
);
return;
}
try {
const dataUrls = await Promise.all(toProcess.map(fileToDataUrl));
onChange([...value, ...dataUrls]);
} catch {
setError("图片读取失败,请重试");
}
},
[value, onChange, maxCount, maxSizeBytes],
);
const handleFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): void => {
if (!e.target.files) return;
void addFiles(e.target.files);
// 重置 input value 以支持重复选择同一文件
e.target.value = "";
},
[addFiles],
);
const handleDrop = useCallback(
(e: DragEvent<HTMLDivElement>): void => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
if (e.dataTransfer.files.length > 0) {
void addFiles(e.dataTransfer.files);
}
},
[addFiles, disabled],
);
const handleDragOver = useCallback(
(e: DragEvent<HTMLDivElement>): void => {
e.preventDefault();
if (!disabled) setIsDragging(true);
},
[disabled],
);
const handleDragLeave = useCallback(
(e: DragEvent<HTMLDivElement>): void => {
e.preventDefault();
setIsDragging(false);
},
[],
);
const handleRemove = useCallback(
(index: number): void => {
const next = value.filter((_, i) => i !== index);
onChange(next);
},
[value, onChange],
);
const canAddMore = value.length < maxCount && !disabled;
return (
<div className="w-full" role="group" aria-label={ariaLabel}>
{/* 拖拽区 */}
{canAddMore && (
<div
role="button"
tabIndex={0}
aria-label="点击或拖拽上传图片"
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"flex flex-col items-center justify-center gap-sm rounded border border-dashed py-lg cursor-pointer transition-colors",
isDragging
? "border-accent bg-accent-muted"
: "border-rule hover:border-accent",
)}
style={{ background: "var(--color-paper)" }}
>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
style={{ color: "var(--color-ink-muted)" }}
>
<path d="M12 16V4M8 8l4-4 4 4M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</svg>
<p
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{maxCount} {" "}
{Math.round(maxSizeBytes / 1024 / 1024)}MB
</p>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
disabled={disabled}
className="sr-only"
aria-label="选择图片文件"
/>
</div>
)}
{/* 错误提示 */}
{error && (
<p
className="mt-xs text-xs"
style={{ color: "var(--color-danger)" }}
role="alert"
>
{error}
</p>
)}
{/* 缩略图网格 */}
{value.length > 0 && (
<ul className="mt-sm grid grid-cols-3 sm:grid-cols-5 gap-sm" aria-label="已上传图片列表">
{value.map((img, i) => (
<li
key={i}
className="relative group rounded border border-rule overflow-hidden"
style={{ aspectRatio: "1 / 1" }}
>
<img
src={img}
alt={`已上传图片 ${i + 1}`}
className="w-full h-full object-cover cursor-pointer"
onClick={() => setPreviewIndex(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setPreviewIndex(i);
}
}}
tabIndex={0}
role="button"
aria-label={`查看图片 ${i + 1} 大图`}
/>
{!disabled && (
<button
type="button"
onClick={() => handleRemove(i)}
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs"
style={{
background: "rgba(0,0,0,0.6)",
color: "var(--color-paper)",
}}
aria-label={`移除图片 ${i + 1}`}
>
×
</button>
)}
</li>
))}
</ul>
)}
{/* 全屏预览 */}
{previewIndex !== null && value[previewIndex] && (
<div
role="dialog"
aria-modal="true"
aria-label="图片预览"
className="fixed inset-0 z-50 flex items-center justify-center p-lg"
style={{ background: "rgba(0,0,0,0.8)" }}
onClick={() => setPreviewIndex(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setPreviewIndex(null);
}}
tabIndex={-1}
>
<img
src={value[previewIndex]}
alt={`图片预览 ${previewIndex + 1}`}
className="max-w-full max-h-full object-contain"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,295 @@
"use client";
/**
* JoinClassModal — 加入班级模态框ai14P5
*
* 通过邀请码加入班级。
* - 6 位邀请码输入(每格独立输入框,自动跳转)
* - 粘贴支持(自动填充 6 格)
* - 提交后调用 useJoinClass mutation
* - 成功 / 失败 / loading 状态反馈
* - A11yrole=dialog + aria-modal + ESC 关闭 + 焦点陷阱
*
* 用于 my-classes 页面"加入班级"按钮触发。
*/
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useJoinClass } from "@/lib/graphql/hooks";
import { cn } from "@/lib/exam-types";
export interface JoinClassModalProps {
/** 是否显示 */
open: boolean;
/** 关闭回调 */
onClose: () => void;
/** 加入成功回调 */
onJoined?: (className: string) => void;
}
const CODE_LENGTH = 6;
export function JoinClassModal({
open,
onClose,
onJoined,
}: JoinClassModalProps): JSX.Element | null {
const [code, setCode] = useState<string[]>(Array(CODE_LENGTH).fill(""));
const [feedback, setFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
const dialogRef = useRef<HTMLDivElement | null>(null);
const { execute: executeJoin, fetching, data, success, errors } = useJoinClass();
// 重置状态
const resetState = useCallback((): void => {
setCode(Array(CODE_LENGTH).fill(""));
setFeedback(null);
}, []);
// 响应 mutation 结果变化
useEffect(() => {
if (!fetching && data !== null) {
if (success && data) {
setFeedback({
type: "success",
message: `已加入班级:${data.className}`,
});
onJoined?.(data.className);
const timer = setTimeout(() => {
onClose();
}, 1500);
return () => clearTimeout(timer);
} else if (!success) {
const errMsg = errors[0]?.message ?? "加入失败";
setFeedback({ type: "error", message: errMsg });
}
}
}, [data, success, errors, fetching, onJoined, onClose]);
// open 变化时重置 + 聚焦第一个输入框
useEffect(() => {
if (open) {
resetState();
const timer = setTimeout(() => {
inputRefs.current[0]?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [open, resetState]);
// ESC 关闭
useEffect(() => {
if (!open) return;
const onKey = (e: globalThis.KeyboardEvent): void => {
if (e.key === "Escape" && !fetching) {
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose, fetching]);
const handleChange = useCallback(
(index: number, value: string): void => {
// 只保留字母数字,转大写
const sanitized = value
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, CODE_LENGTH);
if (sanitized.length === 0) {
setCode((prev) => {
const next = [...prev];
next[index] = "";
return next;
});
return;
}
// 粘贴场景:一次填入多格
if (sanitized.length > 1) {
const next = Array(CODE_LENGTH).fill("");
for (let i = 0; i < sanitized.length && i < CODE_LENGTH; i++) {
next[i] = sanitized[i] ?? "";
}
setCode(next);
const focusIndex = Math.min(sanitized.length, CODE_LENGTH - 1);
inputRefs.current[focusIndex]?.focus();
return;
}
// 单格输入
setCode((prev) => {
const next = [...prev];
next[index] = sanitized;
return next;
});
// 自动跳转下一格
if (index < CODE_LENGTH - 1) {
inputRefs.current[index + 1]?.focus();
}
},
[],
);
const handleKeyDown = useCallback(
(index: number, e: ReactKeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Backspace" && !code[index] && index > 0) {
// 空格按 Backspace 跳回上一格
inputRefs.current[index - 1]?.focus();
}
},
[code],
);
const handleSubmit = useCallback((): void => {
const inviteCode = code.join("");
if (inviteCode.length !== CODE_LENGTH) {
setFeedback({ type: "error", message: "请输入完整的 6 位邀请码" });
return;
}
setFeedback(null);
executeJoin({ inviteCode });
}, [code, executeJoin]);
if (!open) return null;
const fullCode = code.join("");
const isComplete = fullCode.length === CODE_LENGTH;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-md"
style={{ background: "rgba(0,0,0,0.5)" }}
role="button"
tabIndex={-1}
aria-label="关闭模态框"
onClick={(e) => {
if (e.target === e.currentTarget && !fetching) onClose();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="join-class-title"
className="w-full max-w-md card-paper p-lg"
style={{ background: "var(--color-paper)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-md flex items-center justify-between">
<h3
id="join-class-title"
className="font-serif text-xl"
style={{ color: "var(--color-ink)" }}
>
</h3>
<button
type="button"
onClick={() => !fetching && onClose()}
className="p-1 rounded hover:bg-accent-muted"
style={{ color: "var(--color-ink-muted)" }}
aria-label="关闭"
disabled={fetching}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<p
className="mb-md text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
6
</p>
{/* 邀请码输入 */}
<div
className="mb-md flex justify-between gap-xs"
role="group"
aria-label="邀请码输入"
>
{code.map((digit, i) => (
<input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
value={digit}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
disabled={fetching}
maxLength={1}
className={cn(
"w-11 h-12 text-center font-serif text-xl rounded border",
digit ? "border-accent" : "border-rule",
)}
style={{
background: "var(--color-paper)",
color: "var(--color-ink)",
}}
aria-label={`邀请码第 ${i + 1}`}
inputMode="text"
autoComplete="off"
/>
))}
</div>
{/* 反馈 */}
{feedback && (
<p
className="mb-md text-sm"
role="alert"
style={{
color:
feedback.type === "success"
? "var(--color-success)"
: "var(--color-danger)",
}}
>
{feedback.message}
</p>
)}
{/* 操作按钮 */}
<div className="flex justify-end gap-sm">
<button
type="button"
onClick={() => onClose()}
disabled={fetching}
className="btn-secondary"
>
</button>
<button
type="button"
onClick={() => handleSubmit()}
disabled={!isComplete || fetching}
className="btn-primary"
>
{fetching ? "加入中…" : "加入班级"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,283 @@
"use client";
/**
* TeacherAnnotationOverlay — 教师批注覆盖层ai14P5
*
* 在学生答案上方展示教师的批注信息:
* - 批注类型comment评论/ highlight高亮/ correction纠错
* - 每条批注包含:教师名、批注内容、批注时间
* - 支持折叠/展开
*
* 用于 my-exams/[id]/result 页面,展示教师对学生作答的反馈。
*/
import { useState, useCallback } from "react";
import { cn } from "@/lib/exam-types";
/** 批注类型 */
export type AnnotationType = "comment" | "highlight" | "correction";
/** 教师批注数据 */
export interface TeacherAnnotation {
/** 批注 ID */
id: string;
/** 关联题目 ID */
questionId: string;
/** 批注类型 */
type: AnnotationType;
/** 教师姓名 */
teacherName: string;
/** 批注内容 */
content: string;
/** 批注时间ISO 8601 */
createdAt: string;
/** 高亮起始位置(字符偏移,仅 highlight 类型) */
startOffset?: number;
/** 高亮结束位置(字符偏移,仅 highlight 类型) */
endOffset?: number;
}
export interface TeacherAnnotationOverlayProps {
/** 当前题目 ID */
questionId: string;
/** 学生答案文本(用于高亮定位) */
answerText?: string;
/** 该题目的批注列表 */
annotations: TeacherAnnotation[];
/** 默认展开(默认 true */
defaultExpanded?: boolean;
}
const TYPE_LABEL: Record<AnnotationType, string> = {
comment: "评论",
highlight: "高亮",
correction: "纠错",
};
/** 批注类型对应的颜色令牌 */
function typeColorVar(type: AnnotationType): string {
switch (type) {
case "correction":
return "var(--color-danger)";
case "highlight":
return "var(--color-warning)";
case "comment":
default:
return "var(--color-info)";
}
}
/** 批注类型对应的 badge 类名 */
function typeBadgeClass(type: AnnotationType): string {
switch (type) {
case "correction":
return "badge-danger";
case "highlight":
return "badge-warning";
case "comment":
default:
return "badge-info";
}
}
/** 格式化日期 */
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
/** 渲染带高亮的答案文本 */
function renderHighlightedText(
text: string,
annotations: TeacherAnnotation[],
): React.ReactNode {
if (!text) return null;
const highlights = annotations
.filter(
(a) =>
a.type === "highlight" &&
a.startOffset !== undefined &&
a.endOffset !== undefined,
)
.sort((a, b) => (a.startOffset ?? 0) - (b.startOffset ?? 0));
if (highlights.length === 0) {
return <span style={{ color: "var(--color-ink)" }}>{text}</span>;
}
const parts: React.ReactNode[] = [];
let cursor = 0;
highlights.forEach((hl, idx) => {
const start = hl.startOffset ?? 0;
const end = hl.endOffset ?? text.length;
if (start > cursor) {
parts.push(
<span key={`text-${idx}`} style={{ color: "var(--color-ink)" }}>
{text.slice(cursor, start)}
</span>,
);
}
parts.push(
<mark
key={`hl-${idx}`}
className="rounded-sm px-xs"
style={{
background: "var(--color-accent-muted)",
color: "var(--color-ink)",
}}
title={hl.content}
>
{text.slice(start, end)}
</mark>,
);
cursor = end;
});
if (cursor < text.length) {
parts.push(
<span key="text-end" style={{ color: "var(--color-ink)" }}>
{text.slice(cursor)}
</span>,
);
}
return parts;
}
export function TeacherAnnotationOverlay({
questionId,
answerText,
annotations,
defaultExpanded = true,
}: TeacherAnnotationOverlayProps): JSX.Element | null {
const [expanded, setExpanded] = useState(defaultExpanded);
const toggle = useCallback((): void => setExpanded((v) => !v), []);
// 筛选当前题目的批注
const questionAnnotations = annotations.filter(
(a) => a.questionId === questionId,
);
if (questionAnnotations.length === 0) return null;
const hasHighlight = questionAnnotations.some((a) => a.type === "highlight");
const nonHighlightAnnotations = questionAnnotations.filter(
(a) => a.type !== "highlight",
);
return (
<section
className="mt-md mark-left"
aria-label="教师批注"
role="region"
>
{/* 标题栏 */}
<div className="flex items-center justify-between">
<h4
className="font-serif text-sm flex items-center gap-sm"
style={{ color: "var(--color-ink)" }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
style={{ color: "var(--color-accent)" }}
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
<span
className="badge badge-info"
aria-label={`${questionAnnotations.length} 条批注`}
>
{questionAnnotations.length}
</span>
</h4>
<button
type="button"
onClick={toggle}
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
aria-expanded={expanded}
aria-controls={`annotation-detail-${questionId}`}
>
{expanded ? "收起" : "展开"}
</button>
</div>
{expanded && (
<div
id={`annotation-detail-${questionId}`}
className="mt-sm space-y-sm"
>
{/* 高亮答案文本 */}
{hasHighlight && answerText && (
<div
className="p-sm rounded text-sm leading-relaxed"
style={{
background: "var(--color-paper)",
border: "1px solid var(--color-rule)",
}}
>
{renderHighlightedText(answerText, questionAnnotations)}
</div>
)}
{/* 批注列表 */}
{nonHighlightAnnotations.map((annotation) => (
<div
key={annotation.id}
className={cn(
"p-sm rounded border",
annotation.type === "correction"
? "border-rule"
: "border-rule",
)}
style={{
borderLeft: `2px solid ${typeColorVar(annotation.type)}`,
background: "var(--color-paper)",
}}
>
<div className="flex items-center gap-sm mb-xs">
<span
className={cn("badge", typeBadgeClass(annotation.type))}
>
{TYPE_LABEL[annotation.type]}
</span>
<span
className="text-xs"
style={{ color: "var(--color-ink-muted)" }}
>
{annotation.teacherName}
</span>
<span
className="text-xs ml-auto"
style={{ color: "var(--color-ink-subtle)" }}
>
{formatDate(annotation.createdAt)}
</span>
</div>
<p
className="text-sm"
style={{ color: "var(--color-ink)" }}
>
{annotation.content}
</p>
</div>
))}
</div>
)}
</section>
);
}

View File

@@ -17,15 +17,23 @@ import type { CombinedError } from "urql";
import type { import type {
ActionError, ActionError,
ActionState, ActionState,
AssignmentAnalysis,
AttendanceRecord, AttendanceRecord,
ChangePasswordInput,
ChangePasswordResult,
ChapterNode, ChapterNode,
ClassInfo, ClassInfo,
CurrentUser, CurrentUser,
ExamDetail, ExamDetail,
ExamListItem, ExamListItem,
GradeRecord, GradeRecord,
GrowthDataPoint,
HomeworkDetail, HomeworkDetail,
HomeworkListItem, HomeworkListItem,
JoinClassInput,
JoinClassResult,
LeaveClassInput,
LeaveClassResult,
LearningPathNode, LearningPathNode,
MarkAllAsReadInput, MarkAllAsReadInput,
MarkAllAsReadResult, MarkAllAsReadResult,
@@ -38,10 +46,14 @@ import type {
RecordExamViolationResult, RecordExamViolationResult,
RecordPasteEventInput, RecordPasteEventInput,
RecordPasteEventResult, RecordPasteEventResult,
RequestExtensionInput,
RequestExtensionResult,
SaveExamDraftInput, SaveExamDraftInput,
SaveExamDraftResult, SaveExamDraftResult,
ScheduleItem,
ServerTime, ServerTime,
StudentDashboard, StudentDashboard,
StudentProfile,
SubmitExamInput, SubmitExamInput,
SubmitExamResult, SubmitExamResult,
SubmitHomeworkInput, SubmitHomeworkInput,
@@ -51,13 +63,19 @@ import type {
TrendDataPoint, TrendDataPoint,
UpdateNotificationPreferenceInput, UpdateNotificationPreferenceInput,
UpdateNotificationPreferenceResult, UpdateNotificationPreferenceResult,
UpdateProfileInput,
UpdateProfileResult,
WeaknessPoint, WeaknessPoint,
} from "./types"; } from "./types";
import { import {
ASSIGNMENT_ANALYSIS_QUERY,
CHANGE_PASSWORD_MUTATION,
CHAPTERS_QUERY, CHAPTERS_QUERY,
CURRENT_USER_QUERY, CURRENT_USER_QUERY,
EXAM_DETAIL_QUERY, EXAM_DETAIL_QUERY,
HOMEWORK_DETAIL_QUERY, HOMEWORK_DETAIL_QUERY,
JOIN_CLASS_MUTATION,
LEAVE_CLASS_MUTATION,
LEARNING_PATH_QUERY, LEARNING_PATH_QUERY,
MARK_ALL_AS_READ_MUTATION, MARK_ALL_AS_READ_MUTATION,
MARK_AS_READ_MUTATION, MARK_AS_READ_MUTATION,
@@ -67,17 +85,22 @@ import {
MY_GRADES_QUERY, MY_GRADES_QUERY,
MY_HOMEWORK_QUERY, MY_HOMEWORK_QUERY,
MY_NOTIFICATIONS_QUERY, MY_NOTIFICATIONS_QUERY,
MY_PROFILE_QUERY,
MY_SCHEDULE_QUERY,
MY_TREND_QUERY, MY_TREND_QUERY,
MY_WEAKNESS_QUERY, MY_WEAKNESS_QUERY,
RECORD_EXAM_VIOLATION_MUTATION, RECORD_EXAM_VIOLATION_MUTATION,
RECORD_PASTE_EVENT_MUTATION, RECORD_PASTE_EVENT_MUTATION,
REQUEST_EXTENSION_MUTATION,
SAVE_EXAM_DRAFT_MUTATION, SAVE_EXAM_DRAFT_MUTATION,
SERVER_TIME_QUERY, SERVER_TIME_QUERY,
STUDENT_DASHBOARD_QUERY, STUDENT_DASHBOARD_QUERY,
STUDENT_GROWTH_QUERY,
SUBMIT_EXAM_MUTATION, SUBMIT_EXAM_MUTATION,
SUBMIT_HOMEWORK_MUTATION, SUBMIT_HOMEWORK_MUTATION,
TEXTBOOKS_QUERY, TEXTBOOKS_QUERY,
UPDATE_NOTIFICATION_PREFERENCE_MUTATION, UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
UPDATE_PROFILE_MUTATION,
} from "./operations"; } from "./operations";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -691,6 +714,215 @@ export function useUpdateNotificationPreference(): UseMutationResult<
}; };
} }
// ---------------------------------------------------------------------------
// 扩展查询 Hooks4 个)
// ---------------------------------------------------------------------------
/** 我的课表(周视图) */
export function useMySchedule(): UseQueryResult<ScheduleItem[]> {
const [result, reexecute] = useQuery<{
mySchedule: ActionState<ScheduleItem[]>;
}>({ query: MY_SCHEDULE_QUERY });
const state = result.data?.mySchedule;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 学生成长曲线vs 班级平均) */
export function useStudentGrowth(): UseQueryResult<GrowthDataPoint[]> {
const [result, reexecute] = useQuery<{
studentGrowth: ActionState<GrowthDataPoint[]>;
}>({ query: STUDENT_GROWTH_QUERY });
const state = result.data?.studentGrowth;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 作业分析 */
export function useAssignmentAnalysis(
homeworkId: string,
): UseQueryResult<AssignmentAnalysis> {
const [result, reexecute] = useQuery<{
assignmentAnalysis: ActionState<AssignmentAnalysis>;
}>({
query: ASSIGNMENT_ANALYSIS_QUERY,
variables: { homeworkId },
});
const state = result.data?.assignmentAnalysis;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
/** 学生个人资料 */
export function useMyProfile(): UseQueryResult<StudentProfile> {
const [result, reexecute] = useQuery<{
myProfile: ActionState<StudentProfile>;
}>({ query: MY_PROFILE_QUERY });
const state = result.data?.myProfile;
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
stale: result.stale,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
reexecute: (opts?: Record<string, unknown>): void => reexecute(opts),
};
}
// ---------------------------------------------------------------------------
// 扩展变更 Hooks5 个)
// ---------------------------------------------------------------------------
/** 更新个人资料 */
export function useUpdateProfile(): UseMutationResult<
UpdateProfileResult,
UpdateProfileInput
> {
const [result, execute] = useMutation<{
updateProfile: ActionState<UpdateProfileResult>;
}>(UPDATE_PROFILE_MUTATION);
const state = result.data?.updateProfile;
const executeMutation = (input: UpdateProfileInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 修改密码 */
export function useChangePassword(): UseMutationResult<
ChangePasswordResult,
ChangePasswordInput
> {
const [result, execute] = useMutation<{
changePassword: ActionState<ChangePasswordResult>;
}>(CHANGE_PASSWORD_MUTATION);
const state = result.data?.changePassword;
const executeMutation = (input: ChangePasswordInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 作业延期申请 */
export function useRequestExtension(): UseMutationResult<
RequestExtensionResult,
RequestExtensionInput
> {
const [result, execute] = useMutation<{
requestExtension: ActionState<RequestExtensionResult>;
}>(REQUEST_EXTENSION_MUTATION);
const state = result.data?.requestExtension;
const executeMutation = (input: RequestExtensionInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 加入班级 */
export function useJoinClass(): UseMutationResult<
JoinClassResult,
JoinClassInput
> {
const [result, execute] = useMutation<{
joinClass: ActionState<JoinClassResult>;
}>(JOIN_CLASS_MUTATION);
const state = result.data?.joinClass;
const executeMutation = (input: JoinClassInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
/** 退出班级 */
export function useLeaveClass(): UseMutationResult<
LeaveClassResult,
LeaveClassInput
> {
const [result, execute] = useMutation<{
leaveClass: ActionState<LeaveClassResult>;
}>(LEAVE_CLASS_MUTATION);
const state = result.data?.leaveClass;
const executeMutation = (input: LeaveClassInput): void => {
void execute({ input });
};
return {
data: unwrapData(state),
fetching: result.fetching,
error: result.error,
success: getSuccess(state),
errors: getErrors(state),
degraded: getDegraded(state),
degradedReason: getDegradedReason(state),
execute: executeMutation,
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 类型导出(供响应映射使用) // 类型导出(供响应映射使用)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -30,8 +30,12 @@ export type {
ActionExtensions, ActionExtensions,
ActionState, ActionState,
AnswerInput, AnswerInput,
AssignmentAnalysis,
AssignmentAnalysisQuestion,
AttendanceRecord, AttendanceRecord,
AttendanceStatus, AttendanceStatus,
ChangePasswordInput,
ChangePasswordResult,
ChapterNode, ChapterNode,
ClassInfo, ClassInfo,
CurrentUser, CurrentUser,
@@ -44,10 +48,15 @@ export type {
ExamQuestion, ExamQuestion,
ExamStatus, ExamStatus,
GradeRecord, GradeRecord,
GrowthDataPoint,
HomeworkDetail, HomeworkDetail,
HomeworkListItem, HomeworkListItem,
HomeworkQuestion, HomeworkQuestion,
HomeworkStatus, HomeworkStatus,
JoinClassInput,
JoinClassResult,
LeaveClassInput,
LeaveClassResult,
LearningPathNode, LearningPathNode,
LearningPathStatus, LearningPathStatus,
MarkAllAsReadInput, MarkAllAsReadInput,
@@ -68,10 +77,14 @@ export type {
RecordExamViolationResult, RecordExamViolationResult,
RecordPasteEventInput, RecordPasteEventInput,
RecordPasteEventResult, RecordPasteEventResult,
RequestExtensionInput,
RequestExtensionResult,
SaveExamDraftInput, SaveExamDraftInput,
SaveExamDraftResult, SaveExamDraftResult,
ScheduleItem,
ServerTime, ServerTime,
StudentDashboard, StudentDashboard,
StudentProfile,
SubjectRef, SubjectRef,
SubmitExamInput, SubmitExamInput,
SubmitExamResult, SubmitExamResult,
@@ -81,6 +94,8 @@ export type {
TrendDataPoint, TrendDataPoint,
UpdateNotificationPreferenceInput, UpdateNotificationPreferenceInput,
UpdateNotificationPreferenceResult, UpdateNotificationPreferenceResult,
UpdateProfileInput,
UpdateProfileResult,
WeaknessPoint, WeaknessPoint,
} from "./types"; } from "./types";
@@ -88,10 +103,14 @@ export { ERROR_CODES, ERROR_CODE_PREFIX, getErrorI18nPrefix } from "./types";
// operations // operations
export { export {
ASSIGNMENT_ANALYSIS_QUERY,
CHANGE_PASSWORD_MUTATION,
CHAPTERS_QUERY, CHAPTERS_QUERY,
CURRENT_USER_QUERY, CURRENT_USER_QUERY,
EXAM_DETAIL_QUERY, EXAM_DETAIL_QUERY,
HOMEWORK_DETAIL_QUERY, HOMEWORK_DETAIL_QUERY,
JOIN_CLASS_MUTATION,
LEAVE_CLASS_MUTATION,
LEARNING_PATH_QUERY, LEARNING_PATH_QUERY,
MARK_ALL_AS_READ_MUTATION, MARK_ALL_AS_READ_MUTATION,
MARK_AS_READ_MUTATION, MARK_AS_READ_MUTATION,
@@ -101,25 +120,34 @@ export {
MY_GRADES_QUERY, MY_GRADES_QUERY,
MY_HOMEWORK_QUERY, MY_HOMEWORK_QUERY,
MY_NOTIFICATIONS_QUERY, MY_NOTIFICATIONS_QUERY,
MY_PROFILE_QUERY,
MY_SCHEDULE_QUERY,
MY_TREND_QUERY, MY_TREND_QUERY,
MY_WEAKNESS_QUERY, MY_WEAKNESS_QUERY,
RECORD_EXAM_VIOLATION_MUTATION, RECORD_EXAM_VIOLATION_MUTATION,
RECORD_PASTE_EVENT_MUTATION, RECORD_PASTE_EVENT_MUTATION,
REQUEST_EXTENSION_MUTATION,
SAVE_EXAM_DRAFT_MUTATION, SAVE_EXAM_DRAFT_MUTATION,
SERVER_TIME_QUERY, SERVER_TIME_QUERY,
STUDENT_DASHBOARD_QUERY, STUDENT_DASHBOARD_QUERY,
STUDENT_GROWTH_QUERY,
SUBMIT_EXAM_MUTATION, SUBMIT_EXAM_MUTATION,
SUBMIT_HOMEWORK_MUTATION, SUBMIT_HOMEWORK_MUTATION,
TEXTBOOKS_QUERY, TEXTBOOKS_QUERY,
UPDATE_NOTIFICATION_PREFERENCE_MUTATION, UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
UPDATE_PROFILE_MUTATION,
} from "./operations"; } from "./operations";
// hooks // hooks
export { export {
useAssignmentAnalysis,
useChangePassword,
useChapters, useChapters,
useCurrentUser, useCurrentUser,
useExamDetail, useExamDetail,
useHomeworkDetail, useHomeworkDetail,
useJoinClass,
useLeaveClass,
useLearningPath, useLearningPath,
useMarkAllAsRead, useMarkAllAsRead,
useMarkAsRead, useMarkAsRead,
@@ -129,17 +157,22 @@ export {
useMyGrades, useMyGrades,
useMyHomework, useMyHomework,
useMyNotifications, useMyNotifications,
useMyProfile,
useMySchedule,
useMyTrend, useMyTrend,
useMyWeakness, useMyWeakness,
useRecordExamViolation, useRecordExamViolation,
useRecordPasteEvent, useRecordPasteEvent,
useRequestExtension,
useSaveExamDraft, useSaveExamDraft,
useServerTime, useServerTime,
useStudentDashboard, useStudentDashboard,
useStudentGrowth,
useSubmitExam, useSubmitExam,
useSubmitHomework, useSubmitHomework,
useTextbooks, useTextbooks,
useUpdateNotificationPreference, useUpdateNotificationPreference,
useUpdateProfile,
} from "./hooks"; } from "./hooks";
export type { UseMutationResult, UseQueryResult } from "./hooks"; export type { UseMutationResult, UseQueryResult } from "./hooks";

View File

@@ -714,3 +714,251 @@ export const UPDATE_NOTIFICATION_PREFERENCE_MUTATION = gql`
} }
} }
`; `;
// ===========================================================================
// 扩展操作4 查询 + 5 变更)
// ===========================================================================
/** 我的课表(周视图) */
export const MY_SCHEDULE_QUERY = gql`
query mySchedule {
mySchedule {
success
errors {
code
message
field
}
data {
id
dayOfWeek
startTime
endTime
subject
teacher
classroom
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 学生成长曲线vs 班级平均) */
export const STUDENT_GROWTH_QUERY = gql`
query studentGrowth {
studentGrowth {
success
errors {
code
message
field
}
data {
date
myScore
classAverage
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 作业分析 */
export const ASSIGNMENT_ANALYSIS_QUERY = gql`
query assignmentAnalysis($homeworkId: ID!) {
assignmentAnalysis(homeworkId: $homeworkId) {
success
errors {
code
message
field
}
data {
homeworkId
title
totalScore
avgScore
maxScore
minScore
submitCount
questions {
questionId
questionText
questionType
correctRate
referenceAnswer
knowledgePoints
}
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 学生个人资料 */
export const MY_PROFILE_QUERY = gql`
query myProfile {
myProfile {
success
errors {
code
message
field
}
data {
id
name
studentNo
email
phone
gender
avatar
grade
classId
className
bio
preferredSubjectId
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 更新个人资料 */
export const UPDATE_PROFILE_MUTATION = gql`
mutation updateProfile($input: UpdateProfileInput!) {
updateProfile(input: $input) {
success
errors {
code
message
field
}
data {
id
name
email
phone
gender
bio
preferredSubjectId
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 修改密码 */
export const CHANGE_PASSWORD_MUTATION = gql`
mutation changePassword($input: ChangePasswordInput!) {
changePassword(input: $input) {
success
errors {
code
message
field
}
data {
ok
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 作业延期申请 */
export const REQUEST_EXTENSION_MUTATION = gql`
mutation requestExtension($input: RequestExtensionInput!) {
requestExtension(input: $input) {
success
errors {
code
message
field
}
data {
homeworkId
approved
newDueDate
reason
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 加入班级 */
export const JOIN_CLASS_MUTATION = gql`
mutation joinClass($input: JoinClassInput!) {
joinClass(input: $input) {
success
errors {
code
message
field
}
data {
classId
className
grade
inviteCode
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;
/** 退出班级 */
export const LEAVE_CLASS_MUTATION = gql`
mutation leaveClass($input: LeaveClassInput!) {
leaveClass(input: $input) {
success
errors {
code
message
field
}
data {
classId
ok
}
extensions {
degraded
degradedReason
traceId
}
}
}
`;

View File

@@ -336,6 +336,69 @@ export interface ServerTime {
offset: number; offset: number;
} }
// ---------------------------------------------------------------------------
// 扩展实体类型4 查询 + 5 变更)
// ---------------------------------------------------------------------------
/** 课表项(周视图) */
export interface ScheduleItem {
id: string;
/** 0=周日, 1=周一, ..., 6=周六 */
dayOfWeek: number;
/** "08:00" */
startTime: string;
/** "08:45" */
endTime: string;
subject: string;
teacher: string;
classroom: string;
}
/** 成长曲线数据点myScore vs classAverage */
export interface GrowthDataPoint {
date: string;
myScore: number;
classAverage: number;
}
/** 作业分析题目项 */
export interface AssignmentAnalysisQuestion {
questionId: string;
questionText: string;
questionType: string;
correctRate: number;
referenceAnswer: string;
knowledgePoints: string[];
}
/** 作业分析结果 */
export interface AssignmentAnalysis {
homeworkId: string;
title: string;
totalScore: number;
avgScore: number;
maxScore: number;
minScore: number;
submitCount: number;
questions: AssignmentAnalysisQuestion[];
}
/** 学生个人资料 */
export interface StudentProfile {
id: string;
name: string;
studentNo: string;
email: string;
phone: string;
gender: string;
avatar: string;
grade: string;
classId: string;
className: string;
bio: string;
preferredSubjectId: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mutation 输入类型 // Mutation 输入类型
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -405,6 +468,42 @@ export interface UpdateNotificationPreferenceInput {
enabled: boolean; enabled: boolean;
} }
// ---------------------------------------------------------------------------
// 扩展 Mutation 输入类型
// ---------------------------------------------------------------------------
/** 更新个人资料输入 */
export interface UpdateProfileInput {
name?: string;
email?: string;
phone?: string;
gender?: string;
bio?: string;
preferredSubjectId?: string;
}
/** 修改密码输入 */
export interface ChangePasswordInput {
currentPassword: string;
newPassword: string;
}
/** 作业延期申请输入 */
export interface RequestExtensionInput {
homeworkId: string;
reason: string;
}
/** 加入班级输入 */
export interface JoinClassInput {
inviteCode: string;
}
/** 退出班级输入 */
export interface LeaveClassInput {
classId: string;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mutation 响应数据类型 // Mutation 响应数据类型
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -458,6 +557,48 @@ export interface UpdateNotificationPreferenceResult {
updatedAt: string; updatedAt: string;
} }
// ---------------------------------------------------------------------------
// 扩展 Mutation 响应数据类型
// ---------------------------------------------------------------------------
/** 更新个人资料响应 */
export interface UpdateProfileResult {
id: string;
name: string;
email: string;
phone: string;
gender: string;
bio: string;
preferredSubjectId: string;
}
/** 修改密码响应 */
export interface ChangePasswordResult {
ok: boolean;
}
/** 作业延期申请响应 */
export interface RequestExtensionResult {
homeworkId: string;
approved: boolean;
newDueDate: string;
reason: string;
}
/** 加入班级响应 */
export interface JoinClassResult {
classId: string;
className: string;
grade: string;
inviteCode: string;
}
/** 退出班级响应 */
export interface LeaveClassResult {
classId: string;
ok: boolean;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GraphQL 操作名常量16 查询 + 8 变更) // GraphQL 操作名常量16 查询 + 8 变更)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -479,7 +620,11 @@ export type QueryOperationName =
| "myWeakness" | "myWeakness"
| "myTrend" | "myTrend"
| "myNotifications" | "myNotifications"
| "serverTime"; | "serverTime"
| "mySchedule"
| "studentGrowth"
| "assignmentAnalysis"
| "myProfile";
/** 变更操作名 */ /** 变更操作名 */
export type MutationOperationName = export type MutationOperationName =
@@ -490,7 +635,12 @@ export type MutationOperationName =
| "markAllAsRead" | "markAllAsRead"
| "recordExamViolation" | "recordExamViolation"
| "recordPasteEvent" | "recordPasteEvent"
| "updateNotificationPreference"; | "updateNotificationPreference"
| "updateProfile"
| "changePassword"
| "requestExtension"
| "joinClass"
| "leaveClass";
/** 全部操作名 */ /** 全部操作名 */
export type OperationName = QueryOperationName | MutationOperationName; export type OperationName = QueryOperationName | MutationOperationName;
@@ -521,6 +671,10 @@ export interface QueryResponses {
unreadCount: number; unreadCount: number;
}>; }>;
serverTime: ActionState<ServerTime>; serverTime: ActionState<ServerTime>;
mySchedule: ActionState<ScheduleItem[]>;
studentGrowth: ActionState<GrowthDataPoint[]>;
assignmentAnalysis: ActionState<AssignmentAnalysis>;
myProfile: ActionState<StudentProfile>;
} }
/** 变更响应映射 */ /** 变更响应映射 */
@@ -533,6 +687,11 @@ export interface MutationResponses {
recordExamViolation: ActionState<RecordExamViolationResult>; recordExamViolation: ActionState<RecordExamViolationResult>;
recordPasteEvent: ActionState<RecordPasteEventResult>; recordPasteEvent: ActionState<RecordPasteEventResult>;
updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>; updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>;
updateProfile: ActionState<UpdateProfileResult>;
changePassword: ActionState<ChangePasswordResult>;
requestExtension: ActionState<RequestExtensionResult>;
joinClass: ActionState<JoinClassResult>;
leaveClass: ActionState<LeaveClassResult>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,51 @@
{
"homeworkId": "hw-001",
"title": "数学函数与导数练习",
"totalScore": 100,
"avgScore": 78,
"maxScore": 98,
"minScore": 45,
"submitCount": 42,
"questions": [
{
"questionId": "q-001",
"questionText": "求函数 f(x) = x² + 2x 在 x=1 处的导数值",
"questionType": "short-answer",
"correctRate": 0.85,
"referenceAnswer": "f'(x) = 2x + 2f'(1) = 4",
"knowledgePoints": ["导数定义", "求导法则"]
},
{
"questionId": "q-002",
"questionText": "下列哪个函数是奇函数?",
"questionType": "single-choice",
"correctRate": 0.92,
"referenceAnswer": "f(x) = x³",
"knowledgePoints": ["奇偶函数", "函数性质"]
},
{
"questionId": "q-003",
"questionText": "计算定积分 ∫₀¹ (x² + 1) dx 的值",
"questionType": "short-answer",
"correctRate": 0.71,
"referenceAnswer": "∫₀¹ (x² + 1) dx = [x³/3 + x]₀¹ = 1/3 + 1 = 4/3",
"knowledgePoints": ["定积分", "牛顿-莱布尼茨公式"]
},
{
"questionId": "q-004",
"questionText": "讨论函数 f(x) = x³ - 3x + 1 的单调性与极值",
"questionType": "essay",
"correctRate": 0.54,
"referenceAnswer": "f'(x) = 3x² - 3令 f'(x)=0 得 x=±1。x<-1 或 x>1 时单调递增,-1<x<1 时单调递减。x=-1 处取极大值 f(-1)=3x=1 处取极小值 f(1)=-1",
"knowledgePoints": ["导数应用", "单调性", "极值"]
},
{
"questionId": "q-005",
"questionText": "已知 f(x) = ln(x+1),求 f''(x)",
"questionType": "fill-blank",
"correctRate": 0.68,
"referenceAnswer": "f'(x) = 1/(x+1)f''(x) = -1/(x+1)²",
"knowledgePoints": ["复合函数求导", "高阶导数"]
}
]
}

View File

@@ -0,0 +1,14 @@
{
"id": "student-001",
"name": "张小明",
"studentNo": "2024001",
"email": "zhangxiaoming@edu.cn",
"phone": "138****8888",
"gender": "男",
"avatar": "",
"grade": "高三",
"classId": "cls-001",
"className": "高三(2)班",
"bio": "热爱数学与物理,目标是考取理工类大学。",
"preferredSubjectId": "subj-math"
}

View File

@@ -0,0 +1,272 @@
[
{
"id": "sch-001",
"dayOfWeek": 1,
"startTime": "08:00",
"endTime": "08:45",
"subject": "语文",
"teacher": "王老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-002",
"dayOfWeek": 1,
"startTime": "09:00",
"endTime": "09:45",
"subject": "数学",
"teacher": "李老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-003",
"dayOfWeek": 1,
"startTime": "10:15",
"endTime": "11:00",
"subject": "英语",
"teacher": "张老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-004",
"dayOfWeek": 1,
"startTime": "14:00",
"endTime": "14:45",
"subject": "物理",
"teacher": "陈老师",
"classroom": "物理实验室"
},
{
"id": "sch-005",
"dayOfWeek": 1,
"startTime": "15:00",
"endTime": "15:45",
"subject": "化学",
"teacher": "刘老师",
"classroom": "化学实验室"
},
{
"id": "sch-006",
"dayOfWeek": 1,
"startTime": "16:15",
"endTime": "17:00",
"subject": "体育",
"teacher": "赵老师",
"classroom": "操场"
},
{
"id": "sch-007",
"dayOfWeek": 2,
"startTime": "08:00",
"endTime": "08:45",
"subject": "数学",
"teacher": "李老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-008",
"dayOfWeek": 2,
"startTime": "09:00",
"endTime": "09:45",
"subject": "语文",
"teacher": "王老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-009",
"dayOfWeek": 2,
"startTime": "10:15",
"endTime": "11:00",
"subject": "物理",
"teacher": "陈老师",
"classroom": "物理实验室"
},
{
"id": "sch-010",
"dayOfWeek": 2,
"startTime": "14:00",
"endTime": "14:45",
"subject": "英语",
"teacher": "张老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-011",
"dayOfWeek": 2,
"startTime": "15:00",
"endTime": "15:45",
"subject": "生物",
"teacher": "周老师",
"classroom": "生物实验室"
},
{
"id": "sch-012",
"dayOfWeek": 2,
"startTime": "16:15",
"endTime": "17:00",
"subject": "历史",
"teacher": "孙老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-013",
"dayOfWeek": 3,
"startTime": "08:00",
"endTime": "08:45",
"subject": "英语",
"teacher": "张老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-014",
"dayOfWeek": 3,
"startTime": "09:00",
"endTime": "09:45",
"subject": "数学",
"teacher": "李老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-015",
"dayOfWeek": 3,
"startTime": "10:15",
"endTime": "11:00",
"subject": "化学",
"teacher": "刘老师",
"classroom": "化学实验室"
},
{
"id": "sch-016",
"dayOfWeek": 3,
"startTime": "14:00",
"endTime": "14:45",
"subject": "语文",
"teacher": "王老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-017",
"dayOfWeek": 3,
"startTime": "15:00",
"endTime": "15:45",
"subject": "地理",
"teacher": "吴老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-018",
"dayOfWeek": 3,
"startTime": "16:15",
"endTime": "17:00",
"subject": "政治",
"teacher": "郑老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-019",
"dayOfWeek": 4,
"startTime": "08:00",
"endTime": "08:45",
"subject": "物理",
"teacher": "陈老师",
"classroom": "物理实验室"
},
{
"id": "sch-020",
"dayOfWeek": 4,
"startTime": "09:00",
"endTime": "09:45",
"subject": "数学",
"teacher": "李老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-021",
"dayOfWeek": 4,
"startTime": "10:15",
"endTime": "11:00",
"subject": "语文",
"teacher": "王老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-022",
"dayOfWeek": 4,
"startTime": "14:00",
"endTime": "14:45",
"subject": "英语",
"teacher": "张老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-023",
"dayOfWeek": 4,
"startTime": "15:00",
"endTime": "15:45",
"subject": "美术",
"teacher": "钱老师",
"classroom": "美术教室"
},
{
"id": "sch-024",
"dayOfWeek": 4,
"startTime": "16:15",
"endTime": "17:00",
"subject": "信息技术",
"teacher": "冯老师",
"classroom": "计算机教室"
},
{
"id": "sch-025",
"dayOfWeek": 5,
"startTime": "08:00",
"endTime": "08:45",
"subject": "数学",
"teacher": "李老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-026",
"dayOfWeek": 5,
"startTime": "09:00",
"endTime": "09:45",
"subject": "英语",
"teacher": "张老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-027",
"dayOfWeek": 5,
"startTime": "10:15",
"endTime": "11:00",
"subject": "生物",
"teacher": "周老师",
"classroom": "生物实验室"
},
{
"id": "sch-028",
"dayOfWeek": 5,
"startTime": "14:00",
"endTime": "14:45",
"subject": "语文",
"teacher": "王老师",
"classroom": "高三(2)班教室"
},
{
"id": "sch-029",
"dayOfWeek": 5,
"startTime": "15:00",
"endTime": "15:45",
"subject": "音乐",
"teacher": "蒋老师",
"classroom": "音乐教室"
},
{
"id": "sch-030",
"dayOfWeek": 5,
"startTime": "16:15",
"endTime": "17:00",
"subject": "班会",
"teacher": "李老师",
"classroom": "高三(2)班教室"
}
]

View File

@@ -0,0 +1,12 @@
[
{ "date": "2026-07-01", "myScore": 82, "classAverage": 78 },
{ "date": "2026-07-05", "myScore": 85, "classAverage": 79 },
{ "date": "2026-07-10", "myScore": 88, "classAverage": 80 },
{ "date": "2026-07-15", "myScore": 86, "classAverage": 81 },
{ "date": "2026-07-20", "myScore": 90, "classAverage": 82 },
{ "date": "2026-07-25", "myScore": 92, "classAverage": 83 },
{ "date": "2026-07-30", "myScore": 89, "classAverage": 82 },
{ "date": "2026-08-04", "myScore": 91, "classAverage": 84 },
{ "date": "2026-08-09", "myScore": 94, "classAverage": 85 },
{ "date": "2026-08-14", "myScore": 95, "classAverage": 86 }
]

View File

@@ -36,6 +36,10 @@ import myWeaknessFixture from "./fixtures/myWeakness.json";
import myTrendFixture from "./fixtures/myTrend.json"; import myTrendFixture from "./fixtures/myTrend.json";
import myNotificationsFixture from "./fixtures/myNotifications.json"; import myNotificationsFixture from "./fixtures/myNotifications.json";
import serverTimeFixture from "./fixtures/serverTime.json"; import serverTimeFixture from "./fixtures/serverTime.json";
import myScheduleFixture from "./fixtures/mySchedule.json";
import studentGrowthFixture from "./fixtures/studentGrowth.json";
import assignmentAnalysisFixture from "./fixtures/assignmentAnalysis.json";
import myProfileFixture from "./fixtures/myProfile.json";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GraphQL 请求/响应类型 // GraphQL 请求/响应类型
@@ -253,6 +257,24 @@ function handleQuery(
}), }),
); );
case "mySchedule":
return graphqlOk("mySchedule", ok(myScheduleFixture));
case "studentGrowth":
return graphqlOk("studentGrowth", ok(studentGrowthFixture));
case "assignmentAnalysis": {
const homeworkId = variables["homeworkId"] ?? "hw-001";
const detail =
homeworkId === assignmentAnalysisFixture.homeworkId
? assignmentAnalysisFixture
: { ...assignmentAnalysisFixture, homeworkId: homeworkId as string };
return graphqlOk("assignmentAnalysis", ok(detail));
}
case "myProfile":
return graphqlOk("myProfile", ok(myProfileFixture));
default: { default: {
// 未覆盖的查询操作名,返回错误 // 未覆盖的查询操作名,返回错误
return graphqlError( return graphqlError(
@@ -350,6 +372,67 @@ function handleMutation(
return graphqlOk("updateNotificationPreference", ok(result)); return graphqlOk("updateNotificationPreference", ok(result));
} }
case "updateProfile": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const result = {
id: myProfileFixture.id,
name: (input["name"] as string) ?? myProfileFixture.name,
email: (input["email"] as string) ?? myProfileFixture.email,
phone: (input["phone"] as string) ?? myProfileFixture.phone,
gender: (input["gender"] as string) ?? myProfileFixture.gender,
bio: (input["bio"] as string) ?? myProfileFixture.bio,
preferredSubjectId:
(input["preferredSubjectId"] as string) ??
myProfileFixture.preferredSubjectId,
};
return graphqlOk("updateProfile", ok(result));
}
case "changePassword": {
return graphqlOk("changePassword", ok({ ok: true }));
}
case "requestExtension": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const homeworkId =
(input["homeworkId"] as string) ?? "hw-001";
const reason = (input["reason"] as string) ?? "";
const newDue = new Date();
newDue.setDate(newDue.getDate() + 2);
const result = {
homeworkId,
approved: true,
newDueDate: newDue.toISOString(),
reason,
};
return graphqlOk("requestExtension", ok(result));
}
case "joinClass": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const inviteCode = (input["inviteCode"] as string) ?? "";
if (inviteCode.length !== 6) {
return graphqlOk(
"joinClass",
fail(ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED, "邀请码必须为 6 位"),
);
}
const result = {
classId: `class-${Date.now()}`,
className: "高三(3)班",
grade: "高三",
inviteCode,
};
return graphqlOk("joinClass", ok(result));
}
case "leaveClass": {
const input = (variables["input"] ?? {}) as Record<string, unknown>;
const classId = (input["classId"] as string) ?? "";
const result = { classId, ok: true };
return graphqlOk("leaveClass", ok(result));
}
default: { default: {
// 未覆盖的变更操作名,返回错误 // 未覆盖的变更操作名,返回错误
return graphqlError( return graphqlError(
@@ -382,6 +465,10 @@ const QUERY_NAMES = new Set<QueryOperationName>([
"myTrend", "myTrend",
"myNotifications", "myNotifications",
"serverTime", "serverTime",
"mySchedule",
"studentGrowth",
"assignmentAnalysis",
"myProfile",
]); ]);
/** 变更操作名集合 */ /** 变更操作名集合 */
@@ -394,6 +481,11 @@ const MUTATION_NAMES = new Set<MutationOperationName>([
"recordExamViolation", "recordExamViolation",
"recordPasteEvent", "recordPasteEvent",
"updateNotificationPreference", "updateNotificationPreference",
"updateProfile",
"changePassword",
"requestExtension",
"joinClass",
"leaveClass",
]); ]);
/** /**
@@ -475,6 +567,10 @@ export const fixtures = {
myTrend: myTrendFixture, myTrend: myTrendFixture,
myNotifications: myNotificationsFixture, myNotifications: myNotificationsFixture,
serverTime: serverTimeFixture, serverTime: serverTimeFixture,
mySchedule: myScheduleFixture,
studentGrowth: studentGrowthFixture,
assignmentAnalysis: assignmentAnalysisFixture,
myProfile: myProfileFixture,
}; };
/** 导出工具函数供测试使用 */ /** 导出工具函数供测试使用 */

View File

@@ -299,46 +299,7 @@ gantt
- **文档阶段**:✅ 完成01-understanding + 02-architecture-design + contract + workline + issue 全部对齐 ARB-019 裁决) - **文档阶段**:✅ 完成01-understanding + 02-architecture-design + contract + workline + issue 全部对齐 ARB-019 裁决)
- **代码实现阶段**:✅ 已完成(通过 `606acb6` 合并到 main包含 P2-P6 全部代码) - **代码实现阶段**:✅ 已完成(通过 `606acb6` 合并到 main包含 P2-P6 全部代码)
- **集成测试阶段**:✅ 已完成(通过 `61d8249` + `a6b0c54` 修复全部集成测试 bug - **集成测试阶段**:✅ 已完成(通过 `61d8249` + `a6b0c54` 修复全部集成测试 bug
- **待办**参考项目差距分析(见 §9 - **待办**无未完成的架构工作项contract.md + workline.md + 02-architecture-design.md 全部对齐 ARB-019 裁决
---
## §9 参考项目差距分析与实现安排2026-07-13
### §9.1 参考项目功能清单
参考项目 `E:\Desktop\edunexus-pro (4)`Next.js 生产级实现)中学生端已实现的功能:
| # | 功能 | 参考项目实现 | 当前项目状态 |
|---|------|-------------|-------------|
| 1 | 学生仪表盘 | 4 统计卡 + 成长曲线 vs 班级平均 + 能力雷达 + 薄弱知识点 + 通知列表 + 近期作业 | ✅ 已实现(缺今日课表 + 班级平均对比) |
| 2 | 学生作业列表 | 多维筛选(学科/考试类型/状态)+ 延期申请 + 继续答题 | ✅ 已实现(缺多维筛选 + 延期申请) |
| 3 | 学生考试作答 | 文本/手写板/图片三种作答 + localStorage 自动保存 + 答题卡树形结构 | ✅ 已实现(缺手写板 + 图片上传 + 树形答题卡) |
| 4 | 学生考试结果 | 教师批注 canvas 叠加 + 手写作答渲染 + 全局/单题批注切换 | ✅ 已实现(缺教师批注叠加) |
| 5 | 学生班级 | 加入班级6 位邀请码)+ 退出班级 + 仅看成员 | ✅ 已实现(缺加入/退出班级) |
| 6 | 学生课表 | 周课表网格视图(学生只读) | ❌ 未实现 |
| 7 | 消息中心 | 全部/未读筛选 + 消息详情 + 标记已读 | ✅ 已实现(通知中心) |
| 8 | 个人设置 | 个人资料 + 偏好学科 + 安全设置(修改密码) | ❌ 未实现 |
| 9 | 作业分析 | 试卷详解 + 数据总览 + 知识点定位 | ❌ 未实现 |
| 10 | 手写板作答 | Fabric.js Canvas 手写 + 自动保存 | ❌ 未实现 |
| 11 | 图片上传作答 | FileReader → dataURL + 上传到服务器 | ❌ 未实现 |
| 12 | 作业延期申请 | 弹出 prompt 输入理由 → API 调用 | ❌ 未实现 |
### §9.2 差距实现安排
| 批次 | 功能 | 优先级 | 实现方式 |
|------|------|--------|----------|
| A | 手写板组件HandwritingPad | 高 | 新建 `src/components/handwriting-pad.tsx`Fabric.js Canvas |
| A | 图片上传作答 | 高 | 考试作答页 + 作业提交页增加图片上传 |
| A | 教师批注叠加显示 | 高 | 考试结果页增加 canvas 批注渲染 |
| A | 作业延期申请 | 高 | 作业列表页增加延期申请按钮 + mutation |
| A | 加入/退出班级 | 高 | 班级页增加 JoinClassModal + leaveClass |
| B | 课表查看页 `/schedule` | 中 | 新建页面 + GraphQL query |
| B | 个人设置页 `/settings` | 中 | 新建页面 + profile mutation |
| B | 作业分析页 `/my-homework/[id]/analysis` | 中 | 新建页面 + GraphQL query |
| B | 作业多维筛选 | 中 | 作业列表页增加筛选器 |
| C | Dashboard 增强 | 低 | 添加今日课表卡片 + 成长曲线 vs 班级平均 |
| C | 答题卡树形结构 | 低 | 考试作答页答题卡改为树形 |
--- ---