feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档

- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
This commit is contained in:
LoveChengke committed 2026-10-18 11:53:37 +08:00
1 parent 536379036a
commit c7f215a09c
127 files changed
+4334 -8482

No files matched your search

+4 -4
View File
@@ -1,7 +1,7 @@
/**
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
*
* 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端/插件同级):
* 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端同级):
*
* 产出(Windows):
* releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
@@ -50,7 +50,7 @@ const PRODUCT = 'ClassHelper 服务端';
const PORT = '4000';
const LINUX_ARCH = 'x64';
// 产物根:releases/server/<版本>/(releases/ 不入库,见 AGENTS.md §4)
// 产物根:releases/server/<版本>/(releases/ 不入库,见 AGENTS.md 的现行工程约定)
const outRoot = path.join(root, 'releases', 'server', VERSION);
const INSTALLER_DIR = path.join(outRoot, '安装包');
const GENERATED_DIR = path.join(outRoot, '构建中间');
@@ -219,7 +219,7 @@ function copyRecursive(from, to) {
function buildRuntime() {
// 后端代码:保留 dist 层级,保证代码里 serverRoot 的计算(../..)与开发环境一致
// 注:dist 里没有 .d.ts / *.map —— packages/server/tsconfig.build.json 显式关掉了它们
//(那三样占了原 dist 的 82%,运行时一个都用不到,见 AGENTS.md §5 第 54 条)。
//(那三样占了原 dist 的 82%,运行时一个都用不到,见 AGENTS.md 的现行工程约定)。
copyRecursive(path.join(serverDir, 'dist'), path.join(staging, 'server', 'dist'));
// 迁移文件(首次启动 AUTO_MIGRATE 使用)
copyRecursive(
@@ -312,7 +312,7 @@ const sizeMb = (target) => Math.round(dirSize(target) / 1024 / 1024);
* 其余方言(postgresql / cockroachdb / sqlserver)的 wasm 一个字节都用不上。
*
* 原则:只删数据文件与文档,**不碰任何会被 require 的入口**;每一条都写明依据。
* 改这里之前先看「为什么安全」,改完务必按 AGENTS.md §8 的清单复验一次
* 改这里之前先看「为什么安全」,改完务必按 AGENTS.md 的现行工程约定 的清单复验一次
* (尤其是数据库管理模块的「生成客户端 / 一键切换」—— 它是唯一会调用 Prisma CLI 的地方)。
*/
function pruneRuntime() {
+41
View File
@@ -39,6 +39,7 @@ const MENU_ITEMS = [
{ label: '课表管理', path: '/schedules' },
{ label: '作业发布', path: '/homeworks' },
{ label: '通知发布', path: '/notifications' },
{ label: '课件', path: '/courseware' },
{ label: '成绩录入', path: '/grades' },
// 按 client:read 展示设备状态入口。
{ label: '客户端', path: '/clients' },
@@ -575,6 +576,46 @@ async function main() {
);
// 5. 上课时段发布紧急通知:全屏警告 + 3 秒倒计时 + 教师确认后才真正发布
await win.webContents.executeJavaScript(
`[...document.querySelectorAll('.el-menu-item')].find(e=>e.textContent.trim()==='课件')?.click()`,
);
await waitFor(win, `Boolean(document.querySelector('.courseware-page input[type=file]'))`);
const coursewareName = `UI课件验收-${Date.now()}.pptx`;
await win.webContents.executeJavaScript(`(() => {
const input=document.querySelector('.courseware-page input[type=file]'), transfer=new DataTransfer();
transfer.items.add(new File(['ClassHelper UI 课件验收'],${JSON.stringify(coursewareName)},{type:'application/octet-stream'}));
input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));
})()`);
await sleep(100);
await win.webContents.executeJavaScript(
`[...document.querySelectorAll('.courseware-page button')].find(b=>b.textContent.trim()==='上传云端')?.click()`,
);
await waitFor(
win,
`document.querySelector('.courseware-page .el-table__body')?.textContent.includes(${JSON.stringify(coursewareName)})`,
);
const coursewareUploaded = await win.webContents.executeJavaScript(
`(() => {const row=[...document.querySelectorAll('.courseware-page .el-table__row')].find(e=>e.textContent.includes(${JSON.stringify(coursewareName)}));return Boolean(row&&row.textContent.includes('仅自己')&&row.textContent.includes('演示文稿'));})()`,
);
record('课件真实上传并按类型归档,未选班级时仅自己可见', coursewareUploaded);
if (process.env.UI_SMOKE_SHOTS_DIR)
fs.writeFileSync(
path.join(process.env.UI_SMOKE_SHOTS_DIR, 'courseware-web.png'),
(await win.webContents.capturePage()).toPNG(),
);
await win.webContents.executeJavaScript(
`(() => {const row=[...document.querySelectorAll('.courseware-page .el-table__row')].find(e=>e.textContent.includes(${JSON.stringify(coursewareName)}));[...row.querySelectorAll('button')].find(b=>b.textContent.trim()==='删除')?.click();})()`,
);
await waitFor(win, `Boolean(document.querySelector('.el-message-box__btns .el-button--primary'))`);
await win.webContents.executeJavaScript(
`document.querySelector('.el-message-box__btns .el-button--primary')?.click()`,
);
await waitFor(
win,
`!document.querySelector('.courseware-page .el-table__body')?.textContent.includes(${JSON.stringify(coursewareName)})`,
);
record('课件可从原生页面删除并清理测试文件', true);
// "上课时段"由 run.mjs 的探针提前用 HTTP 建好(今天 00:00-23:59 的课表)
const urgentTitle = process.env.UI_SMOKE_URGENT_TITLE ?? 'UI 冒烟紧急通知';
const inClass = process.env.UI_SMOKE_INCLASS === '1';
+43
View File
@@ -0,0 +1,43 @@
/** 课件验收始终使用临时库和临时文件目录,不访问正在使用的班级。 */
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', 'v14-verification', `courseware-${process.pid}-${Date.now()}`);
fs.mkdirSync(dir, { recursive: true });
const env = {
...process.env,
DATABASE_PROVIDER: 'sqlite',
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
COURSEWARE_DIR: path.join(dir, 'files'),
TZ: 'Asia/Shanghai',
RATE_LIMIT_ENABLED: 'false',
LOG_LEVEL: 'warn',
};
const require = createRequire(path.join(root, 'packages/server/package.json'));
const { createClient } = require('@libsql/client');
const db = createClient({ url: env.DATABASE_URL });
await db.execute('PRAGMA user_version=0');
db.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([
'--import',
'./packages/server/node_modules/tsx/dist/loader.mjs',
'packages/server/scripts/verify-courseware.ts',
]);
console.log(`隔离验收留档:${dir}`);
+6 -1
View File
@@ -102,7 +102,12 @@ try {
namespace(fs.readFileSync(path.join(root, 'packages/server/scripts/verify-e2e.mjs'), 'utf8')),
{ flag: 'wx' },
);
await run([testFile], 'e2e.log');
if (process.argv.includes('--web')) {
env.UI_SMOKE_URL = env.VERIFY_BASE_URL;
env.UI_SMOKE_ADMIN = `${prefix}admin`;
env.UI_SMOKE_ADMIN_PASS = 'admin123';
await run(['scripts/ui-smoke/run.mjs'], 'web.log');
} else await run([testFile], 'e2e.log');
} finally {
if (server && server.exitCode === null) {
server.kill();
+4 -7
View File
@@ -5,13 +5,10 @@
* 资源路径、Electron 版本、`app.isPackaged` 都可能不同。交付前应实测 `packaged: true`。
*
* 用法:
* node scripts/verify-packaged.mjs
* node scripts/verify-packaged.mjs --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
* node scripts/verify-packaged.mjs --offline
* node scripts/verify-packaged.mjs --code G101 --password 123456
*
* 联网用例需要后端在 http://127.0.0.1:4000(默认自动探测;探测不到时退化为离线用例)。
* 退出码:0 = 两次启动都全绿且 `packaged: true`。
* node scripts/verify-packaged.mjs --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe" --offline
* 显式 --visual 才进行桌面视觉验收,显式 --online 才探测并启用联网。
* 联网前由调用方准备隔离服务、临时教师和班级,并显式传入测试凭据;本脚本不自动创建它们。
* 默认后台快速、离线,两次启动均成功且 packaged=true 才返回 0。
*/
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';