Files
ClassHelper/packages/server/prisma/seed.ts
T
LoveChengke 817af02357 feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

599 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 种子数据:pnpm db:seed
*
* 会清空业务表后重建一套完整演示数据:
* 1 管理员 / 2 教师 / 3 个在读班级 / 1 个已毕业届别 / 15 名学生 /
* 课程(含各科任课老师)/ 课表 / 作业 / 通知 / 成绩 / 调班与转出历史
*
* 班级称呼一律是「XXXX级X班」(入学年份 + 班号)—— 见 shared 的 `formatClassName()`。
*
* 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员
* teacher1 / teacher123 - 张老师(2026级1班 班主任,兼 2026级1班 数学)
* teacher2 / teacher123 - 李老师(2026级2班 班主任,兼 2026级1班 语文)
* (学生只有名单、没有账号:ClassHelper 班级端统一用班级码 + 班级密码登录)
*/
import {
SUBJECT_CATALOG,
dayKeyLocal,
formatClassName,
formatYearLabel,
gradeLevel,
gradePercent,
} from '@classhelper/shared';
import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
import { MAX_TERM_WEEK } from '../src/lib/term.js';
const STUDENT_NAMES = [
'王小明',
'李思远',
'张欣怡',
'刘子涵',
'陈嘉豪',
'杨雨欣',
'赵依然',
'黄浩然',
'周思彤',
'吴俊杰',
'徐若曦',
'孙晨曦',
'马文博',
'朱佳琪',
'胡一鸣',
];
// 课程目录:与 shared 的 SUBJECT_CATALOG 保持一致(演示数据覆盖全部常见科目)
const COURSE_NAMES = [...SUBJECT_CATALOG];
/** 每天的上课节次模板(dayOfWeek 1-5) */
const PERIOD_TEMPLATE = [
{ startTime: '08:00', endTime: '08:45' },
{ startTime: '08:55', endTime: '09:40' },
{ startTime: '10:00', endTime: '10:45' },
{ startTime: '10:55', endTime: '11:40' },
{ startTime: '14:00', endTime: '14:45' },
{ startTime: '14:55', endTime: '15:40' },
];
const CLASS_ROOMS = ['教学楼 A301', '教学楼 A302', '实验楼 B205'];
/** 固定种子的伪随机数,保证每次 seed 结果一致 */
function createRandom(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
}
async function resetDatabase(): Promise<void> {
// 顺序敏感:先删子表,再删主表
// 权限组 / 客户端设备 / 远端指令 / 安装包缓存 / 大模型审计(2026-10-10 新增表)
// 注意:ClientDevice.classId 是 SetNull,不显式删会留下"孤儿设备"(班级已删、设备还在)
await prisma.clientCommand.deleteMany();
await prisma.clientDevice.deleteMany();
await prisma.clientRelease.deleteMany();
await prisma.aiAudit.deleteMany();
await prisma.userPermissionGroup.deleteMany();
await prisma.permissionGroupItem.deleteMany();
await prisma.permissionGroup.deleteMany();
// 全局设置(大模型配置 / 开发者模式)不属于业务数据,但演示库重置时一并清掉,保证可复现
await prisma.appSetting.deleteMany();
await prisma.grade.deleteMany();
await prisma.homeworkStatus.deleteMany();
await prisma.homework.deleteMany();
await prisma.notificationRead.deleteMany();
await prisma.notification.deleteMany();
await prisma.schedule.deleteMany();
await prisma.course.deleteMany();
await prisma.studentClassTransfer.deleteMany();
// 学生要排在调班历史之后、班级之前(Student.classId 是 SetNull,但历史是 Cascade)
await prisma.student.deleteMany();
await prisma.user.deleteMany();
// 班级归到届别上,所以先删班级再删届别
await prisma.class.deleteMany();
await prisma.archivedYear.deleteMany();
}
async function main(): Promise<void> {
logger.info('开始写入种子数据...');
if (process.env.NODE_ENV === 'production') {
throw new Error('拒绝在生产环境执行 seed(NODE_ENV=production)');
}
await resetDatabase();
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
// 学生没有密码:个人学生账号已清理(2026-10-01),ClassHelper 班级端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
// 班级账号默认密码(ClassHelper 班级端「班级登录」)
hashPassword('123456'),
]);
const admin = await prisma.user.create({
data: { username: 'admin', name: '系统管理员', role: 'ADMIN', passwordHash: adminHash },
});
const teacher1 = await prisma.user.create({
data: { username: 'teacher1', name: '张老师', role: 'TEACHER', passwordHash: teacherHash },
});
const teacher2 = await prisma.user.create({
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 内置权限组示例:**没有分配给任何账号**,所以不影响默认行为(演示「创建权限组」这条需求)。
// 一旦把某个老师加进组,他的生效权限就**只由组的并集决定**(可以比角色默认更少 ⇒ 也能收回权限)。
await prisma.permissionGroup.create({
data: {
name: '教务协作(示例)',
description: '可查看班级与课表、发布作业与通知、录入成绩;不能创建班级或管理教师账号',
builtin: true,
items: {
create: [
'class:read',
'schedule:read',
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'grade:read',
'grade:write',
'dashboard:read',
'term:read',
'client:read',
'ai:use',
].map((key) => ({ key })),
},
},
});
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
//
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
// 毕业年份 = 入学年份 + 3,所以这一届 2026 级将在 2029 年归档。
const classSeeds = [
{ enrollmentYear: 2026, classIndex: 1, grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ enrollmentYear: 2026, classIndex: 2, grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ enrollmentYear: 2025, classIndex: 3, grade: '高二', teacherId: teacher1.id, code: 'G203' },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: {
name: formatClassName(seed.enrollmentYear, seed.classIndex),
grade: seed.grade,
enrollmentYear: seed.enrollmentYear,
classIndex: seed.classIndex,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
},
});
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
}
// ---------------------------------------------------------------- 学生(只是名单,不是账号)
const students = [];
for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
const classSlot = Math.floor(index / 5);
const targetClass = classes[classSlot] ?? classes[0]!;
// 学号:入学年份 + 两位序号(与班级称呼同一套「届别」口径)
const student = await prisma.student.create({
data: {
studentNo: `${targetClass.enrollmentYear}${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
classId: targetClass.id,
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
guardianPhone: `138${String(10000000 + index * 137).slice(0, 8)}`,
},
});
students.push(student);
}
// ---------------------------------------------------------------- 课程(= 班级 + 科目 + 教师的任课关系)
//
// 每门课都要挂一位**任课老师**:作业与成绩能不能被某位老师改动,查的就是 `Course.teacherId`。
// 演示数据刻意让「语文」由另一位老师任教 —— 这样班主任在语文上就没有编辑权,
// 正好覆盖需求「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
const OTHER_TEACHER_SUBJECT = '语文';
const coursesByClass = new Map<string, { id: string; name: string }[]>();
for (const targetClass of classes) {
const otherTeacher = targetClass.teacherId === teacher1.id ? teacher2.id : teacher1.id;
const created = [];
for (const courseName of COURSE_NAMES) {
const course = await prisma.course.create({
data: {
name: courseName,
classId: targetClass.id,
teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId,
},
});
created.push({ id: course.id, name: course.name });
}
coursesByClass.set(targetClass.id, created);
}
// ---------------------------------------------------------------- 课表
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
for (let dayOfWeek = 1; dayOfWeek <= 5; dayOfWeek += 1) {
for (const [periodIndex, period] of PERIOD_TEMPLATE.entries()) {
const course = classCourses[(dayOfWeek + periodIndex) % classCourses.length];
if (!course) continue;
// 前 2 节限制在第 1 周(演示按周筛选)
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
// 周二第 3 节演示"单双周":单周上次课表里的科目、双周上一门别的科目
const paritySlot = dayOfWeek === 2 && periodIndex === 2;
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: course.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
weekParity: paritySlot ? 'ODD' : 'ALL',
},
});
if (paritySlot) {
// 双周同一节换另一门课,用于演示"单双周课表"(第 1 周单周、第 2 周双周)
const evenCourse = classCourses[(dayOfWeek + periodIndex + 2) % classCourses.length];
if (evenCourse) {
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: evenCourse.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: MAX_TERM_WEEK,
weekParity: 'EVEN',
},
});
}
}
}
}
}
// ---------------------------------------------------------------- 作业
// 注:作业"截止时间"功能已下线,种子数据不再写入 dueAt(库字段保留以兼容已装库)
const now = Date.now();
const day = 86_400_000;
const homeworkSeeds = [
{
title: '《劝学》全文背诵',
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
courseIndex: 0,
},
{
title: '数学必修一 3.2 习题',
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
courseIndex: 1,
},
{
title: '英语周记一篇',
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
courseIndex: 2,
},
{
title: '物理实验报告',
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
courseIndex: 3,
},
{
title: '化学方程式默写',
content: '默写第一至第三章全部化学方程式,家长签字。',
courseIndex: 4,
},
];
const homeworks = [];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
for (const seed of homeworkSeeds) {
const course = classCourses[seed.courseIndex % classCourses.length];
const homework = await prisma.homework.create({
data: {
classId: targetClass.id,
courseId: course?.id ?? null,
title: `${seed.title}`,
content: seed.content,
// 种子直接走 prisma.create,绕过了 service 里 `assignDate ?? 今天` 的兜底,
// 不显式写就会落成 schema 默认值空串 —— 于是「按天查看 / 日期高亮」全是空的。
assignDate: dayKeyLocal(new Date()),
attachmentUrl:
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
createdBy: targetClass.teacherId,
},
});
homeworks.push(homework);
}
}
// 部分学生已完成部分作业
const studentsByClass = new Map<string, typeof students>();
for (const student of students) {
if (!student.classId) continue;
const bucket = studentsByClass.get(student.classId) ?? [];
bucket.push(student);
studentsByClass.set(student.classId, bucket);
}
for (const homework of homeworks) {
const classmates = studentsByClass.get(homework.classId) ?? [];
for (const [index, student] of classmates.entries()) {
const completed = (index + homework.title.length) % 3 !== 0;
if (!completed && index % 2 === 1) continue;
await prisma.homeworkStatus.create({
data: { homeworkId: homework.id, studentId: student.id, completed },
});
}
}
// ---------------------------------------------------------------- 通知
const notificationSeeds = [
{
title: '关于期中考试安排的通知',
content: '期中考试定于下月 8 日至 10 日进行,请同学们提前复习,考试安排详见班级公告栏。',
priority: 'HIGH',
daysAgo: 0.2,
},
{
title: '明天下午临时调课',
content: '因教师外出教研,明天下午第 5、6 节课与周五下午对调,请相互转告。',
priority: 'URGENT',
daysAgo: 0.6,
},
{
title: '校运动会报名开始',
content: '本届校运动会开始报名,每人限报 2 个项目,请到体育委员处登记。',
priority: 'NORMAL',
daysAgo: 2,
},
{
title: '教室卫生值日表更新',
content: '新的值日表已张贴在教室后墙,请各小组按时完成值日。',
priority: 'LOW',
daysAgo: 4,
},
];
for (const targetClass of classes) {
for (const seed of notificationSeeds) {
const notification = await prisma.notification.create({
data: {
classId: targetClass.id,
title: seed.title,
content: seed.content,
priority: seed.priority,
createdBy: targetClass.teacherId,
createdAt: new Date(now - seed.daysAgo * day),
},
});
// 第一条通知:部分学生已读,用于演示未读红点
if (seed.priority === 'LOW') {
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
for (const student of classmates) {
await prisma.notificationRead.create({
data: { notificationId: notification.id, studentId: student.id },
});
}
}
}
}
// ---------------------------------------------------------------- 成绩
//
// 三种等级口径各演示一门,方便在界面上看出差别:
// 数学(percent)—— 按得分率自动换算 A~E
// 语文(custom) —— 自定义等级文本
// 英语(letter) —— A / B / C / D
const examNames = ['第一次月考', '期中考试'];
const customLevels = ['优', '良', '合格'] as const;
const letterLevels = ['A', 'B', 'C', 'D'] as const;
const levelPlan = [
{ type: 'percent' as const },
{ type: 'custom' as const },
{ type: 'letter' as const },
];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
const classmates = studentsByClass.get(targetClass.id) ?? [];
for (const [examIndex, examName] of examNames.entries()) {
for (const [courseIndex, course] of classCourses.slice(0, 3).entries()) {
const plan = levelPlan[courseIndex % levelPlan.length]!;
for (const student of classmates) {
const base = 60 + random() * 38;
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
const percent = gradePercent(score, 100);
const level =
plan.type === 'percent'
? gradeLevel(percent)
: plan.type === 'letter'
? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D')
: (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? '合格');
await prisma.grade.create({
data: {
classId: targetClass.id,
courseId: course.id,
studentId: student.id,
examName,
score,
totalScore: 100,
levelType: plan.type,
level,
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
},
});
}
}
}
}
// ---------------------------------------------------------------- 调班 / 转出历史
//
// 调班只改 `Student.classId`(学号不变,历史作业与成绩留在原班级),并留一条可查的痕迹。
const movedStudent = students[7]!; // 赵依然:2026级2班 → 2026级1班(曾经)
await prisma.student.update({
where: { id: movedStudent.id },
data: { classId: classes[0]!.id },
});
await prisma.studentClassTransfer.create({
data: {
studentId: movedStudent.id,
studentNo: movedStudent.studentNo,
studentName: movedStudent.name,
fromClassId: classes[0]!.id,
fromClassName: classes[0]!.name,
toClassId: classes[1]!.id,
toClassName: classes[1]!.name,
operatorId: admin.id,
operatorName: admin.name,
mode: 'batch',
note: '军训后按分班考试成绩重新均衡',
createdAt: new Date(now - 30 * day),
},
});
// 转出:学籍离开本校(不是调班),班级归属保留作历史
const transferredStudent = students[13]!; // 朱佳琪
await prisma.student.update({
where: { id: transferredStudent.id },
data: {
status: 'transferred',
transferredAt: new Date(now - 12 * day),
transferNote: '随家长工作调动转学至外地',
},
});
await prisma.studentClassTransfer.create({
data: {
studentId: transferredStudent.id,
studentNo: transferredStudent.studentNo,
studentName: transferredStudent.name,
fromClassId: transferredStudent.classId,
fromClassName: classes[2]!.name,
toClassId: null,
toClassName: '转出本校',
operatorId: admin.id,
operatorName: admin.name,
mode: 'transfer-out',
note: '随家长工作调动转学至外地',
createdAt: new Date(now - 12 * day),
},
});
// ---------------------------------------------------------------- 已毕业届别(归档页的演示数据)
//
// 归档 = 打标记 + 只读:班级与它的作业/通知原样留在库里,只是从常规列表里隐去。
const graduatedClass = await prisma.class.create({
data: {
name: formatClassName(2023, 1),
grade: '高三',
enrollmentYear: 2023,
classIndex: 1,
teacherId: teacher1.id,
code: 'G231',
passwordHash: classPasswordHash,
},
});
const archivedYear = await prisma.archivedYear.create({
data: {
enrollmentYear: 2023,
graduationYear: 2023 + 3,
name: formatYearLabel(2023),
note: '演示用:2023 级已于 2026 年 6 月毕业',
operatorId: admin.id,
operatorName: admin.name,
archivedAt: new Date(now - 100 * day),
},
});
await prisma.class.update({
where: { id: graduatedClass.id },
data: { archivedYearId: archivedYear.id, archivedAt: new Date(now - 100 * day) },
});
const graduateNames = ['毕业演示甲', '毕业演示乙', '毕业演示丙'];
for (const [index, name] of graduateNames.entries()) {
await prisma.student.create({
data: {
studentNo: `2023${String(index + 1).padStart(2, '0')}`,
name,
classId: graduatedClass.id,
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
status: 'graduated',
archivedYearId: archivedYear.id,
archivedAt: new Date(now - 100 * day),
},
});
}
// 这一届也要有作业与通知,「记录每个年度毕业班级的消息和作业」才有东西可看
await prisma.homework.create({
data: {
classId: graduatedClass.id,
title: '高考前最后一轮复习',
content: '按考点清单过一遍错题本,重点看函数与数列。',
assignDate: dayKeyLocal(new Date(now - 120 * day)),
createdBy: teacher1.id,
},
});
await prisma.notification.create({
data: {
classId: graduatedClass.id,
title: '毕业典礼安排',
content: '6 月 20 日上午 9 点在礼堂举行毕业典礼,请全体同学准时参加。',
priority: 'HIGH',
createdBy: teacher1.id,
createdAt: new Date(now - 110 * day),
},
});
const counts = {
账号: await prisma.user.count(),
在读班级: await prisma.class.count({ where: { archivedYearId: null } }),
学生: await prisma.student.count({ where: { status: 'active' } }),
课程: await prisma.course.count(),
课表: await prisma.schedule.count(),
作业: await prisma.homework.count(),
通知: await prisma.notification.count(),
成绩: await prisma.grade.count(),
调班历史: await prisma.studentClassTransfer.count(),
归档届别: await prisma.archivedYear.count(),
};
logger.info('种子数据写入完成', counts);
console.log('\n================ 演示账号 ================');
console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`);
console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`);
console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`);
console.log('==========================================\n');
}
main()
.catch((error) => {
logger.error('种子数据写入失败', error);
process.exitCode = 1;
})
.finally(async () => {
await disconnectPrisma();
});