feat(imports): 成绩/名单表格导入 + ClassIsland 时间配置导入 + 安装版增量迁移

服务端:
- 新增 imports 模块: 模板下载(csv/xlsx)、表格解析预览、字段映射导入(append/upsert)、
  错误行号与结果统计; ClassIsland 时间配置容错解析 + replace/merge 导入 + 失败回滚(400 不写库)
- TimeLayout 模型 + 迁移; 修复 contentBase64 空值被 422 拦截, 改由 service 返回 IMPORT_EMPTY_FILE
- merge 语义改为按开始时间槽位覆盖, 同开始时间的旧节次不再被静默丢弃
- db-bootstrap 增加 _ch_migrations 账本: 覆盖安装升级也能自动补跑新增迁移(旧库实测通过)

Web 管理端:
- TableImportDialog(成绩/名单): 模板下载、预览、字段映射、重复处理模式、结果统计与错误行
- TimeLayoutImportDialog: 粘贴/选 .json、解析预览、覆盖/合并、已导入列表与删除
- 成绩录入/学生管理/课表管理接入入口(按权限矩阵显示)

验证: verify:e2e 107/107(新增导入 22 项) verify:web 18/18(新增时间配置导入弹窗真实点击)
      verify:desktop 40/40
文档: README 新增导入章节、增量迁移说明、API 表与验收对照更新
This commit is contained in:
LoveChengke committed 2026-09-12 15:35:01 +08:00
1 parent 0dcab6f369
commit 8b85079d81
23 files changed
+2735 -108

No files matched your search

+136 -15
View File
@@ -9,8 +9,9 @@ import { hashPassword } from './password.js';
/**
* 首次启动时的数据库初始化(SQLite/libSQL)。
*
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。
* 场景:安装程序首次运行、覆盖安装升级、Docker 挂载了空数据卷、用户手动删除了数据库文件。
* 做法:以 `_ch_migrations` 账本表记录已执行的迁移;启动时按时间戳顺序补跑未记录的迁移。
* 全新库会跑全部迁移,旧库只补跑新增迁移(已存在的表/索引自动跳过)。
*
* 说明:
* - 只处理 SQLite(本项目打包安装的默认形态);
@@ -58,6 +59,103 @@ async function schemaExists(): Promise<boolean> {
}
}
/**
* 迁移账本表名。
*
* 老版本安装(1.0 之前)只区分「空库 / 非空库」,因此升级安装时不会补建新表。
* 现在用一张账本表记录已执行过的迁移目录名,启动时只补跑未记录的迁移,
* 这样「覆盖安装升级」也能自动获得新表/新索引。
*/
const MIGRATION_LEDGER = '_ch_migrations';
/** 这些错误表示对象已存在,属于「旧版本已经建过」的正常情况,可安全忽略 */
const IGNORABLE_SQL_ERROR = /already exists|duplicate column name|duplicate index/i;
/** 拆分 migration.sql 为独立语句(去掉注释行,过滤空语句) */
function splitStatements(sql: string): string[] {
return sql
.split(/\r?\n/)
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
/** 确保账本表存在,返回已记录的迁移名 */
async function loadAppliedMigrations(client: {
execute: (sql: string) => Promise<unknown>;
}): Promise<Set<string>> {
await client.execute(
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
);
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
`SELECT name FROM "${MIGRATION_LEDGER}"`,
);
return new Set(rows.map((row) => row.name));
}
interface MigrationOutcome {
file: string;
applied: number;
skipped: number;
}
/** 执行单个迁移文件:逐条语句执行,已存在的对象跳过,其余错误上抛 */
async function applyMigration(
client: { execute: (sql: string) => Promise<unknown> },
file: string,
): Promise<MigrationOutcome> {
const sql = fs.readFileSync(file, 'utf8');
const outcome: MigrationOutcome = { file, applied: 0, skipped: 0 };
for (const statement of splitStatements(sql)) {
try {
await client.execute(statement);
outcome.applied += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (IGNORABLE_SQL_ERROR.test(message)) {
outcome.skipped += 1;
continue;
}
throw new Error(
`迁移 ${path.basename(path.dirname(file))} 执行失败:${message}\nSQL: ${statement.slice(0, 200)}`,
);
}
}
await client.execute({
// libsql 的 execute 支持参数化对象形式
sql: `INSERT OR REPLACE INTO "${MIGRATION_LEDGER}" ("name", "appliedAt") VALUES (?, CURRENT_TIMESTAMP)`,
args: [path.basename(path.dirname(file))],
} as never);
return outcome;
}
/** 计算待执行的迁移文件 */
export async function listPendingMigrations(): Promise<string[]> {
const files = listMigrationFiles();
if (files.length === 0) return [];
const client = createClient({ url: env.databaseUrl });
try {
const applied = await loadAppliedMigrations(
client as unknown as {
execute: (sql: string) => Promise<unknown>;
},
);
return files.filter((file) => !applied.has(path.basename(path.dirname(file))));
} catch (error) {
logger.warn('读取迁移账本失败,按全部待执行处理', error);
return files;
} finally {
client.close();
}
}
export interface BootstrapResult {
applied: number;
skipped: boolean;
@@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username
}
/**
* 确保数据库结构存在。
* 确保数据库结构存在(增量迁移)。
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
*
* - 全新数据库:依次执行全部迁移;
* - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过);
* - 已是最新:直接返回,不做任何写操作。
*/
export async function ensureSchema(): Promise<BootstrapResult> {
if (env.databaseProvider !== 'sqlite') {
@@ -107,27 +209,46 @@ export async function ensureSchema(): Promise<BootstrapResult> {
};
}
if (await schemaExists()) {
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
}
const files = listMigrationFiles();
if (files.length === 0) {
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
}
const freshInstall = !(await schemaExists());
const client = createClient({ url: env.databaseUrl });
let applied = 0;
try {
for (const file of files) {
const sql = fs.readFileSync(file, 'utf8');
await client.executeMultiple(sql);
applied += 1;
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`);
const applied = await loadAppliedMigrations(
client as unknown as {
execute: (sql: string) => Promise<unknown>;
},
);
const pending = files.filter((file) => !applied.has(path.basename(path.dirname(file))));
if (pending.length === 0) {
return { applied: 0, skipped: true, detail: '数据库结构已是最新,无需迁移' };
}
let reused = 0;
for (const file of pending) {
const outcome = await applyMigration(
client as unknown as { execute: (sql: string) => Promise<unknown> },
file,
);
reused += outcome.skipped;
logger.info(
`已应用迁移:${path.basename(path.dirname(file))}` +
(outcome.skipped > 0 ? `(跳过 ${outcome.skipped} 个已存在对象)` : ''),
);
}
const mode = freshInstall ? '全新安装' : '升级安装';
return {
applied: pending.length,
skipped: false,
detail: `${mode}:已应用 ${pending.length} 个迁移文件${reused > 0 ? `,跳过 ${reused} 个已存在对象` : ''}`,
};
} finally {
client.close();
}
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
}