项目开发日志 #2简易在线考试系统?
写在前面
本文章为学校课程【软件开发与创新】的作业文章,存在一定程度的简写/概括/作业模板等
观看本文你可能需要了解的:TypeScript、React、Next.js、Node.js、Supabase、Crow、C++?
本文为了理解方便,写了非常多的流水账以及代码解释,但是仍然推荐读者阅读至少React的中文文档,否则可能有比较大的阅读困难
代码以及很多实现细节放在了下拉框中,请点开详情查看哦
这次是结对编程,和学号2452107的花花米同学一起完成本次项目
首先我们有了这样一个题目

由于又是在线系统这种东西,所以仍然还是考虑做一个web项目
这次最难搞的是技术栈分配,虽然本人是全栈但是由于是结对编程,所以考虑到要做兼容,所以思考是让node或者别的什么去调用cpp程序,具体实现方式后面写到了再讲,思路是这样的
而为了尽量减轻工作量,所以挑选了一些比较简单的模块分离给cpp那边(答案规范化、单题判对错、单题计分、整卷判分),剩下的就交给node了
node这边呢,仍然是react+nextjs老组合,出于轻量考虑也是继续用baas(supabase)来处理数据之类的
大概有了这样那样的想法以后就开始新建文件夹了
前期准备
项目创建
一如即往的新建Nextjs项目,supabase那边给了一个更好的初始化方案,以及这次项目名就统一叫exam-system了
npx create-next-app -e with-supabase
这样就自带supabase了,不仅如此,它还会自带一个auth的示例,相当于不用再手动从头开发一遍auth了,只需要简单改一改就能用
接下来在supabase官网新建一个项目,然后获取项目的url以及对应的api key,变量然后更改项目文件夹下的环境变量.env
顺带一提supabase现在的引导做的特别好,可以直接访问这个链接照着配置
这样就能连接到supabase平台了,先npm run dev试一下

⬆️这个页面老朋友了
路由设计
经过两个人讨论,大概得出了下面的草稿

考虑到用户应该有两种类型的身份(教师/学生),因此设计路由时也从这两个方向考虑,第二部分就是教师相关的路由、第三部分就是学生相关
还有一些杂项例如个人信息之类的,这些留到后面有一个加一个
前端设计
从朋友那里知道了google最近做了一个很吊的东西——stitch
简单来说就是前端设计也可以直接交给AI处理,并且返回可实时阅览的结果,还能直接生成html或者生成prompt(不过问题是这个东西国内好像没开放)
虽然说本人自称是全栈工程师但是这么短的时间做完一套完整的前端设计还是太难了,于是这次就来试试看用vibe coding解决前端吧
通过一些简单的prompt就可以实现下面的效果,每一页都会单独规划出来,甚至还可以做动效之类的
然后只需要做一些细调就好了,再加上本来也不是不会做前端开发,其实有这个框架就能解决很多问题,具体的细节都可以慢慢再改

将生成的html文件直接丢给codex,然后我们就得到了这个

蒽有很多神秘占位符,从这里开始慢慢开始一点点补全server actions吧
功能开发
[collapse title=开发前的小插曲/关于next防跨域攻击的更新]
第一次打开的时候手快使用了127.0.0.1:3000,结果发现它会像下图那样什么都显示不出来。并且看控制台它一直在试图连接websocket并且失败

注意到后端控制台有这样的一条消息
⚠ Blocked cross-origin request to Next.js dev resource /_next/webpack-hmr from "127.0.0.1".
Cross-origin access to Next.js dev resources is blocked by default for safety.
To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server:
// next.config.js
module.exports = {
allowedDevOrigins: ['127.0.0.1'],
}
于是这才知道,似乎是因为nextjs更新了一个防跨域攻击的防护,导致把我的hmr(热重载)给拦了,由于浏览器判断是否同源是严格的,也就是说127.0.0.1其实不等价于localhost,然后就认定127是不安全的(
日志里也给了一个可以消除这种问题的方法,直接放行127,但好像目前没有一定要这么做的必要就不管了
[/collapse]
初始页

首页并不需要什么复杂的查询或者跳转
因为supabase提供了非常好的中间件,所以”开启学术之旅“的btn可以直通dashboard,如果没有登录态会被拦截掉,直接进入auth路由
登录与注册
注册
从注册账号开始,supabase提供的模板里默认带一个邮箱注册的方法,所以只要把前端之类的稍微修修就可以了

[collapse title=具体的signup逻辑]
我们将创建账号右侧这个表单部分单独打包成了一个组件
export function RegisterForm() {
const router = useRouter();
const [state, formAction, pending] = useActionState(
signUpWithSupabase,
INITIAL_FORM_STATE,
);
useEffect(() => {
if (state.success) {
router.push("/auth/sign-up-success");
}
}, [router, state.success]);
return (
<form action={formAction} className="space-y-5">
<input
className="w-full rounded-t-sm border-0 border-b-2 border-[var(--curator-outline-variant)] bg-[var(--curator-surface-highest)] px-4 py-4 ring-0 outline-none transition focus:border-[var(--curator-primary)]"
name="name"
placeholder="姓名"
type="text"
/>
<input
className="w-full rounded-t-sm border-0 border-b-2 border-[var(--curator-outline-variant)] bg-[var(--curator-surface-highest)] px-4 py-4 ring-0 outline-none transition focus:border-[var(--curator-primary)]"
name="email"
placeholder="邮箱地址"
type="email"
/>
<input
className="w-full rounded-t-sm border-0 border-b-2 border-[var(--curator-outline-variant)] bg-[var(--curator-surface-highest)] px-4 py-4 ring-0 outline-none transition focus:border-[var(--curator-primary)]"
name="password"
placeholder="密码"
type="password"
/>
<input
className="w-full rounded-t-sm border-0 border-b-2 border-[var(--curator-outline-variant)] bg-[var(--curator-surface-highest)] px-4 py-4 ring-0 outline-none transition focus:border-[var(--curator-primary)]"
name="repeatPassword"
placeholder="确认密码"
type="password"
/>
<AuthSubmitButton
className="cta-gradient w-full rounded-full px-6 py-4 text-base font-bold text-white transition-opacity hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-65"
idleText="注册"
pendingText="创建中..."
/>
{pending ? null : state.message ? (
<p className="rounded-xl bg-[var(--curator-surface-low)] px-3 py-2 text-sm text-[var(--curator-on-surface-variant)]">
{state.message}
</p>
) : null}
</form>
);
}
可以看到,我们使用了useActionState来实现表单提交的逻辑,这个钩子的用法是这样的:
const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);
permalink是前面action失效时的兜底url
用非常简答的语言描述的话:用户提交表单时,action调用formAction即对应fn的函数,然后用函数返回值去更新state然后开始重渲染——然后useEffect在state渲染完成以后,去router.push跳转到显示注册成功(发送注册邮件成功)的页面。
顺带一提,由于为了“闭包”,react要求[hl]effect里用到的所有外部变量,都应该写进useEffect的依赖数组[/hl]
看完组件层以后再看看signUpWithSupabase是长什么样的
async function signUpWithSupabase(
_prevState: CuratorFormState,
formData: FormData,
): Promise<CuratorFormState> {
const supabase = createClient();
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const repeatPassword = String(formData.get("repeatPassword") ?? "");
const role = String(formData.get("role") ?? "student");
if (!name) {
return { success: false, message: "请输入姓名。" };
}
if (password !== repeatPassword) {
return { success: false, message: "两次输入的密码不一致。" };
}
if (role !== "student" && role !== "teacher") {
return { success: false, message: "请选择注册身份。" };
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/dashboard`,
data: { full_name: name, role },
},
});
if (error) {
if (isAlreadyRegisteredError(error.code, error.message)) {
return { success: false, message: "该邮箱已注册,请直接登录或使用忘记密码。" };
}
return { success: false, message: error.message };
}
return { success: true, message: "" };
}
对于所有传给useActionState的action都应该保持(prevState, formData) => newState这个签名
因此首先包了一层signUpWithSupabase然后做数据正确性检测,然后再调用supabase提供的(password-based)supabase.auth.signUp(),传入的参数都很好理解,就是option里多了一个data,这里是为了注册的时候带入一些初值,比如这里是带一个名字,不然注册的时候名字会变空
调用这个函数的时候supabase就会处理后面生成token还有发送邮件之类的逻辑,都不用我们处理
我们要处理的就是怎么处理(通过邮箱链接)获得的token,直接复制supabase文档里给的写法就好
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/";
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
});
if (!error) {
// redirect user to specified redirect URL or root of app
redirect(next);
} else {
// redirect the user to an error page with some instructions
redirect(`/auth/error?error=${error?.message}`);
}
}
// redirect the user to an error page with some instructions
redirect(`/auth/error?error=No token hash or type`);
}
至此就完成了完整的注册逻辑
[/collapse]
注册后会给邮箱发一条验证邮件,为了防止这个邮件太丑,顺带美化了一下(supabase提供了多种邮件模板,对着改html就好)


虽然看起来有点像广告病毒投放小邮件嗯啊
登录
简单改造以后长这样

并没有什么好修的,它的登录流程和注册流程几乎一致,都是formAction,这里就不赘述了
个人信息页
为什么不先做dashboard呢?因为dashboard的信息太多了一次很难搞完,先从小部分开始慢慢拼吧

插一嘴,现在的页面布局是类似于这样的
<ConsoleLayout>
<TopBar title="..." />
<main>
{children}
</main>
</ConsoleLayout>
ConsoleLayout里面包含了Sidebar,这样所有的页面布局都统一了,只需要考虑内部的main怎么写就可以了
目前来说这个界面被拆成了ProfileInfo(上面一个card)和ProfileSettings(下面的)两个组件
分析一下这个profile实际需要实现的部分
- 最中心的姓名、头像、工号、邮箱、还有称号?当然这些需要有对应修改的地方
- 右上角也需要一个头像和名字,也就是整个layout的信息显示
- 下面需要做账号设置
- 修改密码
- 通知?可能不是很好做,也许会删掉
- 安全与隐私也一下子想不到要做什么,可能这两个都会搁置
然后注意到这个profile是教师和同学共用的,所以可能需要一些身份切换的地方
个人信息展示
由于个人信息是全局都会频繁使用的,所以为了提高效率,我们为profile自定义了一个钩子useProfileContext,并且对应有一个组件profileProvider
[collapse title=profileContext的工作方法]
具体地说,provider将包裹所有需要用到profile的地方,将数据共享给组件树,这个“数据”甚至可以不只是数据也可以是函数。因此可以将所有和profile相关的内容都打包进去
对于useContext的用法可以看这篇官方文档
先看主要代码对着来讲
"use client";
import { refreshProfileAction, updateProfileAction } from "@/actions/profile";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { Profile } from "../lib/type/profile";
type ProfileContextValue = {
profile: Profile | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
updateProfile: (payload: Partial<Profile>) => Promise<void>;
};
const ProfileContext = createContext<ProfileContextValue | null>(null);
export function ProfileProvider({
children,
initialProfile,
}: {
children: React.ReactNode;
initialProfile: Profile | null;
}) {
const [profile, setProfile] = useState<Profile | null>(initialProfile);
const [loading, setLoading] = useState(initialProfile === null);
const [error, setError] = useState<string | null>(null);
const initializedRef = useRef(false);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await refreshProfileAction();
if (result?.profile !== undefined) {
setProfile(result.profile);
}
} catch {
setError("加载个人信息失败");
}
setLoading(false);
}, []);
const updateProfile = useCallback(async (payload: Partial<Profile>) => {
setLoading(true);
setError(null);
const result = await updateProfileAction(payload);
if (!result.ok) {
setError(result.error ?? "更新失败");
setLoading(false);
return;
}
setProfile(result.profile ?? null);
setLoading(false);
}, []);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
if (initialProfile !== null) return;
refresh();
}, [initialProfile, refresh]);
return (
<ProfileContext
value={{ profile, loading, error, refresh, updateProfile }}
>
{children}
</ProfileContext>
);
}
export function useProfileContext() {
const ctx = useContext(ProfileContext);
if (!ctx) throw new Error("ProfileProvider 未包裹");
return ctx;
}
分为两部分,第一部分是provider,第二部分是hook,hook很简单,只要通过useContext就能获取上下文
[buy]其实甚至可以不需要定义这个hook,直接在需要profile的地方useContext(ProfileContext)也行,但是为了代码简洁可读性高,所以多封装一层[/buy]
useContext()里的ProfileContext通过createContext()创建,创建后返回的ProfileContext是一个可渲染组件对象,它接受一个参数value,代表这次注入的上下文的内容
我们将“上下文内容+通道”打包成了一个provider,这样就有了一个统一的入口
此处为了让客户端去获取数据渲染防止出现一些神秘报错,使用了一个InitialProfileRef,来负责确认让profile只初始化一次,通过这样就能放心的让initialProfile直接传一个null进来了
再看它接受的value的具体内容:profile, loading, error, refresh, updateProfile,前面三个都是state,后面两个是对应的工具函数,均用了useCallback进行函数缓存提高性能
这样,我们只需要在client component里像下面这样调用就好了
const { profile, loading, updateProfile, refresh } = useProfileContext();
[/collapse]
需要profile的地方有两个,一个是中间的profileInfo,一个是在topbar上的头像
我们只需要在用到profile的地方用ProfileProvider包裹即可,为了方便可以直接在layout里包裹一个
然后具体hook的用法,这里展示一个profileInfo组件的写法,topbar的部分类似
'use client'
import { useProfileContext } from "@/utils/profileProvider";
export default function ProfileInfo() {
const { profile, loading } = useProfileContext();
if (loading && !profile) {
return <ProfileInfoSkeleton />;
}
const username = profile?.username ?? "未设置昵称";
const role = profile?.role ?? "学生";
const avatarText = username.slice(0, 1).toUpperCase();
return (
//...
)
}
这里还顺手写了一个loading skeleton,优化了加载体验,现在就长这样了,拜拜了陈慕华和朱利安

个人信息编辑
这里主要的难点是头像上传与裁剪,其他信息比较简单,直接原模原样写入数据库就可以了
虽然之前写过了头像上传裁剪,但似乎有点臃肿,这里使用了一个开源仓库的组件shadcn-image-cropper,源码就不贴了可以直接去github仓库里看,这里引用了组件并且稍微做了一点修改(supabase storage上传的是buffer但是这个组件返回的是图片的base64,所以修改了一下返回值)

接下来对接实际头像上传持久化,我们先在supabase storage里新建一个bucket叫avatars

虽然设成了public,但是upsert仍然受RLS控制,卧槽这个RLS又来了,设置一下upsert相关的RLS
create policy "avatars authenticated insert"
on storage.objects
for insert
to authenticated
with check (bucket_id = 'avatars');
create policy "avatars authenticated select"
on storage.objects
for select
to authenticated
using (bucket_id = 'avatars');
create policy "avatars authenticated update"
on storage.objects
for update
to authenticated
using (bucket_id = 'avatars')
with check (bucket_id = 'avatars');
然后回到ProfileInfo组件这边,我们是这么用ImageCropper组件的
<ImageCropper
imageSrc={selectedFile?.preview ?? null}
onApply={handleAvatarCropApply}
onCancel={handleAvatarCropCancel}
onOpenChange={setIsCropperOpen}
open={isCropperOpen}
/>
写一下组件onApply对应的回调函数
[collapse title=handleAvatarCropApply]
const handleAvatarCropApply = async (buffer: ArrayBuffer) => {
const file = new File([buffer], `${crypto.randomUUID()}.png`, {
type: "image/png",
});
const formData = new FormData();
formData.set("file", file);
try {
const upload = await uploadAvatarAction(formData);
if (!upload.ok || !upload.url) {
alert("上传头像失败,请重试");
return;
}
setLocalAvatarPreview(upload.url);
await updateProfile({ avatar_url: upload.url });
} catch {
alert("上传头像失败,请重试");
} finally {
clearSelectedFile();
}
};
[/collapse]
对应的upload的action如下
[collapse title=uploadAvatarAction]
export async function uploadAvatarAction(formData: FormData) {
const supabase = await createClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return { ok: false, error: "login_required" };
}
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return { ok: false, error: "missing_file" };
}
const invalid = validateFile(file, AVATAR_MAX_FILE_SIZE, AVATAR_MIME_TYPES);
if (invalid) {
return { ok: false, error: invalid };
}
const ext = AVATAR_MIME_TYPES.get(file.type) ?? "png";
const key = `${crypto.randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const { error } = await supabase.storage.from("avatars").upload(key, buffer, {
contentType: file.type,
upsert: true,
});
if (error) {
console.error("upload avatar failed", error);
return { ok: false, error: error.message };
}
const { data: publicUrl } = supabase.storage.from("avatars").getPublicUrl(key);
return { ok: true, url: publicUrl.publicUrl };
}
[/collapse]
顺手把右上角的头像也显示成实际的头像以后,现在就能上传头像了!并且怎么刷新都不会掉哦

接下来是其他个人信息的修改,就比较简单了
[collapse title=个人信息上传]
使用form上传,给保存的button绑定了type为submit,然后form的onSubmit回调为handleSaveProfile
const handleSaveProfile = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); //阻止浏览器的默认行为
try {
await updateProfile({
username: draft.username,
introduction: draft.introduction,
});
} catch {
alert("更新个人信息失败,请重试");
} finally {
setIsEditing(false);
}
其中updateProfile上面展示过了不再赘述
[/collapse]
最后就全部都能持久化了,剩下三个先不做了谁爱做谁做吧!

题库中心
接下来开始题库,对于这个页面,我们分为了SectionIntro(上面的标题与介绍)、QuestionFilters(筛选学科与难度)、QuestionCards(剩下多个cards)。其中SectionIntro是可在多个页面复用的,QuestionCards点击查看解析后会跳出drawer
需要交互逻辑的是filters和cards


数据表设计
对于一道题目,我们需要的信息/数据有:
- 题号:#{题目学科}-{id}
- 所属学科:(数学)
- 所属二级分类:(微积分II)
- 难度:专家级
- 题目内容(题面以及选项(如果有的话))
- 题目类型:单选/多选/判断/填空
- 答案:(如果有的话)
- 解析:(如果有的话)
- 正确率:(存冗余列,由尝试次数和正确次数计算得到)
对于一次考试,我们需要的有:
- 考试码(可以直接用uuid )
- 考试名
- 考试开始时间
- 考试结束时间
- 考试关联的题目(关联表,并且附带分值)
每次考试需要记录,而练习可以看作一次特殊的考试,不进入库,但是仍然存考试记录
根据以上草稿内容,我们设计一下数据表(咦profile怎么没有这个步骤(因为profile太简单了忘记了
[collapse title=一些定义]
虽然也不知道后面会不会再改就是了...
-- WARNING: This schema is for context only and is not meant to be run.
-- Table order and constraints may not be valid for execution.
CREATE TABLE public.attempt_sessions (
id bigint NOT NULL DEFAULT nextval('attempt_sessions_id_seq'::regclass),
user_id uuid NOT NULL,
source_type USER-DEFINED NOT NULL,
exam_id uuid,
subject_id bigint,
start_time timestamp with time zone NOT NULL DEFAULT now(),
end_time timestamp with time zone,
score numeric DEFAULT 0 CHECK (score >= 0::numeric),
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT attempt_sessions_pkey PRIMARY KEY (id),
CONSTRAINT attempt_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT attempt_sessions_exam_id_fkey FOREIGN KEY (exam_id) REFERENCES public.exams(id),
CONSTRAINT attempt_sessions_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id)
);
CREATE TABLE public.categories (
id bigint NOT NULL DEFAULT nextval('categories_id_seq'::regclass),
subject_id bigint NOT NULL,
name character varying NOT NULL,
parent_id bigint,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT categories_pkey PRIMARY KEY (id),
CONSTRAINT categories_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id),
CONSTRAINT categories_parent_fk FOREIGN KEY (subject_id) REFERENCES public.categories(id),
CONSTRAINT categories_parent_fk FOREIGN KEY (parent_id) REFERENCES public.categories(id),
CONSTRAINT categories_parent_fk FOREIGN KEY (subject_id) REFERENCES public.categories(subject_id),
CONSTRAINT categories_parent_fk FOREIGN KEY (parent_id) REFERENCES public.categories(subject_id)
);
CREATE TABLE public.exam_participations (
id uuid NOT NULL DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
exam_id uuid NOT NULL,
joined_at timestamp with time zone NOT NULL DEFAULT now(),
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT exam_participations_pkey PRIMARY KEY (id),
CONSTRAINT exam_participations_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id),
CONSTRAINT exam_participations_exam_id_fkey FOREIGN KEY (exam_id) REFERENCES public.exams(id)
);
CREATE TABLE public.exam_questions (
id bigint NOT NULL DEFAULT nextval('exam_questions_id_seq'::regclass),
exam_id uuid NOT NULL,
question_id bigint NOT NULL,
score numeric NOT NULL DEFAULT 1 CHECK (score >= 0::numeric),
sort_order integer NOT NULL DEFAULT 0,
CONSTRAINT exam_questions_pkey PRIMARY KEY (id),
CONSTRAINT exam_questions_exam_id_fkey FOREIGN KEY (exam_id) REFERENCES public.exams(id),
CONSTRAINT exam_questions_question_id_fkey FOREIGN KEY (question_id) REFERENCES public.questions(id)
);
CREATE TABLE public.exams (
id uuid NOT NULL DEFAULT gen_random_uuid(),
name character varying NOT NULL,
start_time timestamp with time zone,
end_time timestamp with time zone,
created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(),
duration integer,
CONSTRAINT exams_pkey PRIMARY KEY (id),
CONSTRAINT exams_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id)
);
CREATE TABLE public.profiles (
id uuid NOT NULL,
username text,
avatar_url text,
introduction text,
role text,
CONSTRAINT profiles_pkey PRIMARY KEY (id),
CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id)
);
CREATE TABLE public.question_answer_keys (
id bigint NOT NULL DEFAULT nextval('question_answer_keys_id_seq'::regclass),
question_id bigint NOT NULL,
version integer NOT NULL CHECK (version > 0),
answer_payload jsonb NOT NULL CHECK (jsonb_typeof(answer_payload) = 'object'::text),
is_active boolean NOT NULL DEFAULT true,
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT question_answer_keys_pkey PRIMARY KEY (id),
CONSTRAINT question_answer_keys_question_id_fkey FOREIGN KEY (question_id) REFERENCES public.questions(id)
);
CREATE TABLE public.question_attempts (
id bigint NOT NULL DEFAULT nextval('question_attempts_id_seq'::regclass),
session_id bigint NOT NULL,
question_id bigint NOT NULL,
answer_version integer NOT NULL,
answer_payload jsonb NOT NULL CHECK (jsonb_typeof(answer_payload) = 'object'::text),
is_correct boolean NOT NULL,
score numeric DEFAULT 0 CHECK (score >= 0::numeric),
submitted_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT question_attempts_pkey PRIMARY KEY (id),
CONSTRAINT question_attempts_session_id_fkey FOREIGN KEY (session_id) REFERENCES public.attempt_sessions(id),
CONSTRAINT question_attempts_question_id_fkey FOREIGN KEY (question_id) REFERENCES public.questions(id),
CONSTRAINT question_attempts_answer_version_fk FOREIGN KEY (question_id) REFERENCES public.question_answer_keys(question_id),
CONSTRAINT question_attempts_answer_version_fk FOREIGN KEY (answer_version) REFERENCES public.question_answer_keys(question_id),
CONSTRAINT question_attempts_answer_version_fk FOREIGN KEY (question_id) REFERENCES public.question_answer_keys(version),
CONSTRAINT question_attempts_answer_version_fk FOREIGN KEY (answer_version) REFERENCES public.question_answer_keys(version)
);
CREATE TABLE public.question_options (
id bigint NOT NULL DEFAULT nextval('question_options_id_seq'::regclass),
question_id bigint NOT NULL,
option_key character varying NOT NULL CHECK (option_key::text ~ '^[A-Z][A-Z0-9_]*$'::text),
content text NOT NULL CHECK (length(TRIM(BOTH FROM content)) > 0),
sort_order integer NOT NULL DEFAULT 0,
CONSTRAINT question_options_pkey PRIMARY KEY (id),
CONSTRAINT question_options_question_id_fkey FOREIGN KEY (question_id) REFERENCES public.questions(id)
);
CREATE TABLE public.questions (
id bigint NOT NULL DEFAULT nextval('questions_id_seq'::regclass),
subject_id bigint NOT NULL,
category_id bigint NOT NULL,
type USER-DEFINED NOT NULL,
difficulty USER-DEFINED NOT NULL DEFAULT '入门'::question_difficulty,
content text NOT NULL CHECK (length(TRIM(BOTH FROM content)) > 0),
explanation text,
created_by uuid,
is_active boolean NOT NULL DEFAULT true,
total_attempts integer NOT NULL DEFAULT 0,
correct_attempts integer NOT NULL DEFAULT 0,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT questions_pkey PRIMARY KEY (id),
CONSTRAINT questions_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id),
CONSTRAINT questions_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id),
CONSTRAINT questions_category_subject_fk FOREIGN KEY (subject_id) REFERENCES public.categories(id),
CONSTRAINT questions_category_subject_fk FOREIGN KEY (category_id) REFERENCES public.categories(id),
CONSTRAINT questions_category_subject_fk FOREIGN KEY (subject_id) REFERENCES public.categories(subject_id),
CONSTRAINT questions_category_subject_fk FOREIGN KEY (category_id) REFERENCES public.categories(subject_id)
);
CREATE TABLE public.subjects (
id bigint NOT NULL DEFAULT nextval('subjects_id_seq'::regclass),
name character varying NOT NULL UNIQUE,
created_at timestamp with time zone NOT NULL DEFAULT now(),
code character varying NOT NULL UNIQUE,
CONSTRAINT subjects_pkey PRIMARY KEY (id)
);
[/collapse]

最后再考虑RLS吧!总之先开始实现了
题库展示
先简单写个query
[collapse title=getQuestions()]
import { createClient } from "../supabase/server";
export async function getQuestions() {
const supabase = await createClient();
const { data, error } = await supabase
.from("questions")
.select("id, content, total_attempts, correct_attempts");
if (error) {
console.error("Failed to fetch questions:", error);
return [];
}
return (data ?? [])
}
export async function getQuestionById(id: string) {
const supabase = await createClient();
const { data, error } = await supabase
.from("questions")
.select("*")
.eq("id", id)
.maybeSingle();
if (error) {
console.error(`Failed to fetch question with id ${id}:`, error);
return null;
}
return data;
}
[/collapse]
然后由于需要异步query,next16又继续在报block问题,所以我们需要重新组织一下页面组件,改成类似下面这样
export default function QuestionBankPage() {
return (
<>
<TopBar title="题库" />
<main className="mx-auto max-w-6xl space-y-8 px-5 py-8 md:px-8">
<SectionIntro
description="通过 12,450 道高质量学术题目策展你的进阶学习路径。"
kicker="题库中心"
title="精选题库"
/>
<QuestionFilters
selectedDifficulty={DEFAULT_DIFFICULTY}
selectedSubject={DEFAULT_SUBJECT}
subjects={SUBJECT_OPTIONS}
/>
<Suspense fallback={<QuestionCardsFallback />}>
<QuestionCardsSection />
</Suspense>
</main>
</>
);
}
<QuestionCardsSection />现在是一个异步组件,用suspense包裹
[warning]关于这个block问题,其实就是next16开始不允许page和layout异步,之前的逻辑都是直接让page async,在page下直接去做各种query然后传给子组件,现在必须要让组件各自去异步query不能阻塞根页面组件[/warning]
同理,等会儿Filters的部分也会拆成一个section然后改为异步组件。getCategories()还有getSubjects()和上面的都比较类似,这里也不贴代码了
现在题库空空,为了方便测试,先去教师那边写一个新建题目
新建学科/分类/题目
这是接入了getQuestions的teacher-questions,要完成的是右上角的三个新建

对于新建学科和分类,都只需要弹出一个modal简单新建即可
仍然是考虑之前的阻塞问题,对于这个页面我们应该分成三个阻塞点,一个是filter,一个是下面的试题card,一个是打开新建分类的modal后获取分类的部分

前面两个阻塞点还好,像上面说的拆两个section就好,主要是modal的问题。因为modal不得不放在一个client component里,但我们又不希望client能够直接和数据库通信,所以我们需要使用server action让它强制用server去获取
例如我们应该这么写(主要的点在于上面的use server)
[collapse title=/actions/questions.ts]
"use server";
import { getSubjects, type SubjectOption } from "@/lib/queries/questions";
import { createClient } from "@/lib/supabase/server";
export async function getSubjectsAction(): Promise<SubjectOption[]> {
return await getSubjects();
}
export async function createSubjectAction(subject: string, code: string) {
const supabase = await createClient();
const { data, error } = await supabase
.from("subjects")
.insert({ name: subject, code: code })
.select("name, code")
.maybeSingle();
if (error) {
console.error("Failed to create subject:", error);
return null;
}
return data ?? null;
}
[/collapse]
这样client component里,对应的handleSubmit调用server action就可以两者兼得了(阻塞+server)
[collapse title=handleSubmit]
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
console.log("Creating subject:", subjectName);
const res = await createSubjectAction(subjectName, subjectCode);
// 重置表单
setSubjectName("");
setSubjectCode("");
onOpenChange(false);
} finally {
setIsSubmitting(false);
}
};
[/collapse]

笑点解析:专家/大师+/宗师
这样我们就获得了一些tag,怎么筛选呢?这里考虑到使用searchparam,也就是浏览器里类似下面这样的部分,可以看到路由后面有一个问号后面带了参数
在next16中使用searchparams同样是需要suspense的,所以我们大改了一下整个page组件树的结构,变成了下面这样
export default function QuestionBankPage({ searchParams }: QuestionBankPageProps) {
return (
<>
<TopBar title="题库" />
<main className="mx-auto max-w-6xl space-y-8 px-5 py-8 md:px-8">
<SectionIntro
description="通过 12,450 道高质量学术题目策展你的进阶学习路径。"
kicker="题库中心"
title="精选题库"
/>
<Suspense fallback={<QuestionFiltersFallback />}>
<QuestionFiltersSlot pathname="/question-bank" searchParams={searchParams} />
</Suspense>
<Suspense fallback={<QuestionCardsFallback />}>
<QuestionCardsSlot searchParams={searchParams} />
</Suspense>
</main>
</>
);
}
这样两个slot组件就可以大胆写成async组件了
这样每次筛选相当于我们生成了一个新的url,然后去解析这个url获得筛选的分类再去做query就好了
[collapse title=searchParams的具体工作方法]
在QuestionFilters组件中,有三个Filter,对应学科、分类、难度,这三个分类的过滤逻辑都是类似的,我们以分类为例
{categories.map((category) => {
const isActive = category === selectedCategory;
return (
<form
key={category}
action={setQuestionFiltersAction}
>
<input name="pathname" type="hidden" value={pathname} />
<input name="subject" type="hidden" value={selectedSubject} />
<input name="category" type="hidden" value={category} />
<input name="difficulty" type="hidden" value={selectedDifficulty} />
<button
aria-pressed={isActive}
className={
isActive
? "rounded-full bg-[var(--curator-tertiary)] px-3 py-1 text-xs font-bold text-white"
: "rounded-full bg-[var(--curator-surface-high)] px-3 py-1 text-xs font-bold text-[var(--curator-on-surface-variant)] transition-colors hover:bg-[var(--curator-tertiary)]/20 hover:text-[var(--curator-tertiary)]"
}
type="submit"
>
{category}
</button>
</form>
);
})}
对于每一个分类,相当于一个form,在form提交时(点击对应的tag)的action就是setQuestionFiltersAction,这是一个server action,接受formData
export async function setQuestionFiltersAction(formData: FormData) {
const pathnameValue = formData.get("pathname");
const pathname =
typeof pathnameValue === "string" && pathnameValue.startsWith("/")
? pathnameValue
: "/question-bank";
const subject = normalizeFilterValue(formData.get("subject"));
const category = normalizeFilterValue(formData.get("category"));
const difficulty = normalizeFilterValue(formData.get("difficulty"));
const query = buildFilterQuery({
category: subject === ALL_OPTION ? ALL_OPTION : category,
difficulty,
subject,
});
redirect(query.length > 0 ? `${pathname}?${query}` : pathname);
}
function buildFilterQuery({
subject,
category,
difficulty,
}: {
subject: string;
category: string;
difficulty: string;
}) {
const params = new URLSearchParams();
if (subject !== ALL_OPTION) {
params.set("subject", subject);
}
if (category !== ALL_OPTION) {
params.set("category", category);
}
if (difficulty !== ALL_OPTION) {
params.set("difficulty", difficulty);
}
return params.toString();
}
buildFilterQuery负责整理得到正确的param,然后setQuestionFiltersAction将其拼接注入url,然后由服务端直接发起redirect
回到QuestionCardsSlot组件,在进入组件时我们对searchParams进行解析
function getFirstParam(value?: string | string[]) {
if (Array.isArray(value)) {
return value[0] ?? "";
}
return value ?? "";
}
function getSelectedOption(value?: string | string[]) {
const resolved = getFirstParam(value).trim();
return resolved.length > 0 ? resolved : ALL_OPTION;
}
export async function resolveSelectedFilters(
searchParams?: Promise<QuestionFiltersSearchParams>,
): Promise<SelectedFilters> {
const params = searchParams ? await searchParams : undefined;
return {
category: getSelectedOption(params?.category),
difficulty: getSelectedOption(params?.difficulty),
subject: getSelectedOption(params?.subject),
};
}
(做了很多层安全检查看起来有点繁琐)最后返回三个筛选结果,让card去处理展示就可以
[/collapse]
有了这些前置准备(?)我们终于可以开始写新建题目了(??)
由于新建题目需要的参数比较多,我们直接进入一个新路由开始新建,目前来说的界面长这样

有了前面的经验,这次我们只需要将form的所有数据收集以后,继续使用useActionState提交就好了
[collapse title=创建题目]
在page处,我们将createQuestionAction作为action传入QuestionComposerForm,然后在组件内写useActionState
export function QuestionComposerForm({ action }: QuestionComposerFormProps) {
const [state, formAction, pending] = useActionState(action, INITIAL_QUESTION_FORM_STATE);
...
}
对应的createQuestionAction如下,由于有非常长的输入安全性检测所以看起来非常长....
export async function createQuestionAction(
_prevState: QuestionFormState,
formData: FormData,
): Promise<QuestionFormState> {
const subject = getField(formData, "subject");
const category = getField(formData, "category");
const content = getField(formData, "content");
const difficulty = getField(formData, "difficulty");
const questionType = getField(formData, "questionType");
const optionsPayload = getField(formData, "optionsPayload");
const answerPayload = getField(formData, "answerPayload");
console.log("Received form data:", {
subject,
category,
content,
difficulty,
questionType,
optionsPayload,
answerPayload,
});
if (!subject) {
return { success: false, message: "请选择学科类别。" };
}
if (!category) {
return { success: false, message: "请选择题目分类。" };
}
if (!difficulty) {
return { success: false, message: "请选择题目难度。" };
}
if (content.length < 1) {
return { success: false, message: "题目内容至少 1 个字符。" };
}
if (!questionType) {
return { success: false, message: "请选择题目类型。" };
}
if (questionType === "single" || questionType === "multiple") {
const options = parseJsonArray(optionsPayload);
const answers = parseJsonArray(answerPayload);
if (!options || !answers) {
return { success: false, message: "选项或答案格式不正确,请重新填写。" };
}
const validOptions = options.filter((item) => {
if (!item || typeof item !== "object") return false;
const content = (item as { content?: unknown }).content;
return typeof content === "string" && content.trim().length > 0;
});
if (validOptions.length < 2) {
return { success: false, message: "选择题至少需要填写 2 个有效选项。" };
}
if (questionType === "single" && answers.length !== 1) {
return { success: false, message: "单选题需要且仅需要 1 个正确答案。" };
}
if (questionType === "multiple" && answers.length < 1) {
return { success: false, message: "多选题至少需要 1 个正确答案。" };
}
}
if (questionType === "judge") {
const answers = parseJsonArray(answerPayload);
const value = answers?.[0];
if (value !== "true" && value !== "false") {
return { success: false, message: "请设置判断题答案(正确/错误)。" };
}
}
if (questionType === "blank") {
const answers = parseJsonArray(answerPayload);
const validAnswers = (answers ?? []).filter(
(item) => typeof item === "string" && item.trim().length > 0,
);
if (validAnswers.length < 1) {
return { success: false, message: "填空题至少需要 1 个参考答案。" };
}
}
const supabase = await createClient();
let createdQuestionId: number | null = null;
const rollbackQuestion = async () => {
if (!createdQuestionId) return;
const { error } = await supabase
.from("questions")
.delete()
.eq("id", createdQuestionId);
if (error) {
console.error("Rollback failed for question:", error);
}
};
try {
const { data: subjectRow, error: subjectError } = await supabase
.from("subjects")
.select("id")
.eq("name", subject)
.maybeSingle();
if (subjectError || !subjectRow) {
console.error(`Subject ID not found for subject ${subject}:`, subjectError);
return { success: false, message: "学科类别无效,请重新选择。" };
}
const { data: categoryRow, error: categoryError } = await supabase
.from("categories")
.select("id")
.eq("name", category)
.eq("subject_id", subjectRow.id)
.maybeSingle();
if (categoryError || !categoryRow) {
console.error(`Category ID not found for category ${category}:`, categoryError);
return { success: false, message: "题目分类无效,请重新选择。" };
}
const { data: insertedQuestion, error: questionError } = await supabase
.from("questions")
.insert({
subject_id: subjectRow.id,
category_id: categoryRow.id,
content,
difficulty,
is_active: true,
type: questionType,
})
.select("id")
.single();
if (questionError || !insertedQuestion) {
console.error("Failed to create question:", questionError);
return { success: false, message: "创建题目失败,请稍后再试。" };
}
createdQuestionId = insertedQuestion.id;
const answerKeyPayload = buildAnswerKeyPayload({
answerPayload,
optionsPayload,
questionType,
});
console.log("Constructed answer key payload:", answerKeyPayload);
if (!answerKeyPayload) {
await rollbackQuestion();
return { success: false, message: "答案键格式不正确,请重新填写。" };
}
const { error: answerKeyError } = await supabase
.from("question_answer_keys")
.insert({
answer_payload: answerKeyPayload,
is_active: true,
question_id: createdQuestionId,
version: 1,
});
if (answerKeyError) {
console.error("Failed to create question answer key:", answerKeyError);
await rollbackQuestion();
return { success: false, message: "创建答案键失败,请稍后再试。" };
}
if (questionType === "single" || questionType === "multiple") {
const parsedOptions = parseJsonArray(optionsPayload);
const parsedAnswers = parseJsonArray(answerPayload);
if (!parsedOptions || !parsedAnswers) {
await rollbackQuestion();
return { success: false, message: "选项或答案格式不正确,请重新填写。" };
}
const optionRows = parsedOptions
.filter((item): item is { key: string; content: string } => {
if (!item || typeof item !== "object") return false;
const key = (item as { key?: unknown }).key;
const value = (item as { content?: unknown }).content;
return (
typeof key === "string" &&
/^[A-Z][A-Z0-9_]*$/.test(key) &&
typeof value === "string" &&
value.trim().length > 0
);
})
.map((item, index) => ({
question_id: createdQuestionId,
option_key: item.key,
content: item.content.trim(),
sort_order: index + 1,
}));
if (optionRows.length < 2) {
await rollbackQuestion();
return { success: false, message: "选择题至少需要填写 2 个有效选项。" };
}
const { data: insertedOptions, error: optionsError } = await supabase
.from("question_options")
.insert(optionRows)
.select("id, option_key");
if (optionsError || !insertedOptions) {
console.error("Failed to create question options:", optionsError);
await rollbackQuestion();
return { success: false, message: "创建选项失败,请稍后再试。" };
}
const answerKeys = parsedAnswers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter((item) => item.length > 0);
const optionKeySet = new Set(insertedOptions.map((item) => item.option_key));
const allAnswerKeysAreValid = answerKeys.every((key) => optionKeySet.has(key));
if (questionType === "single" && answerKeys.length !== 1) {
await rollbackQuestion();
return { success: false, message: "单选题需要且仅需要 1 个正确答案。" };
}
if (questionType === "multiple" && answerKeys.length < 1) {
await rollbackQuestion();
return { success: false, message: "多选题至少需要 1 个正确答案。" };
}
if (!allAnswerKeysAreValid) {
await rollbackQuestion();
return { success: false, message: "正确答案必须来自当前选项。" };
}
}
} catch (error) {
console.error("Unexpected error while creating question:", error);
await rollbackQuestion();
return { success: false, message: "创建题目失败,请稍后再试。" };
}
revalidatePath("/teacher-questions");
return { success: true, message: "题目创建成功。" };
}
[/collapse]
创建了四种题型的题目,大概就是下面这种感觉



考试
考试我们也还是从教师端开始开发,目前是这样的

考试是通过唯一的考试码来加入的,而一场考试由题库中的部分题目组成,因此我们只需要新建一场考试记录,然后生成id,再和对应题目做关联,这样就相当于创建了一场考试
考试展示
和题库展示类似,查考试记录表就行,记得先组织一下examCard组件
[collapse title=组件]
export default function TeacherExamsPage() {
return (
<>
<TopBar title="考试列表" />
<main className="mx-auto max-w-7xl px-5 py-8 md:px-8">
<div className="mb-10 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
<SectionIntro
description="查看考试实时状态与回顾数据,统一管理教学评估。"
kicker="教师考试中心"
title="考试编排总览"
/>
<Link
className="cta-gradient rounded-full px-6 py-3 text-sm font-bold text-white transition-transform hover:scale-[1.02]"
href="/publish-exam"
>
发布新考试
</Link>
</div>
<Suspense fallback={<ExamCardsFallback count={5} />}>
<TeacherExamCards />
</Suspense>
</main>
</>
);
}
async function TeacherExamCards() {
const exams = await getExams();
if(exams.length === 0) {
return (
<div className="rounded-xl bg-[var(--curator-surface-low)] p-6 text-center text-sm text-[var(--curator-on-surface-variant)]">
当前没有考试。点击右上角按钮发布你的第一场考试吧!
</div>
)
}
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{exams.map((exam) => (
<ExamCard
key={exam.id}
examCode={exam.examCode}
primaryHref={exam.primaryHref}
reviewHref={exam.reviewHref}
role="teacher"
status={exam.status}
subtitle={exam.subtitle}
title={exam.title}
/>
))}
</div>
)
}
[/collapse]

进入发布新考试页面

由于时间实在是来不及做了这次先做这个智能参数组卷,说是智能参数组卷其实就是选择学科分类难度还有题目数量,系统自动从题库里随机抽取符合要求的题目而已
(虽然似乎可以用embedding做更好的组卷但是一切都是时间不够导致的)
由于前面新建题目的时候偷懒了,没有把对多个表的操作合并成一个RPC就导致回滚做的很痛苦,这次学聪明了写了一个rpc,这样就是一个原子操作了,一旦有哪里出问题了会直接全部回滚
[collapse title=create_exam_with_questions]
create or replace function public.create_exam_with_questions(
p_name text,
p_start_time timestamptz,
p_end_time timestamptz,
p_duration integer,
p_payload jsonb
)
returns uuid
language plpgsql
as $$
declare
v_exam_id uuid;
v_sort_order int := 0;
v_item jsonb;
v_question_id bigint;
v_score numeric(8,2);
begin
-- 1. 插入 exams
insert into public.exams (name, start_time, end_time, duration)
values (p_name, p_start_time, p_end_time, p_duration)
returning id into v_exam_id;
-- 2. 遍历 payload
for v_item in
select * from jsonb_array_elements(p_payload)
loop
v_score := (v_item->>'score')::numeric;
-- 3. 遍历每个题型下的 questionIds
for v_question_id in
select jsonb_array_elements_text(v_item->'selectedQuestionIds')::bigint
loop
v_sort_order := v_sort_order + 1;
insert into public.exam_questions (
exam_id,
question_id,
score,
sort_order
)
values (
v_exam_id,
v_question_id,
v_score,
v_sort_order
);
end loop;
end loop;
return v_exam_id;
end;
$$;
传入的paylaod是类似这样的
const payload: CreateExamPayload = [
{
type: "single",
score: 2,
selectedQuestionIds: [1, 2, 3, 4],
},
{
type: "multiple",
score: 3,
selectedQuestionIds: [10, 11],
},
{
type: "judge",
score: 1,
selectedQuestionIds: [20, 21, 22],
},
{
type: "subjective",
score: 10,
selectedQuestionIds: [30],
},
];
[/collapse]
这时我们就可以写创建的action了,使用supabase.rpc("create_exam_with_questions",{参数列表},)这样就可以调用我们写好的rpc了
[collapse title=publishExamAction]
export async function publishExamAction(
_prevState: QuestionFormState,
formData: FormData,
): Promise<QuestionFormState> {
console.log("publishExamAction - received form data:", Object.fromEntries(formData.entries()));
const title = getField(formData, "title");
const buildMode = getField(formData, "buildMode");
const smartRulesPayload = getField(formData, "smartRulesPayload");
const startAt = getField(formData, "startAt");
const endAt = getField(formData, "endAt");
const duration = getField(formData, "duration");
if (title.length < 2) {
return { success: false, message: "请填写考试标题。" };
}
if (!validateTimeRange(startAt, endAt, duration)) {
return { success: false, message: "时间范围设置无效,请检查开始时间、结束时间和答题时长。" };
}
if (buildMode === "smart") {
const smartRules = parseSmartRulesPayload(smartRulesPayload);
if (!smartRules) {
return { success: false, message: "智能组卷规则无效,请重新设置。" };
}
const enabledRules = smartRules.filter((rule) => rule.count > 0);
if (enabledRules.length < 1) {
return { success: false, message: "请至少为一种题型设置题目数量。" };
}
if (enabledRules.some((rule) => rule.scorePerQuestion <= 0)) {
return { success: false, message: "请为已启用的题型设置大于 0 的单题分值。" };
}
const supabase = await createClient();
const subjectNames = [...new Set(enabledRules.map((rule) => rule.subject))];
const { data: subjectRows, error: subjectError } = await supabase
.from("subjects")
.select("id, name")
.in("name", subjectNames);
if (subjectError) {
console.error("Failed to fetch subjects for smart exam generation:", subjectError);
return { success: false, message: "读取学科信息失败,请稍后再试。" };
}
const subjectIdByName = new Map<string, number | string>();
for (const row of subjectRows ?? []) {
if (row?.name) {
subjectIdByName.set(String(row.name), row.id);
}
}
const selectedQuestionIds = new Set<number>();
const createExamPayload: CreateExamPayload = [];
for (const rule of enabledRules) {
const subjectId = subjectIdByName.get(rule.subject);
if (!subjectId) {
return { success: false, message: `学科「${rule.subject}」不存在,请重新选择。` };
}
const { data: questionRows, error: questionError } = await supabase
.from("questions")
.select("id")
.eq("subject_id", subjectId)
.eq("type", rule.questionType)
.eq("difficulty", rule.difficulty)
.eq("is_active", true);
if (questionError) {
console.error("Failed to fetch questions for smart exam generation:", questionError);
return { success: false, message: "筛选题库失败,请稍后再试。" };
}
const candidateIds = (questionRows ?? [])
.map((row) => Number(row.id))
.filter((id) => Number.isFinite(id) && !selectedQuestionIds.has(id));
if (candidateIds.length < rule.count) {
const typeLabelMap: Record<SmartRule["questionType"], string> = {
blank: "填空题",
judge: "判断题",
multiple: "多选题",
single: "单选题",
};
return {
success: false,
message: `${typeLabelMap[rule.questionType]}(${rule.subject} / ${rule.difficulty})题量不足:需要 ${rule.count} 题,当前仅 ${candidateIds.length} 题。`,
};
}
const pickedIds = shuffle(candidateIds).slice(0, rule.count);
createExamPayload.push({
score: rule.scorePerQuestion,
selectedQuestionIds: pickedIds,
type: rule.questionType,
});
for (const id of pickedIds) {
selectedQuestionIds.add(id);
}
}
if (selectedQuestionIds.size < 1) {
return { success: false, message: "未抽取到有效题目,请调整智能组卷规则。" };
}
const totalScore = enabledRules.reduce(
(sum, rule) => sum + rule.count * rule.scorePerQuestion,
0,
);
const { data, error: insertError } = await supabase.rpc(
"create_exam_with_questions",
{
p_duration: Number(duration),
p_end_time: new Date(endAt).toISOString(),
p_name: title,
p_payload: createExamPayload,
p_start_time: new Date(startAt).toISOString(),
},
)
// console.log("rpc data:", data, "type:", typeof data);
// console.log("rpc error:", insertError);
if (insertError) {
console.error("Failed to create exam with questions:", insertError);
return { success: false, message: "创建考试失败,请稍后再试。" };
}
console.log("Exam created successfully with ID:", data);
return { success: true, message: `考试创建成功,ID: ${data}` };
}
revalidatePath("/teacher-exams");
redirect("/teacher-exams");
}
[/collapse]
再回到考试总览页,我们就有了一个考试
我们接受rpc返回的一个uuid,也就是考试记录的id,作为唯一的考试码,学生端就可以使用这个考试码加入考试

回到学生端,完成考试加入,这里userId仍然依赖useProfileContext获得
[collapse title=JoinExamAction()]
export async function JoinExamAction(examId: string,userId: string) {
if(!examId || !userId){
return { success: false, message: "考试ID或用户ID无效。" };
}
const supabase = await createClient();
const { data, error } = await supabase
.from("exam_participations")
.insert({ exam_id: examId, user_id: userId });
if (error) {
console.error("Error joining exam:", error);
return { success: false, message: "加入考试失败,请稍后再试。" };
}
return { success: true, message: "成功加入考试!" };
}
[/collapse]

单次考试
从这里开始加速了!因为我真的要写不完了!
这是考试的页面,由于是沉浸式所以界面并没有套其他的layout,这个单独写了页面布局

由于真来不及写断点续传(?)了,所以所有提交都是一次性的,退出了就会丢失目前进度
现在的逻辑大概是类似这样的:用户进入考试->答题->提交试卷(提交form)->等待考试结束->cron+supabase edge function触发批改->写会数据库
在edge function这里,我们要对接上另一边同学主要担任的cpp文件做批改
先从form提交开始吧,说是form其实这里并不是用的useActionState,而是直接拿了个Answer state存所有填写的答案,然后提交前格式化成和创建题目时类似的answer_payload,这样就能统一了
当用户确认提交试卷时去调用submitExamAction
[collapse title=submitExamAction()]
export async function submitExamAction(rawAnswerData: Record<string, unknown>) {
const examId = typeof rawAnswerData.examId === "string" ? rawAnswerData.examId.trim() : "";
if (examId.length < 1) {
return { message: "缺少考试 ID。", success: false };
}
const metaInput = Array.isArray(rawAnswerData.questions) ? rawAnswerData.questions : [];
const answerData: SubmitAnswerItem[] = metaInput
.map((item) => {
if (!item || typeof item !== "object") return null;
const questionId = Number((item as { id?: unknown }).id);
const submittedAnswerPayload = normalizeSubmittedAnswerPayload(
(item as { submittedAnswerPayload?: unknown }).submittedAnswerPayload,
);
if (!Number.isFinite(questionId)) return null;
return {
question_id: questionId,
submitted_answer_payload: submittedAnswerPayload,
submitted_at: new Date().toISOString(),
} satisfies SubmitAnswerItem;
})
.filter((item): item is SubmitAnswerItem => item !== null);
if (answerData.length < 1) {
return { message: "未检测到有效作答数据。", success: false };
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return { message: "用户未登录。", success: false };
}
const { data: existingSession, error: sessionError } = await supabase
.from("attempt_sessions")
.select("id")
.eq("user_id", user.id)
.eq("exam_id", examId)
.order("id", { ascending: false })
.limit(1)
.maybeSingle();
if (sessionError) {
console.error("submitExamAction check existing session error:", sessionError);
return { message: "提交失败,请稍后再试。", success: false };
}
if (existingSession?.id) {
return { message: "你已提交过本次考试,请前往回顾页面查看。", success: false };
}
const { data, error } = await supabase.rpc("submit_exam_with_attempts", {
p_answers: answerData,
p_exam_id: examId,
p_source_type: "exam",
});
if (error) {
console.error("submitExamAction rpc error:", error);
return { message: "提交失败,请稍后再试。", success: false };
}
const rpcResult = data as SubmitExamRpcResponse | null;
return {
data: rpcResult,
success: true,
};
}
[/collapse]
这里同样使用了rpc,因为这次的流程也是首先在session表中记录本次提交的session,然后再去attempt表存对应的每道题的提交记录,rpc的定义如下
[collapse title=submit_exam_with_attempts]
declare
v_user_id uuid := auth.uid();
v_session_id bigint;
v_total_score numeric := null;
v_total_count integer := 0;
v_correct_count integer := null;
v_item jsonb;
v_question_id bigint;
v_submitted_payload jsonb;
v_submitted_at timestamptz;
v_answer_version integer;
v_source_enum_type text;
begin
if v_user_id is null then
raise exception 'submit_exam_with_attempts: user not authenticated';
end if;
if p_exam_id is null then
raise exception 'submit_exam_with_attempts: exam id is required';
end if;
if p_answers is null or jsonb_typeof(p_answers) <> 'array' then
raise exception 'submit_exam_with_attempts: p_answers must be jsonb array';
end if;
select format('%I.%I', tn.nspname, t.typname)
into v_source_enum_type
from pg_attribute a
join pg_class c on c.oid = a.attrelid
join pg_type t on t.oid = a.atttypid
join pg_namespace tn on tn.oid = t.typnamespace
where c.oid = 'public.attempt_sessions'::regclass
and a.attname = 'source_type'
and a.attnum > 0
and not a.attisdropped;
if v_source_enum_type is null then
raise exception 'submit_exam_with_attempts: attempt_sessions.source_type type not found';
end if;
execute format(
'insert into public.attempt_sessions (user_id, exam_id, source_type, start_time, end_time, score)
values ($1, $2, $3::%s, now(), now(), 0)
returning id',
v_source_enum_type
)
into v_session_id
using v_user_id, p_exam_id, p_source_type;
execute format(
'update public.attempt_sessions set score = null where id = $1'
)
using v_session_id;
for v_item in select * from jsonb_array_elements(p_answers)
loop
v_question_id := nullif(v_item->>'question_id', '')::bigint;
v_submitted_payload := coalesce(v_item->'submitted_answer_payload', '{}'::jsonb);
v_submitted_at := coalesce(nullif(v_item->>'submitted_at', '')::timestamptz, now());
if v_question_id is null then
continue;
end if;
select qak.version
into v_answer_version
from public.question_answer_keys qak
where qak.question_id = v_question_id
and qak.is_active = true
order by qak.version desc
limit 1;
if v_answer_version is null then
continue;
end if;
insert into public.question_attempts (
session_id,
question_id,
answer_version,
answer_payload,
is_correct,
score,
submitted_at
)
values (
v_session_id,
v_question_id,
v_answer_version,
v_submitted_payload,
false,
null,
v_submitted_at
);
v_total_count := v_total_count + 1;
end loop;
update public.attempt_sessions
set
end_time = now(),
score = null
where id = v_session_id;
return query
select v_session_id, v_total_score, v_total_count, v_correct_count;
end;
[/collapse]
至此我们完成了一次考试提交的过程,对于一次还在考试期间(没有结束)的提交,score将会被留空,来表示尚未批改

我们希望在批改前还能够进入考试,但是不能修改结果,因此增加进入考试时的分支控制,在渲染题面时判断是否在考试结束之后或者已经有过提交(有一个session记录),有的话直接redirect到exam-review页
const now = Date.now();
const examEnded = now >= new Date(exam.endTime).getTime();
const submitted = await hasSubmittedExam(user.id, exam.id);
if (examEnded || submitted) {
redirect(`/exam-review?mode=student&exam=${exam.id}`);
}
exam-review里我们依靠前面提交时对应session的attempt记录来渲染选项即可
考试批改
在完成一次考试以后,我们就可以等着被批改了开始编写edge function,让supabase使用pg_cron去定时(按照结束时间)去调用批改的edge function,然后进行一个批改的操作了
[collapse title=pg_cron与edge function]
pg_cron是什么?在此之前先顺带一提,supabase使用的数据库是PostgreSQL,而pg_cron就是postgre内置的定时调度插件,可以让数据库在某个时间点,自动执行sql
而edge function(边缘函数)是部署在云边缘节点的后端函数(类似 api),它是serverless的,会部署在世界各地的多个节点中,而用户调用时会选择一个最近的节点来调用,这个function可以像我们正常写js/ts一样写,所以可扩展性更高可以写非常多的内容
而这两者联动的方式是通过http通信,pg_cron通过http去调用edge function,就像调用api一样,这样pg_cron主要充当的就是定时器的功能,实际怎么处理就看我们写http服务了
[/collapse]
不幸的,由于edge function是沙箱环境并非node,所以不能child_process。本来想原汁原味原生C++不动源文件的,但我们现在不得不请出C++ Crow了
crow可以把cpp包成http服务,这样就还是让supabase去调用了,也不用走一层ts
由cpp实现的部分有这些,顺便附上花花米酱的算法设计思路:
[collapse title=main(1).cpp]
#include <vector>
#include <map>
#include <string>
#include <algorithm>
#include <set>
struct ScorePolicy {
int single;
int multiple;
int boolean;
};
// 1) 规范化答案
std::vector<int> normalize_answer(std::vector<int> ans, int optionCount) {
std::set<int> s;
for (int v : ans) {
if (v >= 0 && v < optionCount) {
s.insert(v);
}
}
return std::vector<int>(s.begin(), s.end());
}
// 2) 单选题判对错
bool is_correct_single(const std::vector<int>& correct, const std::vector<int>& given) {
return correct == given;
}
// 多选题判对错(全对才算对)
bool is_correct_multiple(const std::vector<int>& correct, const std::vector<int>& given) {
return correct == given;
}
// 判断题判对错
bool is_correct_boolean(const std::vector<int>& correct, const std::vector<int>& given) {
return correct == given;
}
// 3) 单题计分
int score_question(const std::string& type, bool correct, const ScorePolicy& policy) {
if (!correct) return 0;
if (type == "single") return policy.single;
if (type == "multiple") return policy.multiple;
if (type == "boolean") return policy.boolean;
return 0;
}
// 4) Part 1/3:整理全部答案
std::map<std::string, std::vector<int>> normalize_all_answers(
const std::map<std::string, std::vector<int>>& answers,
const std::map<std::string, int>& optionCountByQuestionId) {
std::map<std::string, std::vector<int>> result;
for (const auto& pair : answers) {
const std::string& qid = pair.first;
const std::vector<int>& ans = pair.second;
int optCount = optionCountByQuestionId.at(qid);
result[qid] = normalize_answer(ans, optCount);
}
return result;
}
// Part 2/3:计算每题分数
std::vector<int> score_all_questions(
const std::vector<std::string>& questionIds,
const std::vector<std::string>& types,
const std::vector<std::vector<int>>& correctAnswers,
const std::map<std::string, std::vector<int>>& normalizedAnswers,
int singlePts,
int multiplePts,
int booleanPts) {
ScorePolicy policy = {singlePts, multiplePts, booleanPts};
std::vector<int> itemScores;
for (size_t i = 0; i < questionIds.size(); ++i) {
const std::string& qid = questionIds[i];
const std::string& type = types[i];
const std::vector<int>& correct = correctAnswers[i];
// 获取考生答案(如果没找到,按空答案处理)
std::vector<int> given;
auto it = normalizedAnswers.find(qid);
if (it != normalizedAnswers.end()) {
given = it->second;
}
bool correctFlag = false;
if (type == "single") {
correctFlag = is_correct_single(correct, given);
} else if (type == "multiple") {
correctFlag = is_correct_multiple(correct, given);
} else if (type == "boolean") {
correctFlag = is_correct_boolean(correct, given);
}
int score = score_question(type, correctFlag, policy);
itemScores.push_back(score);
}
return itemScores;
}
// Part 3/3:总分和满分
std::pair<int, int> sum_score_and_total(
const std::vector<int>& itemScores,
const std::vector<std::string>& types,
int singlePts,
int multiplePts,
int booleanPts) {
int totalScore = 0;
int fullScore = 0;
for (size_t i = 0; i < itemScores.size(); ++i) {
totalScore += itemScores[i];
const std::string& type = types[i];
if (type == "single") fullScore += singlePts;
else if (type == "multiple") fullScore += multiplePts;
else if (type == "boolean") fullScore += booleanPts;
}
return {totalScore, fullScore};
}
[/collapse]
[buy]
本模块采用“先标准化、再判分”的两阶段设计:首先通过 normalize_answer 利用 std::set 对答案进行排序、去重并过滤非法选项,确保后续比较的一致性;然后针对单选题、多选题和判断题,统一采用标准化后的答案向量直接比较(==)来判断对错,因为标准化已保证答案有序且无重复;最后通过 score_question 根据题型分值计算单题得分,并在 score_all_questions 和 sum_score_and_total 中逐题累加得到总分与满分。整卷处理时,normalize_all_answers 遍历所有题目逐一标准化,score_all_questions 同步遍历题号、题型、正确答案和考生答案四个数据源,通过映射关系匹配每道题的作答结果,实现了判分逻辑与数据存储的解耦。
[/buy]
在有了各个主要的判分模块以后,我们来部署crow,先创建一个grader_server.cpp,并创建一个health路由来检查服务健康
crow::SimpleApp app;
CROW_ROUTE(app, "/health").methods("GET"_method)([]() {
return crow::response(200, "ok");
});
...
app.port(18080).multithreaded().run();
return 0;
然后给出一个CMakeLists.txt,我是真不会写cmake,cursor大人拜托了
cmake_minimum_required(VERSION 3.16)
project(exam_cpp_grader)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(cpp_grader_server grader_server.cpp)
# If headers are installed globally, this is enough.
# On macOS (homebrew), crow and nlohmann-json are usually in /opt/homebrew/include.
target_include_directories(cpp_grader_server PRIVATE /opt/homebrew/include /usr/local/include)
然后cmake编译一下并且跑一下服务
cd lib/cpp
brew install crow nlohmann-json cmake #我本地没装先装一个
cmake -S . -B build
cmake --build build -j
./build/cpp_grader_server
跑起来以后用curl测一下,看到返回ok就是成功跑起来了
mitorimatsumoto@MitoriMatsumotodeMacBook-Air ~ % curl http://127.0.0.1:18080/health
ok%
接下来补完剩下的,其实就要一个路由负责接受http请求就可以
[collapse title=grader_server.cpp(部分)]
在接受到post请求后,去遍历body.items,对于每个item做一次grade_one_item(item),对于不同的题型走不同的判断方法(前面main.cpp里写的)
最后对每题返回一个is_correct和score
json grade_one_item(const json& item) {
const string type = item.value("type", "single");
const int score_value = item.value("score", 0);
const json answer_key_payload = item.value("answer_key_payload", json::object());
const json submitted_payload = item.value("submitted_payload", json::object());
bool correct = false;
int score = 0;
if (type == "blank") {
correct = grade_blank(answer_key_payload, submitted_payload);
score = correct ? score_value : 0;
return json{{"is_correct", correct}, {"score", score}};
}
vector<string> option_keys;
if (answer_key_payload.is_object()) {
auto option_it = answer_key_payload.find("option_keys");
if (option_it != answer_key_payload.end() && option_it->is_array()) {
for (const auto& k : *option_it) {
if (k.is_string()) option_keys.push_back(k.get<string>());
}
}
}
map<string, int> key_to_index;
for (size_t i = 0; i < option_keys.size(); ++i) {
key_to_index[option_keys[i]] = static_cast<int>(i);
}
vector<int> correct_raw = parse_option_indices(answer_key_payload, key_to_index);
vector<int> given_raw = parse_option_indices(submitted_payload, key_to_index);
vector<int> correct_norm = normalize_answer(correct_raw, static_cast<int>(option_keys.size()));
vector<int> given_norm = normalize_answer(given_raw, static_cast<int>(option_keys.size()));
string mapped_type = type;
if (mapped_type == "judge") mapped_type = "boolean";
if (mapped_type == "single") {
correct = is_correct_single(correct_norm, given_norm);
} else if (mapped_type == "multiple") {
correct = is_correct_multiple(correct_norm, given_norm);
} else if (mapped_type == "boolean") {
correct = is_correct_boolean(correct_norm, given_norm);
}
ScorePolicy policy = {0, 0, 0};
if (mapped_type == "single") policy.single = score_value;
if (mapped_type == "multiple") policy.multiple = score_value;
if (mapped_type == "boolean") policy.boolean = score_value;
score = score_question(mapped_type, correct, policy);
return json{{"is_correct", correct}, {"score", score}};
}
CROW_ROUTE(app, "/grade").methods("POST"_method)([](const crow::request& req) {
try {
const auto body = json::parse(req.body);
if (!body.is_object() || !body.contains("items") || !body["items"].is_array()) {
return crow::response(400, R"({"error":"invalid payload, expected {items: []}"})");
}
json results = json::array();
for (const auto& item : body["items"]) {
if (!item.is_object()) {
results.push_back(json{{"is_correct", false}, {"score", 0}});
continue;
}
results.push_back(grade_one_item(item));
}
const json response = json{
{"results", results},
};
crow::response res(200, response.dump());
res.set_header("Content-Type", "application/json");
return res;
} catch (const exception& e) {
json err = {{"error", string("parse or grade failed: ") + e.what()}};
crow::response res(500, err.dump());
res.set_header("Content-Type", "application/json");
return res;
}
});
[collapse]
部署Edge Function
直接根据supabase官方给出的cli的部署方式来做吧!标了{id}的地方应该填自己项目的真实值,这里脱敏一下
supabase login
supabase link --project-ref {id}
supabase functions deploy grade-exams --project-ref {id}
supabase应该会唤起浏览器来进行登录验证,然后就能够远端部署了
由于调用http服务不得不部署到公网上,所以这次得先放到服务器上了。先把项目整个都丢到服务器上,然后像上面那样重新跑一遍服务就可以——不过既然都部署到服务器了,为了不污染我的服务器还是给crow搞一下容器化吧
本来想偷懒直接打包一个zip传上服务器的,结果发现这沟槽的构建产物有5个g,还是传github吧
给crow写一个Dockerfile来构建容器,同样感谢伟大的cursor老师
[collapse title=Dockerfile]
FROM ubuntu:24.04 AS build
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
git \
ca-certificates \
libasio-dev \
nlohmann-json3-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
# Pull Crow header-only library into local include path used by CMake
RUN mkdir -p /app/third_party/crow && \
git clone --depth=1 https://github.com/CrowCpp/Crow.git /tmp/crow && \
cp -R /tmp/crow/include /app/third_party/crow/include
RUN cmake -S /app -B /app/build && cmake --build /app/build -j
FROM ubuntu:24.04 AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/build/cpp_grader_server /app/cpp_grader_server
EXPOSE 18080
CMD ["/app/cpp_grader_server"]
[/collapse]
然后在lib/cpp目录下执行
docker build -t exam-cpp-grader:latest .
docker run -d --name exam-cpp-grader -p 18080:18080 exam-cpp-grader:latest
等待建构,然后就可以跑起来了

再有就是喜闻乐见的配置一下反代nginx和cloudflare两件套,挂在exam-cpp-grader.mitori.cafe下好了。试一下curl(终于要配完了!)

接着配置一下supabase那边的secret
supabase secrets set CPP_GRADER_URL="https://grader.yourdomain.com/grade" --project-ref {id}
supabase secrets set CPP_GRADER_TOKEN="{your-strong-token}" --project-ref {id}
以及pg_cron
[collapse title=sql]
create extension if not exists pg_cron;
create extension if not exists pg_net;
select vault.create_secret('https://yunojrkknncwxyfyzhvf.supabase.co', 'project_url');
select vault.create_secret('YOUR_SERVICE_ROLE_KEY', 'service_role_key_for_edge');
-- 每5分钟轮询
select
cron.schedule(
'grade-ended-exam-sessions-every-5-min',
'*/5 * * * *',
$$
select
net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url') || '/functions/v1/grade-exams',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key_for_edge')
),
body := '{"trigger":"cron"}'::jsonb
) as request_id;
$$
);
[/collapse]
这样我们就获得了一个定时任务

新建一个考试来测试,将考试结束时间设置的非常接近当前时间,然后随便进行一些惊险刺激的答题()然后燥候批改时间的到来——然后发现成功了!

当然,对应的考试一览处,这场考试页显示了结束

至此,我们完成了所有考试相关的内容
错题集
不出意外这应该是最后一个大板块了,讨论写不完了
由于我们没有单独存一个错题表维护,因此考虑先从session表里获得所有user参与过的session,然后拿着这些session再去attempt表查所有is_correct为false的记录
[collapse title=getWrongQuestions]
export async function getSessionsByUserid(userId: string) {
const supabase = await createClient();
const res = supabase
.from("attempt_sessions")
.select("id")
.eq("user_id", userId)
.then(({ data, error }) => {
if (error) {
console.error(`Failed to fetch exam sessions for user ID ${userId}:`, error);
return [];
}
return data ?? [];
});
return res;
}
export async function getWrongQuestions( userId: string) {
const supabase = await createClient();
const sessions = await getSessionsByUserid(userId);
const res= supabase
.from("question_attempts")
.select("question_id")
.eq("session_id", sessions.map((s) => s.id))
.eq("is_correct", false)
return res;
}
[/collapse]
其他很多地方直接服用题库写的组件就好了,效果如下。这个页面几乎没什么交互(除了筛选但我们用的是searchParams)所以没有用到state的地方,简单写写就好

结语
本人
其实本来应该还有很多东西没写的,包括仪表盘其实最后也没写,多角色还有角色切换也没写,谁能想到我切换角色测试都是直接打路由的,不过无所谓了以后有空再写
这次新接触的东西主要是写rpc还有edge function,额还有crow,感觉都非常有趣,也是第一次搭上http服务了(第一次吗)。然后也重新巩固了一下很多react hooks的用法,当然还有和nextjs16做抗衡(。。。。)
说实话如果不考虑时间紧张的话写的还是很开心的
这里也附上花花米的体会
在这次结对编程中,我负责判分核心模块的实现,同伴负责题目管理和前端交互。通过明确分工和函数接口约定,我们能够并行开发互不阻塞。最大的收获是体会到接口设计的重要性——我只需要保证 normalize_all_answers 输出固定的 map 结构,同伴无需关心内部实现就能直接调用。同时也学会了用 std::set 优雅解决去重排序问题,避免手写复杂循环。当然也遇到了一些小摩擦,比如对“多选题是否允许漏选”的理解不一致,通过及时沟通统一了“全对才算对”的标准。总的来说,结对编程不仅提高了代码质量,也锻炼了团队协作和沟通能力。

浙公网安备 33010602011771号