feat: 完善学生生命周期管理与权限范围并发布 1.4.2
This commit is contained in:
1 parent
6347d1e5aa
commit
24e8dd902b
92 files changed
+6801
-2255
No files matched your search
+98
-42
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user