① 支持录入教师(与学生录入对齐),权限只有管理员
- 新增 Web「教师管理」页(`Views/TeachersView.vue`):单个录入(姓名/用户名/初始密码/角色)、
导入名单(xlsx/csv:用户名、姓名、初始密码、角色)、编辑(改名/改角色)、重置密码、删除
- 服务端 `teachers` 模块补齐:GET / POST / PATCH /:id / POST /:id/reset-password / DELETE /:id,
全部 `requireRole('ADMIN')`;表格导入新增 kind=teachers(`IMPORT_FIELDS.teachers`、`commitTeachers`、
教师名单模板),教师调用预览/提交一律 403
- 删除护栏:还是班主任(`Class.teacherId`)或持有课程/班级分配的账号拒绝删除(409,提示先更换班主任)——
教师是数据创建者,级联删除会连带删掉班级数据
- 前端路由加 `meta.roles`:`/classes`、`/students`、`/teachers` 仅 ADMIN,教师直接输网址会被挡回仪表盘
(菜单隐藏只是"看不见",这次补上真正的守卫);新增环境变量 `DEFAULT_TEACHER_PASSWORD`
② 创建班级仅管理员 + 设置 / 更改班主任
- 新建班级时可直接指定班主任(不填则创建者本人),班级详情顶部「更换班主任」下拉随时改:
新增 `PATCH /api/classes/:id/head-teacher`(仅管理员,校验目标账号是教师/管理员,广播 class:updated)
- 原班主任保留在协作教师名单里,避免换人后丢掉历史协作关系
③ 授课科目全校统一(用户澄清:"统一"指的是授课科目固定,不是各班课表相同)
- `SUBJECT_CATALOG` 改为用户给定的 18 科:语文/数学/英语/政治/历史/物理/化学/地理/生物/信息技术/
体育/美术/音乐/班会/早读/晚读/通用技术/听力
- 课表「新增课表」与成绩「单条/批量录入」的科目下拉直接列出统一目录(该班还没有的进
「统一科目(选中后自动建课)」分组),选中后自动为该班建同名课程再落库 —— 不必按班级一个个录科目
- 各班课表仍然各自独立(各班的课表本来就不一样)
④ 登录页去掉所有示例内容
- Web 登录页删除「演示账号(点击填充)」标签列表与"种子数据见 README"提示;客户端班级码占位符
由「例如 G101(由老师在班级管理中提供)」改为「请输入班级码」
- `verify:web` 与 `verify:desktop` 各自断言登录页正文/占位符不含 `演示/示例/admin123/teacher123/G101`
⑤ 验证脚本不再依赖演示数据(并修掉自身残留)
- 清空服务端作业/通知后,联网自检改为**自建**一条作业/通知并在收尾删除(`seedSmokeContent`);
权限用例需要"其它教师的班级/科任班级"时现建临时班级并在收尾删除
- e2e 新增清理:实时推送用例造的通知/作业、上课时段用例造的两条通知、教师名单/临时班级
(此前这几条会留在库里);班级详情相关断言不再依赖种子班级布局
- ui-smoke 新增 7 项:登录页无示例、教师端访问 /teachers 被挡回、统一科目下拉 + 自动建课 + 收尾删除
课表与课程、管理员真人录入教师 → 列表出现 → 删除
- 冒烟里"岛就绪"与"截图留档"改为轮询等待(偶发丢一张留档图会误判"形态尺寸递增")
实测:verify:e2e 163/163(安装实例)、verify:web 27/27、verify:desktop 77/77(离线复跑 69/69)、
verify:packaged 对 release/win-unpacked 与 D:\class\@classhelperdesktop-client 均 77/77 且 packaged=true;
typecheck / lint / format:check 全绿。服务端安装实例与客户端安装目录已就地更新。
165 lines
7.3 KiB
TypeScript
165 lines
7.3 KiB
TypeScript
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { config as loadEnv } from 'dotenv';
|
||
import { z } from 'zod';
|
||
|
||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||
/**
|
||
* packages/server 根目录。
|
||
* 源码运行时为 src/config -> ../..;编译后为 dist/config -> ../..,两者一致。
|
||
*/
|
||
export const serverRoot = path.resolve(currentDir, '..', '..');
|
||
|
||
// 显式按包根目录加载 .env,避免受启动时 cwd 影响。
|
||
// 打包安装后配置文件放在安装根目录(server 的上一级),因此按顺序尝试两处;
|
||
// dotenv 默认不覆盖已存在的变量,所以先匹配到的文件优先生效。
|
||
loadEnv({ path: path.join(serverRoot, '.env') });
|
||
loadEnv({ path: path.join(serverRoot, '..', '.env') });
|
||
|
||
const envSchema = z.object({
|
||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
|
||
/** 监听地址:生产环境建议 0.0.0.0(容器/内网访问),本地开发可用 127.0.0.1 */
|
||
HOST: z.string().default('0.0.0.0'),
|
||
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
|
||
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
|
||
JWT_SECRET: z.string().min(16),
|
||
JWT_EXPIRES_IN: z.string().default('7d'),
|
||
CORS_ORIGIN: z.string().default('*'),
|
||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
|
||
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
||
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
|
||
TERM_START_DATE: z.string().default(''),
|
||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
|
||
TRUST_PROXY: z.string().default('false'),
|
||
/** Web 管理端构建产物目录;留空则自动探测 ../web-admin/dist 或 ./web */
|
||
WEB_DIST_DIR: z.string().default(''),
|
||
/** 是否启用接口限流(压测时可置 false) */
|
||
RATE_LIMIT_ENABLED: z
|
||
.string()
|
||
.default('true')
|
||
.transform((value) => value !== 'false' && value !== '0'),
|
||
/** 启动时是否校验数据库连通性 */
|
||
STARTUP_DB_CHECK: z
|
||
.string()
|
||
.default('true')
|
||
.transform((value) => value !== 'false' && value !== '0'),
|
||
/** 自动初始化:数据库为空时是否自动执行迁移(安装程序首启动用) */
|
||
AUTO_MIGRATE: z
|
||
.string()
|
||
.default('false')
|
||
.transform((value) => value === 'true' || value === '1'),
|
||
/** 进程 PID 文件(相对路径以 server 根目录为基准),供安装包的启停脚本使用 */
|
||
PID_FILE: z.string().default(''),
|
||
/** 首次初始化(空库)时创建的管理员账号 */
|
||
INITIAL_ADMIN_USERNAME: z.string().min(3).default('admin'),
|
||
INITIAL_ADMIN_PASSWORD: z.string().min(6).default('admin123'),
|
||
});
|
||
|
||
const parsed = envSchema.safeParse(process.env);
|
||
|
||
if (!parsed.success) {
|
||
const issues = parsed.error.issues
|
||
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||
.join('\n');
|
||
throw new Error(`环境变量校验失败,请检查 packages/server/.env(可从 .env.example 复制):\n${issues}`);
|
||
}
|
||
|
||
const raw = parsed.data;
|
||
|
||
/** 把 file:./prisma/dev.db 解析为绝对路径,保证任何 cwd 下都指向同一个数据库文件 */
|
||
function resolveSqliteUrl(url: string): string {
|
||
if (!url.startsWith('file:')) return url;
|
||
const filePath = url.slice('file:'.length);
|
||
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(serverRoot, filePath);
|
||
return `file:${absolute.replace(/\\/g, '/')}`;
|
||
}
|
||
|
||
/** 从 file: URL 中取出真实的文件系统路径(供启动前建目录等使用) */
|
||
function sqliteFilePathOf(url: string): string {
|
||
const withoutPrefix = url.startsWith('file:') ? url.slice('file:'.length) : url;
|
||
return withoutPrefix.replace(/\\/g, path.sep).replace(/\//g, path.sep);
|
||
}
|
||
|
||
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
|
||
const isProduction = raw.NODE_ENV === 'production';
|
||
|
||
const resolvedDatabaseUrl = isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL;
|
||
/** SQLite 数据文件的绝对路径(MySQL 时为空字符串),供启动前创建目录使用 */
|
||
const sqliteFilePath = isSqlite ? sqliteFilePathOf(resolvedDatabaseUrl) : '';
|
||
|
||
/** 生产环境配置自检:不安全的默认值直接拒绝启动,避免把开发配置带上生产 */
|
||
const INSECURE_SECRET_MARKERS = ['change-me', 'changeme', 'secret', 'classhelper-dev'];
|
||
if (isProduction) {
|
||
const problems: string[] = [];
|
||
if (raw.JWT_SECRET.length < 32) problems.push('JWT_SECRET 长度必须 ≥ 32 位');
|
||
if (INSECURE_SECRET_MARKERS.some((marker) => raw.JWT_SECRET.toLowerCase().includes(marker))) {
|
||
problems.push('JWT_SECRET 仍是示例/开发用值,请重新生成');
|
||
}
|
||
if (isSqlite && raw.DATABASE_URL.includes('dev.db')) {
|
||
problems.push('生产环境仍在使用 dev.db,请指定正式的数据文件或改用 MySQL');
|
||
}
|
||
if (problems.length > 0) {
|
||
throw new Error(
|
||
`生产环境(NODE_ENV=production)配置检查未通过:\n${problems.map((item) => ` - ${item}`).join('\n')}\n` +
|
||
'请修改 packages/server/.env 后重试。',
|
||
);
|
||
}
|
||
}
|
||
|
||
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
|
||
function resolveTrustProxy(value: string): boolean | number | string {
|
||
if (value === 'false' || value === '') return false;
|
||
if (value === 'true') return true;
|
||
if (/^\d+$/.test(value)) return Number(value);
|
||
return value;
|
||
}
|
||
|
||
export const env = {
|
||
nodeEnv: raw.NODE_ENV,
|
||
isProduction,
|
||
isDevelopment: raw.NODE_ENV === 'development',
|
||
port: raw.PORT,
|
||
host: raw.HOST,
|
||
databaseProvider: raw.DATABASE_PROVIDER,
|
||
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
|
||
databaseUrl: resolvedDatabaseUrl,
|
||
/** SQLite 数据文件绝对路径(MySQL 时为空) */
|
||
sqliteFilePath,
|
||
rawDatabaseUrl: raw.DATABASE_URL,
|
||
jwtSecret: raw.JWT_SECRET,
|
||
jwtExpiresIn: raw.JWT_EXPIRES_IN,
|
||
/** true 表示允许所有来源,否则为白名单数组 */
|
||
corsOrigins:
|
||
raw.CORS_ORIGIN === '*'
|
||
? true
|
||
: raw.CORS_ORIGIN.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||
/** 新增学生时未指定密码时的初始密码 */
|
||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||
/** 新建班级时的默认班级密码(班级账号登录) */
|
||
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
||
/** 新建/重置教师账号时的初始密码 */
|
||
defaultTeacherPassword: raw.DEFAULT_TEACHER_PASSWORD,
|
||
termStartDate: raw.TERM_START_DATE,
|
||
logLevel: raw.LOG_LEVEL,
|
||
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
|
||
webDistDir: raw.WEB_DIST_DIR,
|
||
rateLimitEnabled: raw.RATE_LIMIT_ENABLED,
|
||
startupDbCheck: raw.STARTUP_DB_CHECK,
|
||
autoMigrate: raw.AUTO_MIGRATE,
|
||
/** PID 文件的绝对路径(未配置时为空字符串) */
|
||
pidFile: raw.PID_FILE ? path.resolve(serverRoot, raw.PID_FILE) : '',
|
||
initialAdminUsername: raw.INITIAL_ADMIN_USERNAME,
|
||
initialAdminPassword: raw.INITIAL_ADMIN_PASSWORD,
|
||
serverRoot,
|
||
} as const;
|
||
|
||
export type AppEnv = typeof env;
|