feat: 完善学生生命周期管理与权限范围并发布 1.4.2

This commit is contained in:
LoveChengke committed 2026-10-23 22:55:21 +08:00
1 parent 6347d1e5aa
commit 24e8dd902b
92 files changed
+6801 -2255

No files matched your search

+98 -42
View File
@@ -36,6 +36,9 @@ const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
*/
const MENU_ITEMS = [
{ label: '仪表盘', path: '/dashboard' },
{ label: '班级管理', path: '/classes' },
{ label: '学生管理', path: '/students' },
{ label: '学期周次', path: '/term-weeks' },
{ label: '课表档案', path: '/plan-editor' },
{ label: '作业发布', path: '/homeworks' },
{ label: '通知发布', path: '/notifications' },
@@ -46,7 +49,7 @@ const MENU_ITEMS = [
];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理', '数据库管理'];
const HIDDEN_MENU_LABELS = ['教师管理', '数据库管理'];
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
@@ -558,10 +561,10 @@ async function main() {
// 等视图渲染
const viewDeadline = Date.now() + 3000;
while (Date.now() < viewDeadline) {
if (document.querySelector('.page-title')) break;
if (document.querySelector('.page-title, .archive-titlebar > span')) break;
await new Promise((resolve) => setTimeout(resolve, 60));
}
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
const title = (document.querySelector('.page-title, .archive-titlebar > span')?.textContent ?? '').trim();
return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected };
})()`);
@@ -796,7 +799,7 @@ async function main() {
return { labels, leaked };
})()`);
record(
'教师端隐藏无权入口(班级管理/学生管理/教师管理)',
'教师端隐藏未授权教师管理与数据库入口',
(hiddenCheck?.leaked ?? ['?']).length === 0,
`可见菜单=[${(hiddenCheck?.labels ?? []).join(',')}] 越权入口=[${(hiddenCheck?.leaked ?? []).join(',')}]`,
);
@@ -1013,7 +1016,10 @@ async function main() {
await sleep(600);
const buttons = Array.from(document.querySelectorAll('button'));
const openButton = buttons.find((node) => (node.textContent ?? '').trim() === '导入档案');
const transfer = buttons.find((node) => (node.textContent ?? '').trim() === '导入/导出');
transfer?.click();
await sleep(200);
const openButton = Array.from(document.querySelectorAll('button')).find((node) => (node.textContent ?? '').trim() === '导入 ClassIsland 档案');
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
// 旧入口必须已经下线(需求 ⑩)
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
@@ -1357,57 +1363,107 @@ async function runPlanEditorChecks(win) {
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
const ready = await waitFor(
win,
`document.body.innerText.includes('课表编辑') && Boolean(document.querySelector('.course-row'))`,
);
record('统一课表档案工作区与课程时段编辑可用', ready);
const save = await win.webContents.executeJavaScript(
`(() => {const b=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='保存课表');b?.click();return Boolean(b&&!b.disabled);})()`,
);
const saved = save && (await waitFor(win, `document.body.innerText.includes('整张日课表已保存')`));
record('日课表整表保存可以从页面提交', saved);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.editor-nav button')].find(e=>e.textContent.trim()==='时间表')?.click();})()`,
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
);
record(
'时间表页四种时间点与管理员保存入口可见',
'ClassIsland 风格档案工作区默认展示周课表和选科侧栏',
ready &&
(await win.webContents.executeJavaScript(
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4`,
)),
);
const assignment = await win.webContents.executeJavaScript(`(() => {
const cell = document.querySelector('.lesson-cell');
if (!cell) return null;
const original = cell.querySelector('strong')?.textContent.trim();
cell.click();
const button = [...document.querySelectorAll('.subject-palette button')].find(b => !b.disabled && b.title !== original);
button?.click();
return { original, editable: Boolean(button) };
})()`);
const dirty =
assignment?.editable && (await waitFor(win, `document.body.innerText.includes('有未保存的修改')`));
record('点击周课表并从右侧选科生成可保存的课程草稿', dirty);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.archive-commands button')].find(b => b.textContent.trim() === '保存')?.click();})()`,
);
record(
'课表草稿可通过现有接口保存',
dirty &&
(await waitFor(
win,
`document.body.innerText.includes('档案已保存') && !document.querySelector('.draft-label')`,
)),
);
// 恢复隔离验收班级的原科目。
if (assignment?.original) {
await win.webContents.executeJavaScript(`(() => {
document.querySelector('.lesson-cell')?.click();
const original = ${JSON.stringify(assignment.original)};
[...document.querySelectorAll('.subject-palette button')].find(b => b.title === original)?.click();
})()`);
await sleep(150);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.archive-commands button')].find(b => b.textContent.trim() === '保存')?.click();})()`,
);
await waitFor(win, `!document.querySelector('.draft-label')`);
}
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
record(
'列表视图保留课时编辑和批量操作',
await waitFor(
win,
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表')`,
`Boolean(document.querySelector('.course-row')) && document.body.innerText.includes('批量排课')`,
),
);
await win.webContents.executeJavaScript(
`document.querySelector('.archive-tabs button:nth-child(2)').click()`,
);
record(
'时间表页四种时间点与保存入口可见',
await waitFor(
win,
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动')`,
),
);
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
const rows = () => document.querySelectorAll('.time-row');
const before = rows().length;
[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='+ 新建课时')?.click();
await new Promise((resolve) => setTimeout(resolve, 180));
const after = rows().length;
const last = rows()[after - 1];
const values = last ? [...last.querySelectorAll('.el-date-editor input')].map((input) => input.value) : [];
const [start, end] = values;
const diff = (() => {
if (!/^\\d{2}:\\d{2}$/.test(start ?? '') || !/^\\d{2}:\\d{2}$/.test(end ?? '')) return -1;
const [h1, m1] = start.split(':').map(Number), [h2, m2] = end.split(':').map(Number);
return h2 * 60 + m2 - (h1 * 60 + m1);
})();
return { before, after, start, end, diff };
const before = document.querySelectorAll('.time-row').length;
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+ 新建课时')?.click();
await new Promise(resolve => setTimeout(resolve, 180));
const rows = document.querySelectorAll('.time-row');
const values = [...(rows[rows.length - 1]?.querySelectorAll('.el-date-editor input') || [])].map(input => input.value);
const minutes = (value) => { const [h, m] = (value || '').split(':').map(Number); return h * 60 + m; };
return { before, after: rows.length, diff: minutes(values[1]) - minutes(values[0]) };
})()`);
record(
'时间表新建课时按快速时长生成起止时间',
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
`行数 ${quickAdd.before}→${quickAdd.after} 时间=${quickAdd.start ?? '-'}–${quickAdd.end ?? '-'} 分钟=${quickAdd.diff}`,
);
await win.webContents.executeJavaScript(
`document.querySelector('.editor-nav button:nth-child(5)').click()`,
`(() => {[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();})()`,
);
await waitFor(win, `Boolean(document.querySelector('.el-message-box'))`);
await win.webContents.executeJavaScript(
`(() => {[...document.querySelectorAll('.el-message-box button')].find(b => b.textContent.trim() === '放弃修改')?.click();})()`,
);
await waitFor(win, `!document.querySelector('.draft-label')`);
await win.webContents.executeJavaScript(
`document.querySelector('.archive-tabs button:first-child').click()`,
);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="月视图"]').click()`);
record(
'整周预览与当前生效课表保持一致',
await waitFor(
win,
`document.body.innerText.includes('整周课表预览') && document.querySelector('.week-grid .el-table__body')`,
),
'月视图提供日期导航',
await waitFor(win, `document.querySelectorAll('.calendar-grid button').length === 42`),
);
await win.webContents.executeJavaScript(`document.querySelector('.editor-nav button:first-child').click()`);
await sleep(300);
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
record('底部视图切换可返回周课表', await waitFor(win, `Boolean(document.querySelector('.schedule-grid'))`));
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.webContents.executeJavaScript(`document.querySelector('.return-button').click()`);
await waitFor(win, `location.pathname === '/dashboard'`);
const collapsedBefore = await win.webContents.executeJavaScript(
`Boolean(document.querySelector('.layout-aside.is-collapsed'))`,
);
@@ -1425,7 +1481,7 @@ async function runPlanEditorChecks(win) {
await sleep(700);
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
fs.writeFileSync(
path.join(__dirname, '../../docs/screenshots/plan-editor-workspace.png'),
path.join(__dirname, '../../docs/screenshots/plan-editor-shell.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.loadURL(new URL('/system-settings', TARGET_URL).href);
@@ -1441,7 +1497,7 @@ async function runPlanEditorChecks(win) {
'学年升级设置与待补跑提示可见且不执行升级',
await waitFor(
win,
`document.body.innerText.includes('每年自动学年升级') && document.body.innerText.includes('总周数')`,
`document.body.innerText.includes('学年升级计划') && document.body.innerText.includes('总周数')`,
),
);
await win.loadURL(new URL('/archives', TARGET_URL).href);
+81 -4
View File
@@ -20,7 +20,7 @@ try {
.readdirSync(migrationRoot)
.filter((name) => fs.existsSync(path.join(migrationRoot, name, 'migration.sql')))
.sort();
assert.equal(names.at(-1), '20261017090000_permission_read_keys');
assert.ok(names.length > 0, '必须存在可回放的迁移');
const baseline = names.indexOf('20261016090000_retire_integration_and_schedule_updates');
await client.execute(
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
@@ -46,7 +46,7 @@ try {
await client.execute(
`INSERT INTO "PermissionGroup" (id,name,updatedAt) VALUES ('custom','教师默认权限',CURRENT_TIMESTAMP)`,
);
const run = (expected) => {
const run = (expected, targetUrl = databaseUrl) => {
const script = `const {ensureSchema}=await import('./packages/server/src/lib/db-bootstrap.ts');
const result=await ensureSchema(); if(result.applied!==${expected})throw new Error(JSON.stringify(result));
const {ensureDefaultGroups}=await import('./packages/server/src/lib/permissions.ts');
@@ -56,7 +56,13 @@ try {
['--import', './packages/server/node_modules/tsx/dist/loader.mjs', '--input-type=module', '-e', script],
{
cwd: root,
env: { ...process.env, DATABASE_URL: databaseUrl, DATABASE_PROVIDER: 'sqlite' },
env: {
...process.env,
NODE_ENV: 'test',
JWT_SECRET: 'migrations-isolated-verification-secret-2026',
DATABASE_URL: targetUrl,
DATABASE_PROVIDER: 'sqlite',
},
encoding: 'utf8',
timeout: 30_000,
},
@@ -85,11 +91,82 @@ try {
).rows[0].n,
0,
);
// 第二个夹具模拟本次重构前已初始化的默认组和旧 mode 历史。
const legacyUrl = `file:${path.join(dir, 'lifecycle-legacy.db').replaceAll('\\', '/')}`;
const legacy = createClient({ url: legacyUrl });
try {
const split = names.indexOf('20261023000000_promotion_permission');
assert(split > 0);
await legacy.execute(
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
);
for (const name of names.slice(0, split)) {
await legacy.executeMultiple(fs.readFileSync(path.join(migrationRoot, name, 'migration.sql'), 'utf8'));
await legacy.execute({
sql: 'INSERT INTO "_prisma_migrations" VALUES (?, CURRENT_TIMESTAMP, NULL)',
args: [name],
});
}
await legacy.execute(
`INSERT INTO "PermissionGroup" (id,name,defaultRole,scope,updatedAt) VALUES ('legacy-admin','旧管理员默认组','ADMIN','ALL',CURRENT_TIMESTAMP)`,
);
await legacy.execute(
`INSERT INTO "PermissionGroupItem" (id,groupId,key) VALUES ('legacy-key','legacy-admin','roster:read')`,
);
await legacy.execute(
`INSERT INTO "Student" (id,studentNo,name,updatedAt) VALUES ('legacy-student','LEGACY','旧学生',CURRENT_TIMESTAMP)`,
);
for (const [id, mode] of [
['old-single', 'single'],
['old-out', 'transfer-out'],
['old-restore', 'restore-transfer'],
])
await legacy.execute({
sql: "INSERT INTO \"StudentClassTransfer\" (id,studentId,studentNo,studentName,mode) VALUES (?,'legacy-student','LEGACY','旧学生',?)",
args: [id, mode],
});
legacy.close();
run(names.length - split, legacyUrl);
const upgraded = createClient({ url: legacyUrl });
try {
const keys = (
await upgraded.execute(`SELECT key FROM "PermissionGroupItem" WHERE groupId='legacy-admin'`)
).rows.map((row) => row.key);
assert(keys.includes('roster:read') && keys.includes('promotion:manage'));
const kinds = (await upgraded.execute('SELECT id,kind,batchId FROM "StudentClassTransfer" ORDER BY id'))
.rows;
assert.deepEqual(
kinds.map((row) => row.kind),
['transfer-out', 'restore', 'class-change'],
);
assert(kinds.every((row) => row.batchId === ''));
assert(
!(await upgraded.execute('PRAGMA table_info("StudentClassTransfer")')).rows.some(
(row) => row.name === 'mode',
),
);
assert(
(await upgraded.execute('PRAGMA table_info("ArchivedYear")')).rows.some(
(row) => row.name === 'inactiveCount',
),
);
} finally {
upgraded.close();
}
run(0, legacyUrl);
} finally {
legacy.close();
}
console.log(
'数据库升级回归通过:接纳 Prisma 迁移记录、保留业务课表、移除联动、避开默认组重名、重启保留空权限组。',
'数据库升级回归通过:保留业务课表与空权限组、默认组补升级键、旧 mode 映射 kind、旧行默认值与重启幂等。',
);
} finally {
client.close();
if (
path.dirname(path.resolve(dir)) !== path.resolve(os.tmpdir()) ||
!path.basename(dir).startsWith('classhelper-upgrade-')
)
throw new Error('拒绝清理临时目录以外的路径');
await fsp
.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
.catch((error) => console.error('清理临时测试库失败:', error.message));
+2
View File
@@ -10,7 +10,9 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
JWT_SECRET: 'e2e-isolated-verification-secret-2026',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dir = path.join(root, '.cache', 'lifecycle-verification', `run-${process.pid}-${Date.now()}`);
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
JWT_SECRET: 'lifecycle-isolated-verification-secret-2026',
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
LOG_LEVEL: 'warn',
};
const require = createRequire(path.join(root, 'packages/server/package.json'));
const database = require('@libsql/client').createClient({ url: env.DATABASE_URL });
await database.execute('PRAGMA user_version=0');
database.close();
async function run(args) {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, { cwd: root, env, stdio: 'inherit', windowsHide: true });
child.once('error', reject);
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`隔离验收失败:${code}`))));
});
}
await run([
'packages/server/node_modules/prisma/build/index.js',
'migrate',
'deploy',
'--config',
'packages/server/prisma.config.ts',
]);
await run(['packages/server/node_modules/tsx/dist/cli.mjs', 'packages/server/scripts/verify-lifecycle.ts']);
console.log(`生命周期隔离数据库:${dir}`);
+2
View File
@@ -8,7 +8,9 @@ const dir = path.join(root, '.cache', 'v14-verification', `run-${process.pid}-${
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
NODE_ENV: 'test',
DATABASE_PROVIDER: 'sqlite',
JWT_SECRET: 'v14-isolated-verification-secret-2026',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',