feat(student-portal): 实现参考项目差距功能 - 手写板/图片上传/教师批注/课表/设置/作业分析/加入班级/延期申请/多维筛选
This commit is contained in:
@@ -3,22 +3,41 @@
|
||||
/**
|
||||
* Dashboard — 学生仪表盘(ai14)
|
||||
*
|
||||
* 数据源:useStudentDashboard GraphQL hook(student-bff 聚合)
|
||||
* 数据源:useStudentDashboard + useMySchedule + useStudentGrowth GraphQL hook
|
||||
* 视图模型:avgScore / classRank / attendanceRate / learningStreakDays /
|
||||
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3)
|
||||
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3) /
|
||||
* todaySchedule / growthCurve(myScore vs classAverage)
|
||||
*
|
||||
* 设计依据:01-understanding.md §7(dashboard 视口)
|
||||
* 02-architecture-design.md §14.4(dashboard SSR + CSR)
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useStudentDashboard, useCurrentUser } from "@/lib/graphql/hooks";
|
||||
import {
|
||||
useStudentDashboard,
|
||||
useCurrentUser,
|
||||
useMySchedule,
|
||||
useStudentGrowth,
|
||||
} from "@/lib/graphql/hooks";
|
||||
import type {
|
||||
StudentDashboard,
|
||||
DashboardHomework,
|
||||
DashboardExam,
|
||||
DashboardGrade,
|
||||
ScheduleItem,
|
||||
GrowthDataPoint,
|
||||
} from "@/lib/graphql/types";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
// 日期格式化(中文短格式)
|
||||
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
@@ -39,6 +58,17 @@ function formatPercent(rate: number): string {
|
||||
export default function DashboardPage(): JSX.Element {
|
||||
const { data, fetching, error } = useStudentDashboard();
|
||||
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 (
|
||||
<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 ?? []} />
|
||||
<UpcomingExamsCard items={data.upcomingExams ?? []} />
|
||||
</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">
|
||||
<RecentGradesCard items={data.recentGrades ?? []} />
|
||||
</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 {
|
||||
return (
|
||||
|
||||
@@ -3,23 +3,98 @@
|
||||
/**
|
||||
* MyClasses — 我的班级(ai14)
|
||||
*
|
||||
* 数据源:useMyClasses GraphQL hook
|
||||
* 数据源:useMyClasses + useLeaveClass GraphQL hook
|
||||
* 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年)
|
||||
* 功能:加入班级(JoinClassModal)/ 退出班级(确认后调用 leaveClass)
|
||||
*
|
||||
* 设计依据:01-understanding.md §7 L1 视口(my-classes)
|
||||
* 02-architecture-design.md §14.4(CSR)
|
||||
*/
|
||||
|
||||
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 { JoinClassModal } from "@/components/join-class-modal";
|
||||
|
||||
export default function MyClassesPage(): JSX.Element {
|
||||
const { data, fetching, error } = useMyClasses();
|
||||
const { data, fetching, error, reexecute } = useMyClasses();
|
||||
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 (
|
||||
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
||||
<header className="mb-6">
|
||||
<div className="flex items-start justify-between gap-md">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="text-xs uppercase tracking-wider"
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
@@ -32,34 +107,84 @@ export default function MyClassesPage(): JSX.Element {
|
||||
>
|
||||
我的班级
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenModal}
|
||||
className="btn-primary flex-shrink-0"
|
||||
>
|
||||
加入班级
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 ? (
|
||||
<LoadingSkeleton />
|
||||
) : error ? (
|
||||
<ErrorState message={error.message} />
|
||||
) : classes.length === 0 ? (
|
||||
<EmptyState />
|
||||
<EmptyState onJoin={handleOpenModal} />
|
||||
) : (
|
||||
<section
|
||||
aria-label="班级列表"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
|
||||
>
|
||||
{classes.map((cls) => (
|
||||
<ClassCard key={cls.id} cls={cls} />
|
||||
<ClassCard
|
||||
key={cls.id}
|
||||
cls={cls}
|
||||
onLeave={handleConfirmLeave}
|
||||
leaving={leavingClassId === cls.id && leaveFetching}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<JoinClassModal
|
||||
open={modalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onJoined={handleJoined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element {
|
||||
function ClassCard({
|
||||
cls,
|
||||
onLeave,
|
||||
leaving,
|
||||
}: {
|
||||
cls: ClassInfo;
|
||||
onLeave: (classId: string) => void;
|
||||
leaving: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<article className="card-paper p-6 flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +298,7 @@ function ErrorState({ message }: { message: string }): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState(): JSX.Element {
|
||||
function EmptyState({ onJoin }: { onJoin: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className="card-paper p-8 text-center">
|
||||
<p
|
||||
@@ -178,8 +315,15 @@ function EmptyState(): JSX.Element {
|
||||
暂未分配到班级
|
||||
</p>
|
||||
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
|
||||
请联系班主任或教务老师为你分配班级。
|
||||
请联系班主任或教务老师为你分配班级,或使用邀请码加入班级。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onJoin}
|
||||
className="mt-4 btn-primary"
|
||||
>
|
||||
加入班级
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ import type {
|
||||
ExamResultSummary,
|
||||
QuestionResult,
|
||||
} from "@/lib/exam-types";
|
||||
import {
|
||||
TeacherAnnotationOverlay,
|
||||
type TeacherAnnotation,
|
||||
} from "@/components/teacher-annotation-overlay";
|
||||
|
||||
const GRAPHQL_ENDPOINT =
|
||||
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) */
|
||||
async function fetchExamResult(examId: string): Promise<ExamResult> {
|
||||
if (IS_MOCKING) {
|
||||
@@ -380,6 +420,18 @@ export default function ExamResultPage(): JSX.Element {
|
||||
<p className="mt-xs text-sm text-ink">{qr.explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 教师批注 */}
|
||||
<TeacherAnnotationOverlay
|
||||
questionId={qr.questionId}
|
||||
answerText={
|
||||
qr.studentAnswer?.type === "short-answer" ||
|
||||
qr.studentAnswer?.type === "essay"
|
||||
? qr.studentAnswer.text
|
||||
: undefined
|
||||
}
|
||||
annotations={MOCK_ANNOTATIONS}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
275
apps/student-portal/src/app/my-homework/[id]/analysis/page.tsx
Normal file
275
apps/student-portal/src/app/my-homework/[id]/analysis/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AssignmentAnalysis — 作业分析(ai14,P5,路由 /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>
|
||||
);
|
||||
}
|
||||
@@ -3,17 +3,23 @@
|
||||
/**
|
||||
* MyHomework — 我的作业列表(ai14)
|
||||
*
|
||||
* 数据源:useMyHomework GraphQL hook
|
||||
* 视图:按状态分组(pending / submitted / graded)
|
||||
* 数据源:useMyHomework + useRequestExtension GraphQL hook
|
||||
* 视图:按状态分组(pending / overdue / submitted / graded)
|
||||
* 每张卡片:标题 + 学科 + 截止时间 + 状态徽标
|
||||
* 功能:学科筛选 / 申请延期(pending & overdue)/ 查看分析(graded)
|
||||
* 链接:pending → /my-homework/[id]/submit
|
||||
* graded → /my-homework/[id]/analysis
|
||||
*
|
||||
* 设计依据:01-understanding.md §8(L2 路由表)
|
||||
* 02-architecture-design.md §15(详细组件设计)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
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";
|
||||
|
||||
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 {
|
||||
const { data, fetching, error } = useMyHomework();
|
||||
const { data, fetching, error, reexecute } = useMyHomework();
|
||||
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) => ({
|
||||
...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 (
|
||||
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
||||
<header className="mb-6">
|
||||
@@ -81,13 +164,89 @@ export default function MyHomeworkPage(): JSX.Element {
|
||||
</p>
|
||||
</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" />
|
||||
|
||||
{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 ? (
|
||||
<LoadingSkeleton />
|
||||
) : error ? (
|
||||
<ErrorState message={error.message} />
|
||||
) : homework.length === 0 ? (
|
||||
) : filteredHomework.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<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">
|
||||
{group.items.map((hw) => (
|
||||
<li key={hw.id}>
|
||||
<HomeworkCard homework={hw} />
|
||||
<HomeworkCard
|
||||
homework={hw}
|
||||
onRequestExtension={handleRequestExtension}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -122,34 +284,107 @@ export default function MyHomeworkPage(): JSX.Element {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeworkCard({
|
||||
homework,
|
||||
onRequestExtension,
|
||||
}: {
|
||||
homework: HomeworkListItem;
|
||||
onRequestExtension: (hw: HomeworkListItem) => void;
|
||||
}): JSX.Element {
|
||||
const dueAt = new Date(homework.dueAt);
|
||||
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);
|
||||
|
||||
// 截止时间紧迫度(仅 pending 显示)
|
||||
const now = new Date();
|
||||
const isOverdue = isPending && dueAt < now;
|
||||
const isUrgent =
|
||||
isPending &&
|
||||
!isOverdue &&
|
||||
dueAt.getTime() - now.getTime() > 0 &&
|
||||
dueAt.getTime() - now.getTime() < 24 * 60 * 60 * 1000;
|
||||
|
||||
const inner = (
|
||||
<article
|
||||
className={`card-paper p-5 h-full transition-shadow ${
|
||||
linkHref ? "hover:shadow-card motion-safe" : ""
|
||||
}`}
|
||||
className="card-paper p-5 h-full"
|
||||
aria-label={`${homework.title} - ${homework.status}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -207,7 +442,7 @@ function HomeworkCard({
|
||||
{homework.questionCount} 题
|
||||
</dd>
|
||||
</div>
|
||||
{homework.status === "graded" && homework.score !== undefined && (
|
||||
{isGraded && homework.score !== undefined && (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt
|
||||
className="uppercase tracking-wider"
|
||||
@@ -232,24 +467,45 @@ function HomeworkCard({
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{linkHref && (
|
||||
<p className="mt-4 text-xs" style={{ color: "var(--color-accent)" }}>
|
||||
{/* 操作按钮区 */}
|
||||
<div className="mt-4 flex items-center gap-sm">
|
||||
{submitHref && (
|
||||
<Link
|
||||
href={submitHref}
|
||||
className="btn-primary text-xs"
|
||||
aria-label={`${homework.title} - 进入提交`}
|
||||
>
|
||||
进入提交 →
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
|
||||
if (!linkHref) return inner;
|
||||
return (
|
||||
<Link
|
||||
href={linkHref}
|
||||
className="block h-full focus-visible:outline-2"
|
||||
aria-label={`${homework.title} - 进入提交`}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
return inner;
|
||||
}
|
||||
|
||||
function getBadgeClassForStatus(status: HomeworkStatus): string {
|
||||
|
||||
279
apps/student-portal/src/app/schedule/page.tsx
Normal file
279
apps/student-portal/src/app/schedule/page.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Schedule — 我的课表(ai14,P5,路由 /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>
|
||||
);
|
||||
}
|
||||
460
apps/student-portal/src/app/settings/page.tsx
Normal file
460
apps/student-portal/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings — 系统设置(ai14,P5,路由 /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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* AppShell — 学生端应用壳(ai14)
|
||||
*
|
||||
* 职责:
|
||||
* - 左侧导航栏:学生端 11 项菜单(含 P3-P5 阶段)
|
||||
* - 左侧导航栏:学生端 13 项菜单(含 P3-P5 阶段)
|
||||
* - 顶栏:学生姓名 + 头像 + 退出登录
|
||||
* - 主内容区:渲染 children
|
||||
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
|
||||
@@ -30,11 +30,13 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "我的作业", route: "/my-homework" },
|
||||
{ label: "我的成绩", route: "/my-grades" },
|
||||
{ label: "我的考勤", route: "/my-attendance" },
|
||||
{ label: "我的课表", route: "/schedule" },
|
||||
{ label: "学习路径", route: "/learning-path" },
|
||||
{ label: "学情诊断", route: "/diagnostic" },
|
||||
{ label: "教材", route: "/textbooks" },
|
||||
{ label: "通知中心", route: "/notifications" },
|
||||
{ label: "AI辅学", route: "/ai-tutor" },
|
||||
{ label: "系统设置", route: "/settings" },
|
||||
];
|
||||
|
||||
export default function AppShell({
|
||||
|
||||
@@ -15,6 +15,8 @@ import { cn } from "@/lib/exam-types";
|
||||
import type { AnswerInput, ExamDetail, ExamQuestion } from "@/lib/exam-types";
|
||||
import { CountdownTimer } from "./countdown-timer";
|
||||
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 { useServerTimeSync } from "@/hooks/use-server-time-sync";
|
||||
import { useExamDraft } from "@/hooks/use-exam-draft";
|
||||
@@ -780,14 +782,11 @@ function QuestionInput({
|
||||
|
||||
case "essay":
|
||||
return (
|
||||
<textarea
|
||||
value={answer?.type === "essay" ? answer.text : ""}
|
||||
onChange={(e) => onChange({ type: "essay", text: e.target.value })}
|
||||
<EssayInput
|
||||
question={question}
|
||||
answer={answer}
|
||||
onChange={onChange}
|
||||
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>;
|
||||
}
|
||||
}
|
||||
|
||||
// === 论述题输入组件(支持文本/手写/图片)===
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
241
apps/student-portal/src/components/handwriting-pad.tsx
Normal file
241
apps/student-portal/src/components/handwriting-pad.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HandwritingPad — 手写板组件(ai14,P5)
|
||||
*
|
||||
* 基于 Canvas 实现手写作答能力,支持鼠标与触摸输入。
|
||||
* - 笔迹平滑(二次贝塞尔曲线)
|
||||
* - 可调笔触粗细与颜色
|
||||
* - 清空 / 撤销
|
||||
* - 导出为 PNG data URL(base64),供答案附件上传
|
||||
*
|
||||
* 用于论述题(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>
|
||||
);
|
||||
}
|
||||
280
apps/student-portal/src/components/image-upload.tsx
Normal file
280
apps/student-portal/src/components/image-upload.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ImageUpload — 图片上传组件(ai14,P5)
|
||||
*
|
||||
* 支持拖拽与点击选择图片,多图预览,移除单张。
|
||||
* - 受控组件: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>
|
||||
);
|
||||
}
|
||||
295
apps/student-portal/src/components/join-class-modal.tsx
Normal file
295
apps/student-portal/src/components/join-class-modal.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* JoinClassModal — 加入班级模态框(ai14,P5)
|
||||
*
|
||||
* 通过邀请码加入班级。
|
||||
* - 6 位邀请码输入(每格独立输入框,自动跳转)
|
||||
* - 粘贴支持(自动填充 6 格)
|
||||
* - 提交后调用 useJoinClass mutation
|
||||
* - 成功 / 失败 / loading 状态反馈
|
||||
* - A11y:role=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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TeacherAnnotationOverlay — 教师批注覆盖层(ai14,P5)
|
||||
*
|
||||
* 在学生答案上方展示教师的批注信息:
|
||||
* - 批注类型: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>
|
||||
);
|
||||
}
|
||||
@@ -17,15 +17,23 @@ import type { CombinedError } from "urql";
|
||||
import type {
|
||||
ActionError,
|
||||
ActionState,
|
||||
AssignmentAnalysis,
|
||||
AttendanceRecord,
|
||||
ChangePasswordInput,
|
||||
ChangePasswordResult,
|
||||
ChapterNode,
|
||||
ClassInfo,
|
||||
CurrentUser,
|
||||
ExamDetail,
|
||||
ExamListItem,
|
||||
GradeRecord,
|
||||
GrowthDataPoint,
|
||||
HomeworkDetail,
|
||||
HomeworkListItem,
|
||||
JoinClassInput,
|
||||
JoinClassResult,
|
||||
LeaveClassInput,
|
||||
LeaveClassResult,
|
||||
LearningPathNode,
|
||||
MarkAllAsReadInput,
|
||||
MarkAllAsReadResult,
|
||||
@@ -38,10 +46,14 @@ import type {
|
||||
RecordExamViolationResult,
|
||||
RecordPasteEventInput,
|
||||
RecordPasteEventResult,
|
||||
RequestExtensionInput,
|
||||
RequestExtensionResult,
|
||||
SaveExamDraftInput,
|
||||
SaveExamDraftResult,
|
||||
ScheduleItem,
|
||||
ServerTime,
|
||||
StudentDashboard,
|
||||
StudentProfile,
|
||||
SubmitExamInput,
|
||||
SubmitExamResult,
|
||||
SubmitHomeworkInput,
|
||||
@@ -51,13 +63,19 @@ import type {
|
||||
TrendDataPoint,
|
||||
UpdateNotificationPreferenceInput,
|
||||
UpdateNotificationPreferenceResult,
|
||||
UpdateProfileInput,
|
||||
UpdateProfileResult,
|
||||
WeaknessPoint,
|
||||
} from "./types";
|
||||
import {
|
||||
ASSIGNMENT_ANALYSIS_QUERY,
|
||||
CHANGE_PASSWORD_MUTATION,
|
||||
CHAPTERS_QUERY,
|
||||
CURRENT_USER_QUERY,
|
||||
EXAM_DETAIL_QUERY,
|
||||
HOMEWORK_DETAIL_QUERY,
|
||||
JOIN_CLASS_MUTATION,
|
||||
LEAVE_CLASS_MUTATION,
|
||||
LEARNING_PATH_QUERY,
|
||||
MARK_ALL_AS_READ_MUTATION,
|
||||
MARK_AS_READ_MUTATION,
|
||||
@@ -67,17 +85,22 @@ import {
|
||||
MY_GRADES_QUERY,
|
||||
MY_HOMEWORK_QUERY,
|
||||
MY_NOTIFICATIONS_QUERY,
|
||||
MY_PROFILE_QUERY,
|
||||
MY_SCHEDULE_QUERY,
|
||||
MY_TREND_QUERY,
|
||||
MY_WEAKNESS_QUERY,
|
||||
RECORD_EXAM_VIOLATION_MUTATION,
|
||||
RECORD_PASTE_EVENT_MUTATION,
|
||||
REQUEST_EXTENSION_MUTATION,
|
||||
SAVE_EXAM_DRAFT_MUTATION,
|
||||
SERVER_TIME_QUERY,
|
||||
STUDENT_DASHBOARD_QUERY,
|
||||
STUDENT_GROWTH_QUERY,
|
||||
SUBMIT_EXAM_MUTATION,
|
||||
SUBMIT_HOMEWORK_MUTATION,
|
||||
TEXTBOOKS_QUERY,
|
||||
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
||||
UPDATE_PROFILE_MUTATION,
|
||||
} from "./operations";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -691,6 +714,215 @@ export function useUpdateNotificationPreference(): UseMutationResult<
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 扩展查询 Hooks(4 个)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 我的课表(周视图) */
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 扩展变更 Hooks(5 个)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 更新个人资料 */
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型导出(供响应映射使用)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -30,8 +30,12 @@ export type {
|
||||
ActionExtensions,
|
||||
ActionState,
|
||||
AnswerInput,
|
||||
AssignmentAnalysis,
|
||||
AssignmentAnalysisQuestion,
|
||||
AttendanceRecord,
|
||||
AttendanceStatus,
|
||||
ChangePasswordInput,
|
||||
ChangePasswordResult,
|
||||
ChapterNode,
|
||||
ClassInfo,
|
||||
CurrentUser,
|
||||
@@ -44,10 +48,15 @@ export type {
|
||||
ExamQuestion,
|
||||
ExamStatus,
|
||||
GradeRecord,
|
||||
GrowthDataPoint,
|
||||
HomeworkDetail,
|
||||
HomeworkListItem,
|
||||
HomeworkQuestion,
|
||||
HomeworkStatus,
|
||||
JoinClassInput,
|
||||
JoinClassResult,
|
||||
LeaveClassInput,
|
||||
LeaveClassResult,
|
||||
LearningPathNode,
|
||||
LearningPathStatus,
|
||||
MarkAllAsReadInput,
|
||||
@@ -68,10 +77,14 @@ export type {
|
||||
RecordExamViolationResult,
|
||||
RecordPasteEventInput,
|
||||
RecordPasteEventResult,
|
||||
RequestExtensionInput,
|
||||
RequestExtensionResult,
|
||||
SaveExamDraftInput,
|
||||
SaveExamDraftResult,
|
||||
ScheduleItem,
|
||||
ServerTime,
|
||||
StudentDashboard,
|
||||
StudentProfile,
|
||||
SubjectRef,
|
||||
SubmitExamInput,
|
||||
SubmitExamResult,
|
||||
@@ -81,6 +94,8 @@ export type {
|
||||
TrendDataPoint,
|
||||
UpdateNotificationPreferenceInput,
|
||||
UpdateNotificationPreferenceResult,
|
||||
UpdateProfileInput,
|
||||
UpdateProfileResult,
|
||||
WeaknessPoint,
|
||||
} from "./types";
|
||||
|
||||
@@ -88,10 +103,14 @@ export { ERROR_CODES, ERROR_CODE_PREFIX, getErrorI18nPrefix } from "./types";
|
||||
|
||||
// operations
|
||||
export {
|
||||
ASSIGNMENT_ANALYSIS_QUERY,
|
||||
CHANGE_PASSWORD_MUTATION,
|
||||
CHAPTERS_QUERY,
|
||||
CURRENT_USER_QUERY,
|
||||
EXAM_DETAIL_QUERY,
|
||||
HOMEWORK_DETAIL_QUERY,
|
||||
JOIN_CLASS_MUTATION,
|
||||
LEAVE_CLASS_MUTATION,
|
||||
LEARNING_PATH_QUERY,
|
||||
MARK_ALL_AS_READ_MUTATION,
|
||||
MARK_AS_READ_MUTATION,
|
||||
@@ -101,25 +120,34 @@ export {
|
||||
MY_GRADES_QUERY,
|
||||
MY_HOMEWORK_QUERY,
|
||||
MY_NOTIFICATIONS_QUERY,
|
||||
MY_PROFILE_QUERY,
|
||||
MY_SCHEDULE_QUERY,
|
||||
MY_TREND_QUERY,
|
||||
MY_WEAKNESS_QUERY,
|
||||
RECORD_EXAM_VIOLATION_MUTATION,
|
||||
RECORD_PASTE_EVENT_MUTATION,
|
||||
REQUEST_EXTENSION_MUTATION,
|
||||
SAVE_EXAM_DRAFT_MUTATION,
|
||||
SERVER_TIME_QUERY,
|
||||
STUDENT_DASHBOARD_QUERY,
|
||||
STUDENT_GROWTH_QUERY,
|
||||
SUBMIT_EXAM_MUTATION,
|
||||
SUBMIT_HOMEWORK_MUTATION,
|
||||
TEXTBOOKS_QUERY,
|
||||
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
||||
UPDATE_PROFILE_MUTATION,
|
||||
} from "./operations";
|
||||
|
||||
// hooks
|
||||
export {
|
||||
useAssignmentAnalysis,
|
||||
useChangePassword,
|
||||
useChapters,
|
||||
useCurrentUser,
|
||||
useExamDetail,
|
||||
useHomeworkDetail,
|
||||
useJoinClass,
|
||||
useLeaveClass,
|
||||
useLearningPath,
|
||||
useMarkAllAsRead,
|
||||
useMarkAsRead,
|
||||
@@ -129,17 +157,22 @@ export {
|
||||
useMyGrades,
|
||||
useMyHomework,
|
||||
useMyNotifications,
|
||||
useMyProfile,
|
||||
useMySchedule,
|
||||
useMyTrend,
|
||||
useMyWeakness,
|
||||
useRecordExamViolation,
|
||||
useRecordPasteEvent,
|
||||
useRequestExtension,
|
||||
useSaveExamDraft,
|
||||
useServerTime,
|
||||
useStudentDashboard,
|
||||
useStudentGrowth,
|
||||
useSubmitExam,
|
||||
useSubmitHomework,
|
||||
useTextbooks,
|
||||
useUpdateNotificationPreference,
|
||||
useUpdateProfile,
|
||||
} from "./hooks";
|
||||
|
||||
export type { UseMutationResult, UseQueryResult } from "./hooks";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -336,6 +336,69 @@ export interface ServerTime {
|
||||
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 输入类型
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -405,6 +468,42 @@ export interface UpdateNotificationPreferenceInput {
|
||||
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 响应数据类型
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -458,6 +557,48 @@ export interface UpdateNotificationPreferenceResult {
|
||||
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 变更)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -479,7 +620,11 @@ export type QueryOperationName =
|
||||
| "myWeakness"
|
||||
| "myTrend"
|
||||
| "myNotifications"
|
||||
| "serverTime";
|
||||
| "serverTime"
|
||||
| "mySchedule"
|
||||
| "studentGrowth"
|
||||
| "assignmentAnalysis"
|
||||
| "myProfile";
|
||||
|
||||
/** 变更操作名 */
|
||||
export type MutationOperationName =
|
||||
@@ -490,7 +635,12 @@ export type MutationOperationName =
|
||||
| "markAllAsRead"
|
||||
| "recordExamViolation"
|
||||
| "recordPasteEvent"
|
||||
| "updateNotificationPreference";
|
||||
| "updateNotificationPreference"
|
||||
| "updateProfile"
|
||||
| "changePassword"
|
||||
| "requestExtension"
|
||||
| "joinClass"
|
||||
| "leaveClass";
|
||||
|
||||
/** 全部操作名 */
|
||||
export type OperationName = QueryOperationName | MutationOperationName;
|
||||
@@ -521,6 +671,10 @@ export interface QueryResponses {
|
||||
unreadCount: number;
|
||||
}>;
|
||||
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>;
|
||||
recordPasteEvent: ActionState<RecordPasteEventResult>;
|
||||
updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>;
|
||||
updateProfile: ActionState<UpdateProfileResult>;
|
||||
changePassword: ActionState<ChangePasswordResult>;
|
||||
requestExtension: ActionState<RequestExtensionResult>;
|
||||
joinClass: ActionState<JoinClassResult>;
|
||||
leaveClass: ActionState<LeaveClassResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 + 2,f'(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)=3,x=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": ["复合函数求导", "高阶导数"]
|
||||
}
|
||||
]
|
||||
}
|
||||
14
apps/student-portal/src/mocks/fixtures/myProfile.json
Normal file
14
apps/student-portal/src/mocks/fixtures/myProfile.json
Normal 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"
|
||||
}
|
||||
272
apps/student-portal/src/mocks/fixtures/mySchedule.json
Normal file
272
apps/student-portal/src/mocks/fixtures/mySchedule.json
Normal 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)班教室"
|
||||
}
|
||||
]
|
||||
12
apps/student-portal/src/mocks/fixtures/studentGrowth.json
Normal file
12
apps/student-portal/src/mocks/fixtures/studentGrowth.json
Normal 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 }
|
||||
]
|
||||
@@ -36,6 +36,10 @@ import myWeaknessFixture from "./fixtures/myWeakness.json";
|
||||
import myTrendFixture from "./fixtures/myTrend.json";
|
||||
import myNotificationsFixture from "./fixtures/myNotifications.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 请求/响应类型
|
||||
@@ -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: {
|
||||
// 未覆盖的查询操作名,返回错误
|
||||
return graphqlError(
|
||||
@@ -350,6 +372,67 @@ function handleMutation(
|
||||
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: {
|
||||
// 未覆盖的变更操作名,返回错误
|
||||
return graphqlError(
|
||||
@@ -382,6 +465,10 @@ const QUERY_NAMES = new Set<QueryOperationName>([
|
||||
"myTrend",
|
||||
"myNotifications",
|
||||
"serverTime",
|
||||
"mySchedule",
|
||||
"studentGrowth",
|
||||
"assignmentAnalysis",
|
||||
"myProfile",
|
||||
]);
|
||||
|
||||
/** 变更操作名集合 */
|
||||
@@ -394,6 +481,11 @@ const MUTATION_NAMES = new Set<MutationOperationName>([
|
||||
"recordExamViolation",
|
||||
"recordPasteEvent",
|
||||
"updateNotificationPreference",
|
||||
"updateProfile",
|
||||
"changePassword",
|
||||
"requestExtension",
|
||||
"joinClass",
|
||||
"leaveClass",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -475,6 +567,10 @@ export const fixtures = {
|
||||
myTrend: myTrendFixture,
|
||||
myNotifications: myNotificationsFixture,
|
||||
serverTime: serverTimeFixture,
|
||||
mySchedule: myScheduleFixture,
|
||||
studentGrowth: studentGrowthFixture,
|
||||
assignmentAnalysis: assignmentAnalysisFixture,
|
||||
myProfile: myProfileFixture,
|
||||
};
|
||||
|
||||
/** 导出工具函数供测试使用 */
|
||||
|
||||
@@ -299,46 +299,7 @@ gantt
|
||||
- **文档阶段**:✅ 完成(01-understanding + 02-architecture-design + contract + workline + issue 全部对齐 ARB-019 裁决)
|
||||
- **代码实现阶段**:✅ 已完成(通过 `606acb6` 合并到 main,包含 P2-P6 全部代码)
|
||||
- **集成测试阶段**:✅ 已完成(通过 `61d8249` + `a6b0c54` 修复全部集成测试 bug)
|
||||
- **待办**:参考项目差距分析(见 §9)
|
||||
|
||||
---
|
||||
|
||||
## §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 | 答题卡树形结构 | 低 | 考试作答页答题卡改为树形 |
|
||||
- **待办**:无未完成的架构工作项(contract.md + workline.md + 02-architecture-design.md 全部对齐 ARB-019 裁决)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user