feat(student-portal): 实现参考项目差距功能 - 手写板/图片上传/教师批注/课表/设置/作业分析/加入班级/延期申请/多维筛选
This commit is contained in:
@@ -3,22 +3,41 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard — 学生仪表盘(ai14)
|
* Dashboard — 学生仪表盘(ai14)
|
||||||
*
|
*
|
||||||
* 数据源:useStudentDashboard GraphQL hook(student-bff 聚合)
|
* 数据源:useStudentDashboard + useMySchedule + useStudentGrowth GraphQL hook
|
||||||
* 视图模型:avgScore / classRank / attendanceRate / learningStreakDays /
|
* 视图模型:avgScore / classRank / attendanceRate / learningStreakDays /
|
||||||
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3)
|
* upcomingHomework(3) / upcomingExams(2) / recentGrades(3) /
|
||||||
|
* todaySchedule / growthCurve(myScore vs classAverage)
|
||||||
*
|
*
|
||||||
* 设计依据:01-understanding.md §7(dashboard 视口)
|
* 设计依据:01-understanding.md §7(dashboard 视口)
|
||||||
* 02-architecture-design.md §14.4(dashboard SSR + CSR)
|
* 02-architecture-design.md §14.4(dashboard SSR + CSR)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useStudentDashboard, useCurrentUser } from "@/lib/graphql/hooks";
|
import {
|
||||||
|
useStudentDashboard,
|
||||||
|
useCurrentUser,
|
||||||
|
useMySchedule,
|
||||||
|
useStudentGrowth,
|
||||||
|
} from "@/lib/graphql/hooks";
|
||||||
import type {
|
import type {
|
||||||
StudentDashboard,
|
StudentDashboard,
|
||||||
DashboardHomework,
|
DashboardHomework,
|
||||||
DashboardExam,
|
DashboardExam,
|
||||||
DashboardGrade,
|
DashboardGrade,
|
||||||
|
ScheduleItem,
|
||||||
|
GrowthDataPoint,
|
||||||
} from "@/lib/graphql/types";
|
} from "@/lib/graphql/types";
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
// 日期格式化(中文短格式)
|
// 日期格式化(中文短格式)
|
||||||
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
|
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||||
@@ -39,6 +58,17 @@ function formatPercent(rate: number): string {
|
|||||||
export default function DashboardPage(): JSX.Element {
|
export default function DashboardPage(): JSX.Element {
|
||||||
const { data, fetching, error } = useStudentDashboard();
|
const { data, fetching, error } = useStudentDashboard();
|
||||||
const { data: currentUser } = useCurrentUser();
|
const { data: currentUser } = useCurrentUser();
|
||||||
|
const { data: scheduleData } = useMySchedule();
|
||||||
|
const { data: growthData } = useStudentGrowth();
|
||||||
|
|
||||||
|
// 筛选今日课表
|
||||||
|
const todaySchedule = useMemo(() => {
|
||||||
|
if (!scheduleData) return [];
|
||||||
|
const today = new Date().getDay();
|
||||||
|
return scheduleData
|
||||||
|
.filter((s) => s.dayOfWeek === today)
|
||||||
|
.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
|
}, [scheduleData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
||||||
@@ -58,6 +88,10 @@ export default function DashboardPage(): JSX.Element {
|
|||||||
<UpcomingHomeworkCard items={data.upcomingHomework ?? []} />
|
<UpcomingHomeworkCard items={data.upcomingHomework ?? []} />
|
||||||
<UpcomingExamsCard items={data.upcomingExams ?? []} />
|
<UpcomingExamsCard items={data.upcomingExams ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<TodayScheduleCard items={todaySchedule} />
|
||||||
|
<GrowthCurveCard items={growthData ?? []} />
|
||||||
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<RecentGradesCard items={data.recentGrades ?? []} />
|
<RecentGradesCard items={data.recentGrades ?? []} />
|
||||||
</div>
|
</div>
|
||||||
@@ -353,6 +387,171 @@ function RecentGradesCard({ items }: { items: DashboardGrade[] }): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 今日课表 ===
|
||||||
|
function TodayScheduleCard({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: ScheduleItem[];
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<section aria-label="今日课表" className="card-paper p-6">
|
||||||
|
<div className="flex items-baseline justify-between mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-serif"
|
||||||
|
style={{ color: "var(--color-ink)" }}
|
||||||
|
>
|
||||||
|
今日课表
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/schedule"
|
||||||
|
className="text-xs hover:underline"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
查看周课表 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="rule-thin mb-4" />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-sm py-6 text-center"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
今日无课
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-md py-xs"
|
||||||
|
style={{ borderLeft: "2px solid var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs flex-shrink-0 pl-sm"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
{item.startTime}–{item.endTime}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
className="text-sm font-medium text-truncate"
|
||||||
|
style={{ color: "var(--color-ink)" }}
|
||||||
|
>
|
||||||
|
{item.subject}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs text-truncate"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
{item.teacher} · {item.classroom}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 成长曲线 ===
|
||||||
|
function GrowthCurveCard({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: GrowthDataPoint[];
|
||||||
|
}): JSX.Element {
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
items.map((p) => ({
|
||||||
|
date: p.date.slice(5),
|
||||||
|
myScore: p.myScore,
|
||||||
|
classAverage: p.classAverage,
|
||||||
|
})),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="成长曲线" className="card-paper p-6">
|
||||||
|
<div className="flex items-baseline justify-between mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-serif"
|
||||||
|
style={{ color: "var(--color-ink)" }}
|
||||||
|
>
|
||||||
|
成长曲线
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
我的成绩 vs 班级平均
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rule-thin mb-4" />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p
|
||||||
|
className="text-sm py-6 text-center"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
暂无成长数据
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: "100%", height: 240 }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 8, right: 16, bottom: 8, left: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
stroke="var(--color-rule)"
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: "var(--color-ink-muted)", fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: "var(--color-ink-muted)", fontSize: 11 }}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: "var(--color-paper)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
borderRadius: "var(--radius-base)",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: "var(--color-ink)" }}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="myScore"
|
||||||
|
name="我的成绩"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: "var(--color-accent)" }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="classAverage"
|
||||||
|
name="班级平均"
|
||||||
|
stroke="var(--color-ink-muted)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// === 加载骨架 ===
|
// === 加载骨架 ===
|
||||||
function LoadingSkeleton(): JSX.Element {
|
function LoadingSkeleton(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,63 +3,188 @@
|
|||||||
/**
|
/**
|
||||||
* MyClasses — 我的班级(ai14)
|
* MyClasses — 我的班级(ai14)
|
||||||
*
|
*
|
||||||
* 数据源:useMyClasses GraphQL hook
|
* 数据源:useMyClasses + useLeaveClass GraphQL hook
|
||||||
* 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年)
|
* 视图:班级卡片网格(班级名 + 班主任 + 学生数 + 年级 + 学年)
|
||||||
|
* 功能:加入班级(JoinClassModal)/ 退出班级(确认后调用 leaveClass)
|
||||||
*
|
*
|
||||||
* 设计依据:01-understanding.md §7 L1 视口(my-classes)
|
* 设计依据:01-understanding.md §7 L1 视口(my-classes)
|
||||||
* 02-architecture-design.md §14.4(CSR)
|
* 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 type { ClassInfo } from "@/lib/graphql/types";
|
||||||
|
import { JoinClassModal } from "@/components/join-class-modal";
|
||||||
|
|
||||||
export default function MyClassesPage(): JSX.Element {
|
export default function MyClassesPage(): JSX.Element {
|
||||||
const { data, fetching, error } = useMyClasses();
|
const { data, fetching, error, reexecute } = useMyClasses();
|
||||||
const classes = data ?? [];
|
const classes = data ?? [];
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [leavingClassId, setLeavingClassId] = useState<string | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<
|
||||||
|
{ type: "success" | "error"; message: string } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
execute: executeLeave,
|
||||||
|
fetching: leaveFetching,
|
||||||
|
data: leaveData,
|
||||||
|
success: leaveSuccess,
|
||||||
|
errors: leaveErrors,
|
||||||
|
} = useLeaveClass();
|
||||||
|
|
||||||
|
// 响应退出班级结果
|
||||||
|
useEffect(() => {
|
||||||
|
if (!leaveFetching && leaveData !== null && leavingClassId !== null) {
|
||||||
|
if (leaveSuccess) {
|
||||||
|
setFeedback({ type: "success", message: "已退出班级" });
|
||||||
|
setLeavingClassId(null);
|
||||||
|
reexecute();
|
||||||
|
} else if (!leaveSuccess) {
|
||||||
|
const errMsg = leaveErrors[0]?.message ?? "退出失败";
|
||||||
|
setFeedback({ type: "error", message: errMsg });
|
||||||
|
setLeavingClassId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
leaveData,
|
||||||
|
leaveSuccess,
|
||||||
|
leaveErrors,
|
||||||
|
leaveFetching,
|
||||||
|
leavingClassId,
|
||||||
|
reexecute,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleOpenModal = useCallback((): void => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseModal = useCallback((): void => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleJoined = useCallback(
|
||||||
|
(_className: string): void => {
|
||||||
|
setFeedback({ type: "success", message: "加入班级成功" });
|
||||||
|
reexecute();
|
||||||
|
},
|
||||||
|
[reexecute],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleLeaveClass = useCallback((classId: string): void => {
|
||||||
|
setLeavingClassId(classId);
|
||||||
|
executeLeave({ classId });
|
||||||
|
}, [executeLeave]);
|
||||||
|
|
||||||
|
const handleConfirmLeave = useCallback(
|
||||||
|
(classId: string): void => {
|
||||||
|
if (
|
||||||
|
window.confirm("确定要退出该班级吗?退出后将无法查看该班级的作业与通知。")
|
||||||
|
) {
|
||||||
|
handleLeaveClass(classId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleLeaveClass],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<p
|
<div className="flex items-start justify-between gap-md">
|
||||||
className="text-xs uppercase tracking-wider"
|
<div className="min-w-0 flex-1">
|
||||||
style={{ color: "var(--color-ink-muted)" }}
|
<p
|
||||||
>
|
className="text-xs uppercase tracking-wider"
|
||||||
学习场景
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
</p>
|
>
|
||||||
<h1
|
学习场景
|
||||||
className="mt-2 text-3xl lg:text-4xl font-serif"
|
</p>
|
||||||
style={{ color: "var(--color-ink)" }}
|
<h1
|
||||||
>
|
className="mt-2 text-3xl lg:text-4xl font-serif"
|
||||||
我的班级
|
style={{ color: "var(--color-ink)" }}
|
||||||
</h1>
|
>
|
||||||
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
|
我的班级
|
||||||
查看你所在的班级、班主任与同学人数。
|
</h1>
|
||||||
</p>
|
<p
|
||||||
|
className="mt-2 text-sm"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
查看你所在的班级、班主任与同学人数。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
className="btn-primary flex-shrink-0"
|
||||||
|
>
|
||||||
|
加入班级
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rule-thin mb-8" />
|
<div className="rule-thin mb-8" />
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<div
|
||||||
|
className="mb-md p-sm rounded text-sm"
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
feedback.type === "success"
|
||||||
|
? "var(--color-success)"
|
||||||
|
: "var(--color-danger)",
|
||||||
|
background:
|
||||||
|
feedback.type === "success"
|
||||||
|
? "hsl(var(--hue-success), var(--sat-success), 95%)"
|
||||||
|
: "hsl(var(--hue-danger), var(--sat-danger), 95%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fetching ? (
|
{fetching ? (
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorState message={error.message} />
|
<ErrorState message={error.message} />
|
||||||
) : classes.length === 0 ? (
|
) : classes.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState onJoin={handleOpenModal} />
|
||||||
) : (
|
) : (
|
||||||
<section
|
<section
|
||||||
aria-label="班级列表"
|
aria-label="班级列表"
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"
|
||||||
>
|
>
|
||||||
{classes.map((cls) => (
|
{classes.map((cls) => (
|
||||||
<ClassCard key={cls.id} cls={cls} />
|
<ClassCard
|
||||||
|
key={cls.id}
|
||||||
|
cls={cls}
|
||||||
|
onLeave={handleConfirmLeave}
|
||||||
|
leaving={leavingClassId === cls.id && leaveFetching}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<JoinClassModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
onJoined={handleJoined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element {
|
function ClassCard({
|
||||||
|
cls,
|
||||||
|
onLeave,
|
||||||
|
leaving,
|
||||||
|
}: {
|
||||||
|
cls: ClassInfo;
|
||||||
|
onLeave: (classId: string) => void;
|
||||||
|
leaving: boolean;
|
||||||
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<article className="card-paper p-6 flex flex-col">
|
<article className="card-paper p-6 flex flex-col">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -98,6 +223,18 @@ function ClassCard({ cls }: { cls: ClassInfo }): JSX.Element {
|
|||||||
<dd style={{ color: "var(--color-ink)" }}>{cls.homeroomTeacher}</dd>
|
<dd style={{ color: "var(--color-ink)" }}>{cls.homeroomTeacher}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLeave(cls.id)}
|
||||||
|
disabled={leaving}
|
||||||
|
className="btn-secondary text-xs"
|
||||||
|
aria-label={`退出班级 ${cls.name}`}
|
||||||
|
>
|
||||||
|
{leaving ? "退出中…" : "退出班级"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,7 +298,7 @@ function ErrorState({ message }: { message: string }): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState(): JSX.Element {
|
function EmptyState({ onJoin }: { onJoin: () => void }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="card-paper p-8 text-center">
|
<div className="card-paper p-8 text-center">
|
||||||
<p
|
<p
|
||||||
@@ -178,8 +315,15 @@ function EmptyState(): JSX.Element {
|
|||||||
暂未分配到班级
|
暂未分配到班级
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
|
<p className="mt-2 text-sm" style={{ color: "var(--color-ink-muted)" }}>
|
||||||
请联系班主任或教务老师为你分配班级。
|
请联系班主任或教务老师为你分配班级,或使用邀请码加入班级。
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onJoin}
|
||||||
|
className="mt-4 btn-primary"
|
||||||
|
>
|
||||||
|
加入班级
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import type {
|
|||||||
ExamResultSummary,
|
ExamResultSummary,
|
||||||
QuestionResult,
|
QuestionResult,
|
||||||
} from "@/lib/exam-types";
|
} from "@/lib/exam-types";
|
||||||
|
import {
|
||||||
|
TeacherAnnotationOverlay,
|
||||||
|
type TeacherAnnotation,
|
||||||
|
} from "@/components/teacher-annotation-overlay";
|
||||||
|
|
||||||
const GRAPHQL_ENDPOINT =
|
const GRAPHQL_ENDPOINT =
|
||||||
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
|
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ?? "/api/v1/student/graphql";
|
||||||
@@ -102,6 +106,42 @@ function createMockExamResult(examId: string): ExamResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模拟教师批注数据(P5 阶段,后续接入 teacher-bff 批注 API) */
|
||||||
|
const MOCK_ANNOTATIONS: TeacherAnnotation[] = [
|
||||||
|
{
|
||||||
|
id: "ann-001",
|
||||||
|
questionId: "q2",
|
||||||
|
type: "comment",
|
||||||
|
teacherName: "李老师",
|
||||||
|
content: "注意 9 不是质数,9 = 3 × 3。多选题需要逐项验证。",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ann-002",
|
||||||
|
questionId: "q4",
|
||||||
|
type: "correction",
|
||||||
|
teacherName: "李老师",
|
||||||
|
content: "定义表述不够完整,应强调极限概念。导数的严格定义需要用到极限运算。",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ann-003",
|
||||||
|
questionId: "q4",
|
||||||
|
type: "comment",
|
||||||
|
teacherName: "李老师",
|
||||||
|
content: "思路正确,但建议补充数学符号表示:f'(x₀) = lim[Δx→0] (f(x₀+Δx) - f(x₀)) / Δx",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ann-004",
|
||||||
|
questionId: "q5",
|
||||||
|
type: "comment",
|
||||||
|
teacherName: "李老师",
|
||||||
|
content: "本题未作答,建议复习微积分基本定理及其在物理学中的位移-速度关系应用。",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/** 获取考试结果(API 或 Mock) */
|
/** 获取考试结果(API 或 Mock) */
|
||||||
async function fetchExamResult(examId: string): Promise<ExamResult> {
|
async function fetchExamResult(examId: string): Promise<ExamResult> {
|
||||||
if (IS_MOCKING) {
|
if (IS_MOCKING) {
|
||||||
@@ -380,6 +420,18 @@ export default function ExamResultPage(): JSX.Element {
|
|||||||
<p className="mt-xs text-sm text-ink">{qr.explanation}</p>
|
<p className="mt-xs text-sm text-ink">{qr.explanation}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 教师批注 */}
|
||||||
|
<TeacherAnnotationOverlay
|
||||||
|
questionId={qr.questionId}
|
||||||
|
answerText={
|
||||||
|
qr.studentAnswer?.type === "short-answer" ||
|
||||||
|
qr.studentAnswer?.type === "essay"
|
||||||
|
? qr.studentAnswer.text
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
annotations={MOCK_ANNOTATIONS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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)
|
* MyHomework — 我的作业列表(ai14)
|
||||||
*
|
*
|
||||||
* 数据源:useMyHomework GraphQL hook
|
* 数据源:useMyHomework + useRequestExtension GraphQL hook
|
||||||
* 视图:按状态分组(pending / submitted / graded)
|
* 视图:按状态分组(pending / overdue / submitted / graded)
|
||||||
* 每张卡片:标题 + 学科 + 截止时间 + 状态徽标
|
* 每张卡片:标题 + 学科 + 截止时间 + 状态徽标
|
||||||
|
* 功能:学科筛选 / 申请延期(pending & overdue)/ 查看分析(graded)
|
||||||
* 链接:pending → /my-homework/[id]/submit
|
* 链接:pending → /my-homework/[id]/submit
|
||||||
|
* graded → /my-homework/[id]/analysis
|
||||||
*
|
*
|
||||||
* 设计依据:01-understanding.md §8(L2 路由表)
|
* 设计依据:01-understanding.md §8(L2 路由表)
|
||||||
* 02-architecture-design.md §15(详细组件设计)
|
* 02-architecture-design.md §15(详细组件设计)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMyHomework } from "@/lib/graphql/hooks";
|
import {
|
||||||
|
useMyHomework,
|
||||||
|
useRequestExtension,
|
||||||
|
} from "@/lib/graphql/hooks";
|
||||||
import type { HomeworkListItem, HomeworkStatus } from "@/lib/graphql/types";
|
import type { HomeworkListItem, HomeworkStatus } from "@/lib/graphql/types";
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
|
const dateFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||||
@@ -51,16 +57,93 @@ const STATUS_GROUPS: Array<{
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SubjectFilter = "all" | string;
|
||||||
|
|
||||||
export default function MyHomeworkPage(): JSX.Element {
|
export default function MyHomeworkPage(): JSX.Element {
|
||||||
const { data, fetching, error } = useMyHomework();
|
const { data, fetching, error, reexecute } = useMyHomework();
|
||||||
const homework = data ?? [];
|
const homework = data ?? [];
|
||||||
|
const [subjectFilter, setSubjectFilter] = useState<SubjectFilter>("all");
|
||||||
|
const [extensionTarget, setExtensionTarget] = useState<HomeworkListItem | null>(null);
|
||||||
|
const [extensionReason, setExtensionReason] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<
|
||||||
|
{ type: "success" | "error"; message: string } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
execute: executeExtension,
|
||||||
|
fetching: extFetching,
|
||||||
|
data: extData,
|
||||||
|
success: extSuccess,
|
||||||
|
errors: extErrors,
|
||||||
|
} = useRequestExtension();
|
||||||
|
|
||||||
|
// 提取所有学科选项(去重)
|
||||||
|
const subjectOptions = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
homework.forEach((h) => {
|
||||||
|
map.set(h.subject.id, h.subject.name);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
|
}, [homework]);
|
||||||
|
|
||||||
|
// 按学科筛选
|
||||||
|
const filteredHomework = useMemo(() => {
|
||||||
|
if (subjectFilter === "all") return homework;
|
||||||
|
return homework.filter((h) => h.subject.id === subjectFilter);
|
||||||
|
}, [homework, subjectFilter]);
|
||||||
|
|
||||||
// 按状态分组
|
// 按状态分组
|
||||||
const grouped = STATUS_GROUPS.map((g) => ({
|
const grouped = STATUS_GROUPS.map((g) => ({
|
||||||
...g,
|
...g,
|
||||||
items: homework.filter((h) => h.status === g.key),
|
items: filteredHomework.filter((h) => h.status === g.key),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 响应延期申请结果
|
||||||
|
useEffect(() => {
|
||||||
|
if (!extFetching && extData !== null && extensionTarget) {
|
||||||
|
if (extSuccess && extData) {
|
||||||
|
setFeedback({
|
||||||
|
type: "success",
|
||||||
|
message: `延期申请已通过,新截止时间:${dateFormatter.format(
|
||||||
|
new Date(extData.newDueDate),
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
setExtensionTarget(null);
|
||||||
|
setExtensionReason("");
|
||||||
|
reexecute();
|
||||||
|
} else if (!extSuccess) {
|
||||||
|
const errMsg = extErrors[0]?.message ?? "延期申请失败";
|
||||||
|
setFeedback({ type: "error", message: errMsg });
|
||||||
|
setExtensionTarget(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [extData, extSuccess, extErrors, extFetching, extensionTarget, reexecute]);
|
||||||
|
|
||||||
|
const handleRequestExtension = useCallback(
|
||||||
|
(hw: HomeworkListItem): void => {
|
||||||
|
setExtensionTarget(hw);
|
||||||
|
setExtensionReason("");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmitExtension = useCallback((): void => {
|
||||||
|
if (!extensionTarget) return;
|
||||||
|
if (!extensionReason.trim()) {
|
||||||
|
setFeedback({ type: "error", message: "请填写延期原因" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
executeExtension({
|
||||||
|
homeworkId: extensionTarget.id,
|
||||||
|
reason: extensionReason.trim(),
|
||||||
|
});
|
||||||
|
}, [extensionTarget, extensionReason, executeExtension]);
|
||||||
|
|
||||||
|
const handleCloseExtension = useCallback((): void => {
|
||||||
|
setExtensionTarget(null);
|
||||||
|
setExtensionReason("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
<div className="px-6 lg:px-10 py-8 lg:py-10 max-w-7xl mx-auto">
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
@@ -81,13 +164,89 @@ export default function MyHomeworkPage(): JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* 学科筛选 */}
|
||||||
|
{homework.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-md flex items-center gap-sm flex-wrap"
|
||||||
|
role="group"
|
||||||
|
aria-label="学科筛选"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs uppercase tracking-wider"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
学科:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubjectFilter("all")}
|
||||||
|
className="px-sm py-xs text-xs rounded-sm"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
subjectFilter === "all"
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
subjectFilter === "all"
|
||||||
|
? "var(--color-paper)"
|
||||||
|
: "var(--color-ink)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
aria-pressed={subjectFilter === "all"}
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
{subjectOptions.map((subj) => (
|
||||||
|
<button
|
||||||
|
key={subj.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubjectFilter(subj.id)}
|
||||||
|
className="px-sm py-xs text-xs rounded-sm"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
subjectFilter === subj.id
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
subjectFilter === subj.id
|
||||||
|
? "var(--color-paper)"
|
||||||
|
: "var(--color-ink)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
aria-pressed={subjectFilter === subj.id}
|
||||||
|
>
|
||||||
|
{subj.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rule-thin mb-8" />
|
<div className="rule-thin mb-8" />
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<div
|
||||||
|
className="mb-md p-sm rounded text-sm"
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
feedback.type === "success"
|
||||||
|
? "var(--color-success)"
|
||||||
|
: "var(--color-danger)",
|
||||||
|
background:
|
||||||
|
feedback.type === "success"
|
||||||
|
? "hsl(var(--hue-success), var(--sat-success), 95%)"
|
||||||
|
: "hsl(var(--hue-danger), var(--sat-danger), 95%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fetching ? (
|
{fetching ? (
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorState message={error.message} />
|
<ErrorState message={error.message} />
|
||||||
) : homework.length === 0 ? (
|
) : filteredHomework.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -113,7 +272,10 @@ export default function MyHomeworkPage(): JSX.Element {
|
|||||||
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<ul className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{group.items.map((hw) => (
|
{group.items.map((hw) => (
|
||||||
<li key={hw.id}>
|
<li key={hw.id}>
|
||||||
<HomeworkCard homework={hw} />
|
<HomeworkCard
|
||||||
|
homework={hw}
|
||||||
|
onRequestExtension={handleRequestExtension}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -122,34 +284,107 @@ export default function MyHomeworkPage(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 延期申请对话框 */}
|
||||||
|
{extensionTarget && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-md"
|
||||||
|
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="extension-title"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !extFetching) {
|
||||||
|
handleCloseExtension();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md card-paper p-lg"
|
||||||
|
style={{ background: "var(--color-paper)" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="extension-title"
|
||||||
|
className="font-serif text-xl mb-md"
|
||||||
|
style={{ color: "var(--color-ink)" }}
|
||||||
|
>
|
||||||
|
申请作业延期
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-sm mb-md"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
作业:{extensionTarget.title}
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
htmlFor="ext-reason"
|
||||||
|
className="block text-xs mb-xs"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
延期原因
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ext-reason"
|
||||||
|
value={extensionReason}
|
||||||
|
onChange={(e) => setExtensionReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="请说明延期原因…"
|
||||||
|
className="w-full input-paper"
|
||||||
|
aria-label="延期原因"
|
||||||
|
/>
|
||||||
|
<div className="mt-md flex justify-end gap-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseExtension}
|
||||||
|
disabled={extFetching}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitExtension}
|
||||||
|
disabled={extFetching || !extensionReason.trim()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{extFetching ? "申请中…" : "提交申请"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeworkCard({
|
function HomeworkCard({
|
||||||
homework,
|
homework,
|
||||||
|
onRequestExtension,
|
||||||
}: {
|
}: {
|
||||||
homework: HomeworkListItem;
|
homework: HomeworkListItem;
|
||||||
|
onRequestExtension: (hw: HomeworkListItem) => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const dueAt = new Date(homework.dueAt);
|
const dueAt = new Date(homework.dueAt);
|
||||||
const isPending = homework.status === "pending";
|
const isPending = homework.status === "pending";
|
||||||
const linkHref = isPending ? `/my-homework/${homework.id}/submit` : null;
|
const isOverdue = homework.status === "overdue";
|
||||||
|
const isGraded = homework.status === "graded";
|
||||||
|
const canRequestExtension = isPending || isOverdue;
|
||||||
|
const submitHref = isPending || isOverdue ? `/my-homework/${homework.id}/submit` : null;
|
||||||
|
const analysisHref = isGraded ? `/my-homework/${homework.id}/analysis` : null;
|
||||||
|
|
||||||
const badgeClass = getBadgeClassForStatus(homework.status);
|
const badgeClass = getBadgeClassForStatus(homework.status);
|
||||||
|
|
||||||
// 截止时间紧迫度(仅 pending 显示)
|
// 截止时间紧迫度(仅 pending 显示)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isOverdue = isPending && dueAt < now;
|
|
||||||
const isUrgent =
|
const isUrgent =
|
||||||
isPending &&
|
isPending &&
|
||||||
!isOverdue &&
|
dueAt.getTime() - now.getTime() > 0 &&
|
||||||
dueAt.getTime() - now.getTime() < 24 * 60 * 60 * 1000;
|
dueAt.getTime() - now.getTime() < 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<article
|
<article
|
||||||
className={`card-paper p-5 h-full transition-shadow ${
|
className="card-paper p-5 h-full"
|
||||||
linkHref ? "hover:shadow-card motion-safe" : ""
|
|
||||||
}`}
|
|
||||||
aria-label={`${homework.title} - ${homework.status}`}
|
aria-label={`${homework.title} - ${homework.status}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -207,7 +442,7 @@ function HomeworkCard({
|
|||||||
{homework.questionCount} 题
|
{homework.questionCount} 题
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{homework.status === "graded" && homework.score !== undefined && (
|
{isGraded && homework.score !== undefined && (
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<dt
|
<dt
|
||||||
className="uppercase tracking-wider"
|
className="uppercase tracking-wider"
|
||||||
@@ -232,24 +467,45 @@ function HomeworkCard({
|
|||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{linkHref && (
|
{/* 操作按钮区 */}
|
||||||
<p className="mt-4 text-xs" style={{ color: "var(--color-accent)" }}>
|
<div className="mt-4 flex items-center gap-sm">
|
||||||
进入提交 →
|
{submitHref && (
|
||||||
</p>
|
<Link
|
||||||
)}
|
href={submitHref}
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
aria-label={`${homework.title} - 进入提交`}
|
||||||
|
>
|
||||||
|
进入提交 →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canRequestExtension && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onRequestExtension(homework);
|
||||||
|
}}
|
||||||
|
className="btn-secondary text-xs"
|
||||||
|
aria-label={`申请延期 ${homework.title}`}
|
||||||
|
>
|
||||||
|
申请延期
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{analysisHref && (
|
||||||
|
<Link
|
||||||
|
href={analysisHref}
|
||||||
|
className="btn-secondary text-xs"
|
||||||
|
aria-label={`查看分析 ${homework.title}`}
|
||||||
|
>
|
||||||
|
查看分析
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!linkHref) return inner;
|
return inner;
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={linkHref}
|
|
||||||
className="block h-full focus-visible:outline-2"
|
|
||||||
aria-label={`${homework.title} - 进入提交`}
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBadgeClassForStatus(status: HomeworkStatus): string {
|
function getBadgeClassForStatus(status: HomeworkStatus): string {
|
||||||
|
|||||||
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)
|
* AppShell — 学生端应用壳(ai14)
|
||||||
*
|
*
|
||||||
* 职责:
|
* 职责:
|
||||||
* - 左侧导航栏:学生端 11 项菜单(含 P3-P5 阶段)
|
* - 左侧导航栏:学生端 13 项菜单(含 P3-P5 阶段)
|
||||||
* - 顶栏:学生姓名 + 头像 + 退出登录
|
* - 顶栏:学生姓名 + 头像 + 退出登录
|
||||||
* - 主内容区:渲染 children
|
* - 主内容区:渲染 children
|
||||||
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
|
* - 响应式:移动端 (<lg) 折叠为 hamburger 抽屉
|
||||||
@@ -30,11 +30,13 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ label: "我的作业", route: "/my-homework" },
|
{ label: "我的作业", route: "/my-homework" },
|
||||||
{ label: "我的成绩", route: "/my-grades" },
|
{ label: "我的成绩", route: "/my-grades" },
|
||||||
{ label: "我的考勤", route: "/my-attendance" },
|
{ label: "我的考勤", route: "/my-attendance" },
|
||||||
|
{ label: "我的课表", route: "/schedule" },
|
||||||
{ label: "学习路径", route: "/learning-path" },
|
{ label: "学习路径", route: "/learning-path" },
|
||||||
{ label: "学情诊断", route: "/diagnostic" },
|
{ label: "学情诊断", route: "/diagnostic" },
|
||||||
{ label: "教材", route: "/textbooks" },
|
{ label: "教材", route: "/textbooks" },
|
||||||
{ label: "通知中心", route: "/notifications" },
|
{ label: "通知中心", route: "/notifications" },
|
||||||
{ label: "AI辅学", route: "/ai-tutor" },
|
{ label: "AI辅学", route: "/ai-tutor" },
|
||||||
|
{ label: "系统设置", route: "/settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AppShell({
|
export default function AppShell({
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { cn } from "@/lib/exam-types";
|
|||||||
import type { AnswerInput, ExamDetail, ExamQuestion } from "@/lib/exam-types";
|
import type { AnswerInput, ExamDetail, ExamQuestion } from "@/lib/exam-types";
|
||||||
import { CountdownTimer } from "./countdown-timer";
|
import { CountdownTimer } from "./countdown-timer";
|
||||||
import { DraftRecovery } from "./draft-recovery";
|
import { DraftRecovery } from "./draft-recovery";
|
||||||
|
import { HandwritingPad } from "@/components/handwriting-pad";
|
||||||
|
import { ImageUpload } from "@/components/image-upload";
|
||||||
import { useExamStateMachine } from "@/hooks/use-exam-state-machine";
|
import { useExamStateMachine } from "@/hooks/use-exam-state-machine";
|
||||||
import { useServerTimeSync } from "@/hooks/use-server-time-sync";
|
import { useServerTimeSync } from "@/hooks/use-server-time-sync";
|
||||||
import { useExamDraft } from "@/hooks/use-exam-draft";
|
import { useExamDraft } from "@/hooks/use-exam-draft";
|
||||||
@@ -780,14 +782,11 @@ function QuestionInput({
|
|||||||
|
|
||||||
case "essay":
|
case "essay":
|
||||||
return (
|
return (
|
||||||
<textarea
|
<EssayInput
|
||||||
value={answer?.type === "essay" ? answer.text : ""}
|
question={question}
|
||||||
onChange={(e) => onChange({ type: "essay", text: e.target.value })}
|
answer={answer}
|
||||||
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
rows={8}
|
|
||||||
placeholder="请输入论述内容"
|
|
||||||
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
|
|
||||||
aria-label="论述题答案"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -795,3 +794,165 @@ function QuestionInput({
|
|||||||
return <div className="text-sm text-ink-muted">不支持的题型</div>;
|
return <div className="text-sm text-ink-muted">不支持的题型</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 论述题输入组件(支持文本/手写/图片)===
|
||||||
|
|
||||||
|
type EssayMode = "text" | "handwriting" | "image";
|
||||||
|
|
||||||
|
interface EssayInputProps {
|
||||||
|
question: ExamQuestion;
|
||||||
|
answer: AnswerInput | null;
|
||||||
|
onChange: (answer: AnswerInput) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EssayInput({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
}: EssayInputProps): JSX.Element {
|
||||||
|
const [mode, setMode] = useState<EssayMode>("text");
|
||||||
|
const textValue = answer?.type === "essay" ? answer.text : "";
|
||||||
|
|
||||||
|
const handleTextChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||||
|
onChange({ type: "essay", text: e.target.value });
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHandwritingExport = useCallback(
|
||||||
|
(dataUrl: string): void => {
|
||||||
|
if (!dataUrl) return;
|
||||||
|
// 将手写图片以标记形式追加到文本
|
||||||
|
const marker = `\n[handwriting:${dataUrl}]\n`;
|
||||||
|
onChange({ type: "essay", text: textValue + marker });
|
||||||
|
},
|
||||||
|
[textValue, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImageChange = useCallback(
|
||||||
|
(images: string[]): void => {
|
||||||
|
// 将图片以标记形式追加到文本
|
||||||
|
const markers = images
|
||||||
|
.map((img) => `[image:${img}]`)
|
||||||
|
.join("\n");
|
||||||
|
onChange({ type: "essay", text: markers });
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const modeTabs: Array<{ key: EssayMode; label: string }> = [
|
||||||
|
{ key: "text", label: "文本" },
|
||||||
|
{ key: "handwriting", label: "手写" },
|
||||||
|
{ key: "image", label: "图片" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-sm">
|
||||||
|
{/* 模式切换 */}
|
||||||
|
<div
|
||||||
|
className="flex gap-xs"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="论述题作答方式"
|
||||||
|
>
|
||||||
|
{modeTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === tab.key}
|
||||||
|
onClick={() => setMode(tab.key)}
|
||||||
|
className={cn(
|
||||||
|
"px-md py-xs text-xs rounded-sm transition-colors",
|
||||||
|
mode === tab.key ? "font-serif" : "",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
mode === tab.key
|
||||||
|
? "var(--color-accent)"
|
||||||
|
: "transparent",
|
||||||
|
color:
|
||||||
|
mode === tab.key
|
||||||
|
? "var(--color-paper)"
|
||||||
|
: "var(--color-ink-muted)",
|
||||||
|
border: "1px solid var(--color-rule)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文本输入 */}
|
||||||
|
{mode === "text" && (
|
||||||
|
<textarea
|
||||||
|
value={textValue}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
rows={8}
|
||||||
|
placeholder="请输入论述内容"
|
||||||
|
className="w-full rounded border border-rule px-lg py-sm text-sm text-ink focus:border-accent focus:outline-none"
|
||||||
|
aria-label="论述题文本答案"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 手写板 */}
|
||||||
|
{mode === "handwriting" && (
|
||||||
|
<div>
|
||||||
|
<HandwritingPad
|
||||||
|
onExport={handleHandwritingExport}
|
||||||
|
height={280}
|
||||||
|
ariaLabel={`${question.id} 手写作答区`}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="mt-xs text-xs"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
完成手写后,笔迹将自动作为图片附件追加到答案中。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图片上传 */}
|
||||||
|
{mode === "image" && (
|
||||||
|
<div>
|
||||||
|
<ImageUpload
|
||||||
|
value={[]}
|
||||||
|
onChange={handleImageChange}
|
||||||
|
maxCount={3}
|
||||||
|
ariaLabel={`${question.id} 图片作答上传`}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="mt-xs text-xs"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
上传手写答案照片或其他参考图片(最多 3 张)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 当前文本答案预览 */}
|
||||||
|
{textValue && mode !== "text" && (
|
||||||
|
<div
|
||||||
|
className="mt-sm p-sm rounded border border-rule"
|
||||||
|
style={{ background: "var(--color-paper)" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-xs mb-xs"
|
||||||
|
style={{ color: "var(--color-ink-muted)" }}
|
||||||
|
>
|
||||||
|
当前答案(含附件标记)
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs text-clamp-2"
|
||||||
|
style={{ color: "var(--color-ink)" }}
|
||||||
|
>
|
||||||
|
{textValue.replace(/\[(handwriting|image):[^\]]+\]/g, "[附件]")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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 {
|
import type {
|
||||||
ActionError,
|
ActionError,
|
||||||
ActionState,
|
ActionState,
|
||||||
|
AssignmentAnalysis,
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
|
ChangePasswordInput,
|
||||||
|
ChangePasswordResult,
|
||||||
ChapterNode,
|
ChapterNode,
|
||||||
ClassInfo,
|
ClassInfo,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
ExamDetail,
|
ExamDetail,
|
||||||
ExamListItem,
|
ExamListItem,
|
||||||
GradeRecord,
|
GradeRecord,
|
||||||
|
GrowthDataPoint,
|
||||||
HomeworkDetail,
|
HomeworkDetail,
|
||||||
HomeworkListItem,
|
HomeworkListItem,
|
||||||
|
JoinClassInput,
|
||||||
|
JoinClassResult,
|
||||||
|
LeaveClassInput,
|
||||||
|
LeaveClassResult,
|
||||||
LearningPathNode,
|
LearningPathNode,
|
||||||
MarkAllAsReadInput,
|
MarkAllAsReadInput,
|
||||||
MarkAllAsReadResult,
|
MarkAllAsReadResult,
|
||||||
@@ -38,10 +46,14 @@ import type {
|
|||||||
RecordExamViolationResult,
|
RecordExamViolationResult,
|
||||||
RecordPasteEventInput,
|
RecordPasteEventInput,
|
||||||
RecordPasteEventResult,
|
RecordPasteEventResult,
|
||||||
|
RequestExtensionInput,
|
||||||
|
RequestExtensionResult,
|
||||||
SaveExamDraftInput,
|
SaveExamDraftInput,
|
||||||
SaveExamDraftResult,
|
SaveExamDraftResult,
|
||||||
|
ScheduleItem,
|
||||||
ServerTime,
|
ServerTime,
|
||||||
StudentDashboard,
|
StudentDashboard,
|
||||||
|
StudentProfile,
|
||||||
SubmitExamInput,
|
SubmitExamInput,
|
||||||
SubmitExamResult,
|
SubmitExamResult,
|
||||||
SubmitHomeworkInput,
|
SubmitHomeworkInput,
|
||||||
@@ -51,13 +63,19 @@ import type {
|
|||||||
TrendDataPoint,
|
TrendDataPoint,
|
||||||
UpdateNotificationPreferenceInput,
|
UpdateNotificationPreferenceInput,
|
||||||
UpdateNotificationPreferenceResult,
|
UpdateNotificationPreferenceResult,
|
||||||
|
UpdateProfileInput,
|
||||||
|
UpdateProfileResult,
|
||||||
WeaknessPoint,
|
WeaknessPoint,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
|
ASSIGNMENT_ANALYSIS_QUERY,
|
||||||
|
CHANGE_PASSWORD_MUTATION,
|
||||||
CHAPTERS_QUERY,
|
CHAPTERS_QUERY,
|
||||||
CURRENT_USER_QUERY,
|
CURRENT_USER_QUERY,
|
||||||
EXAM_DETAIL_QUERY,
|
EXAM_DETAIL_QUERY,
|
||||||
HOMEWORK_DETAIL_QUERY,
|
HOMEWORK_DETAIL_QUERY,
|
||||||
|
JOIN_CLASS_MUTATION,
|
||||||
|
LEAVE_CLASS_MUTATION,
|
||||||
LEARNING_PATH_QUERY,
|
LEARNING_PATH_QUERY,
|
||||||
MARK_ALL_AS_READ_MUTATION,
|
MARK_ALL_AS_READ_MUTATION,
|
||||||
MARK_AS_READ_MUTATION,
|
MARK_AS_READ_MUTATION,
|
||||||
@@ -67,17 +85,22 @@ import {
|
|||||||
MY_GRADES_QUERY,
|
MY_GRADES_QUERY,
|
||||||
MY_HOMEWORK_QUERY,
|
MY_HOMEWORK_QUERY,
|
||||||
MY_NOTIFICATIONS_QUERY,
|
MY_NOTIFICATIONS_QUERY,
|
||||||
|
MY_PROFILE_QUERY,
|
||||||
|
MY_SCHEDULE_QUERY,
|
||||||
MY_TREND_QUERY,
|
MY_TREND_QUERY,
|
||||||
MY_WEAKNESS_QUERY,
|
MY_WEAKNESS_QUERY,
|
||||||
RECORD_EXAM_VIOLATION_MUTATION,
|
RECORD_EXAM_VIOLATION_MUTATION,
|
||||||
RECORD_PASTE_EVENT_MUTATION,
|
RECORD_PASTE_EVENT_MUTATION,
|
||||||
|
REQUEST_EXTENSION_MUTATION,
|
||||||
SAVE_EXAM_DRAFT_MUTATION,
|
SAVE_EXAM_DRAFT_MUTATION,
|
||||||
SERVER_TIME_QUERY,
|
SERVER_TIME_QUERY,
|
||||||
STUDENT_DASHBOARD_QUERY,
|
STUDENT_DASHBOARD_QUERY,
|
||||||
|
STUDENT_GROWTH_QUERY,
|
||||||
SUBMIT_EXAM_MUTATION,
|
SUBMIT_EXAM_MUTATION,
|
||||||
SUBMIT_HOMEWORK_MUTATION,
|
SUBMIT_HOMEWORK_MUTATION,
|
||||||
TEXTBOOKS_QUERY,
|
TEXTBOOKS_QUERY,
|
||||||
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
||||||
|
UPDATE_PROFILE_MUTATION,
|
||||||
} from "./operations";
|
} from "./operations";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -691,6 +714,215 @@ export function useUpdateNotificationPreference(): UseMutationResult<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 扩展查询 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,
|
ActionExtensions,
|
||||||
ActionState,
|
ActionState,
|
||||||
AnswerInput,
|
AnswerInput,
|
||||||
|
AssignmentAnalysis,
|
||||||
|
AssignmentAnalysisQuestion,
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
AttendanceStatus,
|
AttendanceStatus,
|
||||||
|
ChangePasswordInput,
|
||||||
|
ChangePasswordResult,
|
||||||
ChapterNode,
|
ChapterNode,
|
||||||
ClassInfo,
|
ClassInfo,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
@@ -44,10 +48,15 @@ export type {
|
|||||||
ExamQuestion,
|
ExamQuestion,
|
||||||
ExamStatus,
|
ExamStatus,
|
||||||
GradeRecord,
|
GradeRecord,
|
||||||
|
GrowthDataPoint,
|
||||||
HomeworkDetail,
|
HomeworkDetail,
|
||||||
HomeworkListItem,
|
HomeworkListItem,
|
||||||
HomeworkQuestion,
|
HomeworkQuestion,
|
||||||
HomeworkStatus,
|
HomeworkStatus,
|
||||||
|
JoinClassInput,
|
||||||
|
JoinClassResult,
|
||||||
|
LeaveClassInput,
|
||||||
|
LeaveClassResult,
|
||||||
LearningPathNode,
|
LearningPathNode,
|
||||||
LearningPathStatus,
|
LearningPathStatus,
|
||||||
MarkAllAsReadInput,
|
MarkAllAsReadInput,
|
||||||
@@ -68,10 +77,14 @@ export type {
|
|||||||
RecordExamViolationResult,
|
RecordExamViolationResult,
|
||||||
RecordPasteEventInput,
|
RecordPasteEventInput,
|
||||||
RecordPasteEventResult,
|
RecordPasteEventResult,
|
||||||
|
RequestExtensionInput,
|
||||||
|
RequestExtensionResult,
|
||||||
SaveExamDraftInput,
|
SaveExamDraftInput,
|
||||||
SaveExamDraftResult,
|
SaveExamDraftResult,
|
||||||
|
ScheduleItem,
|
||||||
ServerTime,
|
ServerTime,
|
||||||
StudentDashboard,
|
StudentDashboard,
|
||||||
|
StudentProfile,
|
||||||
SubjectRef,
|
SubjectRef,
|
||||||
SubmitExamInput,
|
SubmitExamInput,
|
||||||
SubmitExamResult,
|
SubmitExamResult,
|
||||||
@@ -81,6 +94,8 @@ export type {
|
|||||||
TrendDataPoint,
|
TrendDataPoint,
|
||||||
UpdateNotificationPreferenceInput,
|
UpdateNotificationPreferenceInput,
|
||||||
UpdateNotificationPreferenceResult,
|
UpdateNotificationPreferenceResult,
|
||||||
|
UpdateProfileInput,
|
||||||
|
UpdateProfileResult,
|
||||||
WeaknessPoint,
|
WeaknessPoint,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -88,10 +103,14 @@ export { ERROR_CODES, ERROR_CODE_PREFIX, getErrorI18nPrefix } from "./types";
|
|||||||
|
|
||||||
// operations
|
// operations
|
||||||
export {
|
export {
|
||||||
|
ASSIGNMENT_ANALYSIS_QUERY,
|
||||||
|
CHANGE_PASSWORD_MUTATION,
|
||||||
CHAPTERS_QUERY,
|
CHAPTERS_QUERY,
|
||||||
CURRENT_USER_QUERY,
|
CURRENT_USER_QUERY,
|
||||||
EXAM_DETAIL_QUERY,
|
EXAM_DETAIL_QUERY,
|
||||||
HOMEWORK_DETAIL_QUERY,
|
HOMEWORK_DETAIL_QUERY,
|
||||||
|
JOIN_CLASS_MUTATION,
|
||||||
|
LEAVE_CLASS_MUTATION,
|
||||||
LEARNING_PATH_QUERY,
|
LEARNING_PATH_QUERY,
|
||||||
MARK_ALL_AS_READ_MUTATION,
|
MARK_ALL_AS_READ_MUTATION,
|
||||||
MARK_AS_READ_MUTATION,
|
MARK_AS_READ_MUTATION,
|
||||||
@@ -101,25 +120,34 @@ export {
|
|||||||
MY_GRADES_QUERY,
|
MY_GRADES_QUERY,
|
||||||
MY_HOMEWORK_QUERY,
|
MY_HOMEWORK_QUERY,
|
||||||
MY_NOTIFICATIONS_QUERY,
|
MY_NOTIFICATIONS_QUERY,
|
||||||
|
MY_PROFILE_QUERY,
|
||||||
|
MY_SCHEDULE_QUERY,
|
||||||
MY_TREND_QUERY,
|
MY_TREND_QUERY,
|
||||||
MY_WEAKNESS_QUERY,
|
MY_WEAKNESS_QUERY,
|
||||||
RECORD_EXAM_VIOLATION_MUTATION,
|
RECORD_EXAM_VIOLATION_MUTATION,
|
||||||
RECORD_PASTE_EVENT_MUTATION,
|
RECORD_PASTE_EVENT_MUTATION,
|
||||||
|
REQUEST_EXTENSION_MUTATION,
|
||||||
SAVE_EXAM_DRAFT_MUTATION,
|
SAVE_EXAM_DRAFT_MUTATION,
|
||||||
SERVER_TIME_QUERY,
|
SERVER_TIME_QUERY,
|
||||||
STUDENT_DASHBOARD_QUERY,
|
STUDENT_DASHBOARD_QUERY,
|
||||||
|
STUDENT_GROWTH_QUERY,
|
||||||
SUBMIT_EXAM_MUTATION,
|
SUBMIT_EXAM_MUTATION,
|
||||||
SUBMIT_HOMEWORK_MUTATION,
|
SUBMIT_HOMEWORK_MUTATION,
|
||||||
TEXTBOOKS_QUERY,
|
TEXTBOOKS_QUERY,
|
||||||
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
UPDATE_NOTIFICATION_PREFERENCE_MUTATION,
|
||||||
|
UPDATE_PROFILE_MUTATION,
|
||||||
} from "./operations";
|
} from "./operations";
|
||||||
|
|
||||||
// hooks
|
// hooks
|
||||||
export {
|
export {
|
||||||
|
useAssignmentAnalysis,
|
||||||
|
useChangePassword,
|
||||||
useChapters,
|
useChapters,
|
||||||
useCurrentUser,
|
useCurrentUser,
|
||||||
useExamDetail,
|
useExamDetail,
|
||||||
useHomeworkDetail,
|
useHomeworkDetail,
|
||||||
|
useJoinClass,
|
||||||
|
useLeaveClass,
|
||||||
useLearningPath,
|
useLearningPath,
|
||||||
useMarkAllAsRead,
|
useMarkAllAsRead,
|
||||||
useMarkAsRead,
|
useMarkAsRead,
|
||||||
@@ -129,17 +157,22 @@ export {
|
|||||||
useMyGrades,
|
useMyGrades,
|
||||||
useMyHomework,
|
useMyHomework,
|
||||||
useMyNotifications,
|
useMyNotifications,
|
||||||
|
useMyProfile,
|
||||||
|
useMySchedule,
|
||||||
useMyTrend,
|
useMyTrend,
|
||||||
useMyWeakness,
|
useMyWeakness,
|
||||||
useRecordExamViolation,
|
useRecordExamViolation,
|
||||||
useRecordPasteEvent,
|
useRecordPasteEvent,
|
||||||
|
useRequestExtension,
|
||||||
useSaveExamDraft,
|
useSaveExamDraft,
|
||||||
useServerTime,
|
useServerTime,
|
||||||
useStudentDashboard,
|
useStudentDashboard,
|
||||||
|
useStudentGrowth,
|
||||||
useSubmitExam,
|
useSubmitExam,
|
||||||
useSubmitHomework,
|
useSubmitHomework,
|
||||||
useTextbooks,
|
useTextbooks,
|
||||||
useUpdateNotificationPreference,
|
useUpdateNotificationPreference,
|
||||||
|
useUpdateProfile,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
|
|
||||||
export type { UseMutationResult, UseQueryResult } from "./hooks";
|
export type { UseMutationResult, UseQueryResult } from "./hooks";
|
||||||
|
|||||||
@@ -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;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 扩展实体类型(4 查询 + 5 变更)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 课表项(周视图) */
|
||||||
|
export interface ScheduleItem {
|
||||||
|
id: string;
|
||||||
|
/** 0=周日, 1=周一, ..., 6=周六 */
|
||||||
|
dayOfWeek: number;
|
||||||
|
/** "08:00" */
|
||||||
|
startTime: string;
|
||||||
|
/** "08:45" */
|
||||||
|
endTime: string;
|
||||||
|
subject: string;
|
||||||
|
teacher: string;
|
||||||
|
classroom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成长曲线数据点(myScore vs classAverage) */
|
||||||
|
export interface GrowthDataPoint {
|
||||||
|
date: string;
|
||||||
|
myScore: number;
|
||||||
|
classAverage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业分析题目项 */
|
||||||
|
export interface AssignmentAnalysisQuestion {
|
||||||
|
questionId: string;
|
||||||
|
questionText: string;
|
||||||
|
questionType: string;
|
||||||
|
correctRate: number;
|
||||||
|
referenceAnswer: string;
|
||||||
|
knowledgePoints: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业分析结果 */
|
||||||
|
export interface AssignmentAnalysis {
|
||||||
|
homeworkId: string;
|
||||||
|
title: string;
|
||||||
|
totalScore: number;
|
||||||
|
avgScore: number;
|
||||||
|
maxScore: number;
|
||||||
|
minScore: number;
|
||||||
|
submitCount: number;
|
||||||
|
questions: AssignmentAnalysisQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生个人资料 */
|
||||||
|
export interface StudentProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
studentNo: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
gender: string;
|
||||||
|
avatar: string;
|
||||||
|
grade: string;
|
||||||
|
classId: string;
|
||||||
|
className: string;
|
||||||
|
bio: string;
|
||||||
|
preferredSubjectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mutation 输入类型
|
// Mutation 输入类型
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -405,6 +468,42 @@ export interface UpdateNotificationPreferenceInput {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 扩展 Mutation 输入类型
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 更新个人资料输入 */
|
||||||
|
export interface UpdateProfileInput {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
gender?: string;
|
||||||
|
bio?: string;
|
||||||
|
preferredSubjectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改密码输入 */
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业延期申请输入 */
|
||||||
|
export interface RequestExtensionInput {
|
||||||
|
homeworkId: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加入班级输入 */
|
||||||
|
export interface JoinClassInput {
|
||||||
|
inviteCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出班级输入 */
|
||||||
|
export interface LeaveClassInput {
|
||||||
|
classId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mutation 响应数据类型
|
// Mutation 响应数据类型
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -458,6 +557,48 @@ export interface UpdateNotificationPreferenceResult {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 扩展 Mutation 响应数据类型
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 更新个人资料响应 */
|
||||||
|
export interface UpdateProfileResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
gender: string;
|
||||||
|
bio: string;
|
||||||
|
preferredSubjectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改密码响应 */
|
||||||
|
export interface ChangePasswordResult {
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作业延期申请响应 */
|
||||||
|
export interface RequestExtensionResult {
|
||||||
|
homeworkId: string;
|
||||||
|
approved: boolean;
|
||||||
|
newDueDate: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加入班级响应 */
|
||||||
|
export interface JoinClassResult {
|
||||||
|
classId: string;
|
||||||
|
className: string;
|
||||||
|
grade: string;
|
||||||
|
inviteCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出班级响应 */
|
||||||
|
export interface LeaveClassResult {
|
||||||
|
classId: string;
|
||||||
|
ok: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GraphQL 操作名常量(16 查询 + 8 变更)
|
// GraphQL 操作名常量(16 查询 + 8 变更)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -479,7 +620,11 @@ export type QueryOperationName =
|
|||||||
| "myWeakness"
|
| "myWeakness"
|
||||||
| "myTrend"
|
| "myTrend"
|
||||||
| "myNotifications"
|
| "myNotifications"
|
||||||
| "serverTime";
|
| "serverTime"
|
||||||
|
| "mySchedule"
|
||||||
|
| "studentGrowth"
|
||||||
|
| "assignmentAnalysis"
|
||||||
|
| "myProfile";
|
||||||
|
|
||||||
/** 变更操作名 */
|
/** 变更操作名 */
|
||||||
export type MutationOperationName =
|
export type MutationOperationName =
|
||||||
@@ -490,7 +635,12 @@ export type MutationOperationName =
|
|||||||
| "markAllAsRead"
|
| "markAllAsRead"
|
||||||
| "recordExamViolation"
|
| "recordExamViolation"
|
||||||
| "recordPasteEvent"
|
| "recordPasteEvent"
|
||||||
| "updateNotificationPreference";
|
| "updateNotificationPreference"
|
||||||
|
| "updateProfile"
|
||||||
|
| "changePassword"
|
||||||
|
| "requestExtension"
|
||||||
|
| "joinClass"
|
||||||
|
| "leaveClass";
|
||||||
|
|
||||||
/** 全部操作名 */
|
/** 全部操作名 */
|
||||||
export type OperationName = QueryOperationName | MutationOperationName;
|
export type OperationName = QueryOperationName | MutationOperationName;
|
||||||
@@ -521,6 +671,10 @@ export interface QueryResponses {
|
|||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
}>;
|
}>;
|
||||||
serverTime: ActionState<ServerTime>;
|
serverTime: ActionState<ServerTime>;
|
||||||
|
mySchedule: ActionState<ScheduleItem[]>;
|
||||||
|
studentGrowth: ActionState<GrowthDataPoint[]>;
|
||||||
|
assignmentAnalysis: ActionState<AssignmentAnalysis>;
|
||||||
|
myProfile: ActionState<StudentProfile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 变更响应映射 */
|
/** 变更响应映射 */
|
||||||
@@ -533,6 +687,11 @@ export interface MutationResponses {
|
|||||||
recordExamViolation: ActionState<RecordExamViolationResult>;
|
recordExamViolation: ActionState<RecordExamViolationResult>;
|
||||||
recordPasteEvent: ActionState<RecordPasteEventResult>;
|
recordPasteEvent: ActionState<RecordPasteEventResult>;
|
||||||
updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>;
|
updateNotificationPreference: ActionState<UpdateNotificationPreferenceResult>;
|
||||||
|
updateProfile: ActionState<UpdateProfileResult>;
|
||||||
|
changePassword: ActionState<ChangePasswordResult>;
|
||||||
|
requestExtension: ActionState<RequestExtensionResult>;
|
||||||
|
joinClass: ActionState<JoinClassResult>;
|
||||||
|
leaveClass: ActionState<LeaveClassResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 myTrendFixture from "./fixtures/myTrend.json";
|
||||||
import myNotificationsFixture from "./fixtures/myNotifications.json";
|
import myNotificationsFixture from "./fixtures/myNotifications.json";
|
||||||
import serverTimeFixture from "./fixtures/serverTime.json";
|
import serverTimeFixture from "./fixtures/serverTime.json";
|
||||||
|
import myScheduleFixture from "./fixtures/mySchedule.json";
|
||||||
|
import studentGrowthFixture from "./fixtures/studentGrowth.json";
|
||||||
|
import assignmentAnalysisFixture from "./fixtures/assignmentAnalysis.json";
|
||||||
|
import myProfileFixture from "./fixtures/myProfile.json";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GraphQL 请求/响应类型
|
// GraphQL 请求/响应类型
|
||||||
@@ -253,6 +257,24 @@ function handleQuery(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "mySchedule":
|
||||||
|
return graphqlOk("mySchedule", ok(myScheduleFixture));
|
||||||
|
|
||||||
|
case "studentGrowth":
|
||||||
|
return graphqlOk("studentGrowth", ok(studentGrowthFixture));
|
||||||
|
|
||||||
|
case "assignmentAnalysis": {
|
||||||
|
const homeworkId = variables["homeworkId"] ?? "hw-001";
|
||||||
|
const detail =
|
||||||
|
homeworkId === assignmentAnalysisFixture.homeworkId
|
||||||
|
? assignmentAnalysisFixture
|
||||||
|
: { ...assignmentAnalysisFixture, homeworkId: homeworkId as string };
|
||||||
|
return graphqlOk("assignmentAnalysis", ok(detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "myProfile":
|
||||||
|
return graphqlOk("myProfile", ok(myProfileFixture));
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// 未覆盖的查询操作名,返回错误
|
// 未覆盖的查询操作名,返回错误
|
||||||
return graphqlError(
|
return graphqlError(
|
||||||
@@ -350,6 +372,67 @@ function handleMutation(
|
|||||||
return graphqlOk("updateNotificationPreference", ok(result));
|
return graphqlOk("updateNotificationPreference", ok(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "updateProfile": {
|
||||||
|
const input = (variables["input"] ?? {}) as Record<string, unknown>;
|
||||||
|
const result = {
|
||||||
|
id: myProfileFixture.id,
|
||||||
|
name: (input["name"] as string) ?? myProfileFixture.name,
|
||||||
|
email: (input["email"] as string) ?? myProfileFixture.email,
|
||||||
|
phone: (input["phone"] as string) ?? myProfileFixture.phone,
|
||||||
|
gender: (input["gender"] as string) ?? myProfileFixture.gender,
|
||||||
|
bio: (input["bio"] as string) ?? myProfileFixture.bio,
|
||||||
|
preferredSubjectId:
|
||||||
|
(input["preferredSubjectId"] as string) ??
|
||||||
|
myProfileFixture.preferredSubjectId,
|
||||||
|
};
|
||||||
|
return graphqlOk("updateProfile", ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "changePassword": {
|
||||||
|
return graphqlOk("changePassword", ok({ ok: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "requestExtension": {
|
||||||
|
const input = (variables["input"] ?? {}) as Record<string, unknown>;
|
||||||
|
const homeworkId =
|
||||||
|
(input["homeworkId"] as string) ?? "hw-001";
|
||||||
|
const reason = (input["reason"] as string) ?? "";
|
||||||
|
const newDue = new Date();
|
||||||
|
newDue.setDate(newDue.getDate() + 2);
|
||||||
|
const result = {
|
||||||
|
homeworkId,
|
||||||
|
approved: true,
|
||||||
|
newDueDate: newDue.toISOString(),
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
return graphqlOk("requestExtension", ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "joinClass": {
|
||||||
|
const input = (variables["input"] ?? {}) as Record<string, unknown>;
|
||||||
|
const inviteCode = (input["inviteCode"] as string) ?? "";
|
||||||
|
if (inviteCode.length !== 6) {
|
||||||
|
return graphqlOk(
|
||||||
|
"joinClass",
|
||||||
|
fail(ERROR_CODES.BFF_STUDENT_VALIDATION_FAILED, "邀请码必须为 6 位"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
classId: `class-${Date.now()}`,
|
||||||
|
className: "高三(3)班",
|
||||||
|
grade: "高三",
|
||||||
|
inviteCode,
|
||||||
|
};
|
||||||
|
return graphqlOk("joinClass", ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "leaveClass": {
|
||||||
|
const input = (variables["input"] ?? {}) as Record<string, unknown>;
|
||||||
|
const classId = (input["classId"] as string) ?? "";
|
||||||
|
const result = { classId, ok: true };
|
||||||
|
return graphqlOk("leaveClass", ok(result));
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// 未覆盖的变更操作名,返回错误
|
// 未覆盖的变更操作名,返回错误
|
||||||
return graphqlError(
|
return graphqlError(
|
||||||
@@ -382,6 +465,10 @@ const QUERY_NAMES = new Set<QueryOperationName>([
|
|||||||
"myTrend",
|
"myTrend",
|
||||||
"myNotifications",
|
"myNotifications",
|
||||||
"serverTime",
|
"serverTime",
|
||||||
|
"mySchedule",
|
||||||
|
"studentGrowth",
|
||||||
|
"assignmentAnalysis",
|
||||||
|
"myProfile",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** 变更操作名集合 */
|
/** 变更操作名集合 */
|
||||||
@@ -394,6 +481,11 @@ const MUTATION_NAMES = new Set<MutationOperationName>([
|
|||||||
"recordExamViolation",
|
"recordExamViolation",
|
||||||
"recordPasteEvent",
|
"recordPasteEvent",
|
||||||
"updateNotificationPreference",
|
"updateNotificationPreference",
|
||||||
|
"updateProfile",
|
||||||
|
"changePassword",
|
||||||
|
"requestExtension",
|
||||||
|
"joinClass",
|
||||||
|
"leaveClass",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -475,6 +567,10 @@ export const fixtures = {
|
|||||||
myTrend: myTrendFixture,
|
myTrend: myTrendFixture,
|
||||||
myNotifications: myNotificationsFixture,
|
myNotifications: myNotificationsFixture,
|
||||||
serverTime: serverTimeFixture,
|
serverTime: serverTimeFixture,
|
||||||
|
mySchedule: myScheduleFixture,
|
||||||
|
studentGrowth: studentGrowthFixture,
|
||||||
|
assignmentAnalysis: assignmentAnalysisFixture,
|
||||||
|
myProfile: myProfileFixture,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 导出工具函数供测试使用 */
|
/** 导出工具函数供测试使用 */
|
||||||
|
|||||||
@@ -299,46 +299,7 @@ gantt
|
|||||||
- **文档阶段**:✅ 完成(01-understanding + 02-architecture-design + contract + workline + issue 全部对齐 ARB-019 裁决)
|
- **文档阶段**:✅ 完成(01-understanding + 02-architecture-design + contract + workline + issue 全部对齐 ARB-019 裁决)
|
||||||
- **代码实现阶段**:✅ 已完成(通过 `606acb6` 合并到 main,包含 P2-P6 全部代码)
|
- **代码实现阶段**:✅ 已完成(通过 `606acb6` 合并到 main,包含 P2-P6 全部代码)
|
||||||
- **集成测试阶段**:✅ 已完成(通过 `61d8249` + `a6b0c54` 修复全部集成测试 bug)
|
- **集成测试阶段**:✅ 已完成(通过 `61d8249` + `a6b0c54` 修复全部集成测试 bug)
|
||||||
- **待办**:参考项目差距分析(见 §9)
|
- **待办**:无未完成的架构工作项(contract.md + workline.md + 02-architecture-design.md 全部对齐 ARB-019 裁决)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## §9 参考项目差距分析与实现安排(2026-07-13)
|
|
||||||
|
|
||||||
### §9.1 参考项目功能清单
|
|
||||||
|
|
||||||
参考项目 `E:\Desktop\edunexus-pro (4)`(Next.js 生产级实现)中学生端已实现的功能:
|
|
||||||
|
|
||||||
| # | 功能 | 参考项目实现 | 当前项目状态 |
|
|
||||||
|---|------|-------------|-------------|
|
|
||||||
| 1 | 学生仪表盘 | 4 统计卡 + 成长曲线 vs 班级平均 + 能力雷达 + 薄弱知识点 + 通知列表 + 近期作业 | ✅ 已实现(缺今日课表 + 班级平均对比) |
|
|
||||||
| 2 | 学生作业列表 | 多维筛选(学科/考试类型/状态)+ 延期申请 + 继续答题 | ✅ 已实现(缺多维筛选 + 延期申请) |
|
|
||||||
| 3 | 学生考试作答 | 文本/手写板/图片三种作答 + localStorage 自动保存 + 答题卡树形结构 | ✅ 已实现(缺手写板 + 图片上传 + 树形答题卡) |
|
|
||||||
| 4 | 学生考试结果 | 教师批注 canvas 叠加 + 手写作答渲染 + 全局/单题批注切换 | ✅ 已实现(缺教师批注叠加) |
|
|
||||||
| 5 | 学生班级 | 加入班级(6 位邀请码)+ 退出班级 + 仅看成员 | ✅ 已实现(缺加入/退出班级) |
|
|
||||||
| 6 | 学生课表 | 周课表网格视图(学生只读) | ❌ 未实现 |
|
|
||||||
| 7 | 消息中心 | 全部/未读筛选 + 消息详情 + 标记已读 | ✅ 已实现(通知中心) |
|
|
||||||
| 8 | 个人设置 | 个人资料 + 偏好学科 + 安全设置(修改密码) | ❌ 未实现 |
|
|
||||||
| 9 | 作业分析 | 试卷详解 + 数据总览 + 知识点定位 | ❌ 未实现 |
|
|
||||||
| 10 | 手写板作答 | Fabric.js Canvas 手写 + 自动保存 | ❌ 未实现 |
|
|
||||||
| 11 | 图片上传作答 | FileReader → dataURL + 上传到服务器 | ❌ 未实现 |
|
|
||||||
| 12 | 作业延期申请 | 弹出 prompt 输入理由 → API 调用 | ❌ 未实现 |
|
|
||||||
|
|
||||||
### §9.2 差距实现安排
|
|
||||||
|
|
||||||
| 批次 | 功能 | 优先级 | 实现方式 |
|
|
||||||
|------|------|--------|----------|
|
|
||||||
| A | 手写板组件(HandwritingPad) | 高 | 新建 `src/components/handwriting-pad.tsx`,Fabric.js Canvas |
|
|
||||||
| A | 图片上传作答 | 高 | 考试作答页 + 作业提交页增加图片上传 |
|
|
||||||
| A | 教师批注叠加显示 | 高 | 考试结果页增加 canvas 批注渲染 |
|
|
||||||
| A | 作业延期申请 | 高 | 作业列表页增加延期申请按钮 + mutation |
|
|
||||||
| A | 加入/退出班级 | 高 | 班级页增加 JoinClassModal + leaveClass |
|
|
||||||
| B | 课表查看页 `/schedule` | 中 | 新建页面 + GraphQL query |
|
|
||||||
| B | 个人设置页 `/settings` | 中 | 新建页面 + profile mutation |
|
|
||||||
| B | 作业分析页 `/my-homework/[id]/analysis` | 中 | 新建页面 + GraphQL query |
|
|
||||||
| B | 作业多维筛选 | 中 | 作业列表页增加筛选器 |
|
|
||||||
| C | Dashboard 增强 | 低 | 添加今日课表卡片 + 成长曲线 vs 班级平均 |
|
|
||||||
| C | 答题卡树形结构 | 低 | 考试作答页答题卡改为树形 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user