From 9783be58c0b4b1e37a7cf4792e6faed641437570 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:01:54 +0800 Subject: [PATCH] feat(scripts): add diagnostic, seed, and test scripts - Add add-ai-provider-visibility and add-missing-columns migration scripts - Add clear-error-book, seed-error-book, diagnose-error-book scripts - Add diagnose-tables and create-missing-tables scripts - Add test-failing-modules and test-teacher-pages test scripts --- scripts/add-ai-provider-visibility.js | 118 ++++++++ scripts/add-missing-columns.js | 146 +++++++++ scripts/clear-error-book.ts | 15 + scripts/create-missing-tables.js | 229 ++++++++++++++ scripts/diagnose-error-book.ts | 181 +++++++++++ scripts/diagnose-tables.js | 65 ++++ scripts/seed-error-book.ts | 413 ++++++++++++++++++++++++++ scripts/test-failing-modules.py | 151 ++++++++++ scripts/test-teacher-pages.py | 395 ++++++++++++++++++++++++ 9 files changed, 1713 insertions(+) create mode 100644 scripts/add-ai-provider-visibility.js create mode 100644 scripts/add-missing-columns.js create mode 100644 scripts/clear-error-book.ts create mode 100644 scripts/create-missing-tables.js create mode 100644 scripts/diagnose-error-book.ts create mode 100644 scripts/diagnose-tables.js create mode 100644 scripts/seed-error-book.ts create mode 100644 scripts/test-failing-modules.py create mode 100644 scripts/test-teacher-pages.py diff --git a/scripts/add-ai-provider-visibility.js b/scripts/add-ai-provider-visibility.js new file mode 100644 index 0000000..f47e3ac --- /dev/null +++ b/scripts/add-ai-provider-visibility.js @@ -0,0 +1,118 @@ +/** + * V3: 为 ai_providers 表添加 visibility 列(public/private) + * + * 背景: + * - 管理员可发布 public provider,全员可用 + * - 普通用户(teacher/student 等)可创建 private provider,仅本人可见 + * + * 缺失列清单: + * - ai_providers.visibility (可见性:public | private,默认 private) + * - 索引 ai_provider_visibility_idx (visibility) + * - 索引 ai_provider_created_by_idx (created_by) + * + * 注意:默认值设为 'private',保证历史记录不会意外对全员公开。 + * 管理员可在 UI 中将需要共享的 provider 改为 public。 + */ +require("dotenv/config"); +const mysql = require("mysql2/promise"); + +const ALTER_OPERATIONS = [ + { + table: "ai_providers", + description: "添加 visibility 列(public/private 可见性)", + sql: [ + "ALTER TABLE `ai_providers` ADD COLUMN `visibility` ENUM('public','private') NOT NULL DEFAULT 'private' AFTER `is_default`", + "ALTER TABLE `ai_providers` ADD INDEX `ai_provider_visibility_idx` (`visibility`)", + "ALTER TABLE `ai_providers` ADD INDEX `ai_provider_created_by_idx` (`created_by`)", + ], + }, +]; + +async function columnExists(conn, table, column) { + const [rows] = await conn.execute( + `SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + [table, column], + ); + return rows.length > 0; +} + +async function indexExists(conn, table, indexName) { + const [rows] = await conn.execute( + `SELECT INDEX_NAME FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + LIMIT 1`, + [table, indexName], + ); + return rows.length > 0; +} + +async function main() { + const conn = await mysql.createConnection({ uri: process.env.DATABASE_URL }); + console.log("✅ 已连接数据库\n"); + + for (const op of ALTER_OPERATIONS) { + console.log(`${"=".repeat(60)}`); + console.log(`表: ${op.table} — ${op.description}`); + console.log("=".repeat(60)); + + for (const sql of op.sql) { + try { + const isColumn = sql.includes("ADD COLUMN"); + const isIndex = sql.includes("ADD INDEX"); + + if (isColumn) { + const match = sql.match(/ADD COLUMN `(\w+)`/); + if (match && await columnExists(conn, op.table, match[1])) { + console.log(` ⏭️ 跳过已存在的列: ${match[1]}`); + continue; + } + } else if (isIndex) { + const match = sql.match(/ADD INDEX `(\w+)`/); + if (match && await indexExists(conn, op.table, match[1])) { + console.log(` ⏭️ 跳过已存在的索引: ${match[1]}`); + continue; + } + } + + await conn.execute(sql); + console.log(` ✅ 执行成功: ${sql.slice(0, 100)}...`); + } catch (err) { + if (err.code === "ER_DUP_FIELDNAME" || err.code === "ER_DUP_KEYNAME" || err.errno === 1060 || err.errno === 1061) { + console.log(` ⏭️ 已存在,跳过: ${err.message}`); + } else { + console.error(` ❌ 执行失败: ${err.message}`); + console.error(` SQL: ${sql}`); + } + } + } + console.log(""); + } + + // 验证最终列结构 + console.log(`${"=".repeat(60)}`); + console.log("最终验证 — 检查 visibility 列是否已添加"); + console.log("=".repeat(60)); + + const checks = [ + ["ai_providers", "visibility"], + ]; + + let allOk = true; + for (const [table, col] of checks) { + const exists = await columnExists(conn, table, col); + const status = exists ? "✅" : "❌"; + if (!exists) allOk = false; + console.log(` ${status} ${table}.${col}`); + } + + console.log(allOk ? "\n✅ visibility 列已就绪" : "\n❌ 仍有缺失列,请检查错误"); + + await conn.end(); + process.exitCode = allOk ? 0 : 1; +} + +main().catch((err) => { + console.error("致命错误:", err); + process.exit(1); +}); diff --git a/scripts/add-missing-columns.js b/scripts/add-missing-columns.js new file mode 100644 index 0000000..8c287f1 --- /dev/null +++ b/scripts/add-missing-columns.js @@ -0,0 +1,146 @@ +/** + * 为已存在的表添加缺失的列(因 drizzle-kit push 阻塞未同步的 schema 变更) + * + * 缺失列清单: + * - learning_diagnostic_reports.class_id (v4-P1-4 班级报告关联) + * - announcements.is_pinned (V2-P2-13d 公告置顶) + * - messages.is_starred (V2-P2-13c 消息星标) + * - message_notifications.priority (V2-P2-13b 通知优先级) + * - message_notifications.is_archived (V2-P2-13b 通知归档) + */ +require("dotenv/config"); +const mysql = require("mysql2/promise"); + +// 每个 ALTER 操作:[表名, 描述, SQL语句数组] +const ALTER_OPERATIONS = [ + { + table: "learning_diagnostic_reports", + description: "添加 class_id 列(班级报告关联)", + sql: [ + "ALTER TABLE `learning_diagnostic_reports` ADD COLUMN `class_id` varchar(128) NULL AFTER `generated_by`", + "ALTER TABLE `learning_diagnostic_reports` ADD INDEX `diagnostic_class_idx` (`class_id`)", + "ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `diagnostic_class_fk` FOREIGN KEY (`class_id`) REFERENCES `classes` (`id`) ON DELETE SET NULL", + ], + }, + { + table: "announcements", + description: "添加 is_pinned 列(公告置顶)", + sql: [ + "ALTER TABLE `announcements` ADD COLUMN `is_pinned` boolean NOT NULL DEFAULT false AFTER `published_at`", + "ALTER TABLE `announcements` ADD INDEX `announcements_status_pinned_idx` (`status`, `is_pinned`)", + ], + }, + { + table: "messages", + description: "添加 is_starred 列(消息星标)", + sql: [ + "ALTER TABLE `messages` ADD COLUMN `is_starred` boolean NOT NULL DEFAULT false AFTER `receiver_deleted_at`", + "ALTER TABLE `messages` ADD INDEX `messages_receiver_starred_idx` (`receiver_id`, `is_starred`)", + ], + }, + { + table: "message_notifications", + description: "添加 priority 和 is_archived 列(通知优先级与归档)", + sql: [ + "ALTER TABLE `message_notifications` ADD COLUMN `priority` varchar(16) NOT NULL DEFAULT 'normal' AFTER `is_read`", + "ALTER TABLE `message_notifications` ADD COLUMN `is_archived` boolean NOT NULL DEFAULT false AFTER `priority`", + "ALTER TABLE `message_notifications` ADD INDEX `message_notifications_priority_idx` (`priority`)", + "ALTER TABLE `message_notifications` ADD INDEX `message_notifications_user_archived_idx` (`user_id`, `is_archived`)", + ], + }, +]; + +async function columnExists(conn, table, column) { + const [rows] = await conn.execute( + `SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + [table, column], + ); + return rows.length > 0; +} + +async function indexExists(conn, table, indexName) { + const [rows] = await conn.execute( + `SELECT INDEX_NAME FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + LIMIT 1`, + [table, indexName], + ); + return rows.length > 0; +} + +async function main() { + const conn = await mysql.createConnection({ uri: process.env.DATABASE_URL }); + console.log("✅ 已连接数据库\n"); + + for (const op of ALTER_OPERATIONS) { + console.log(`${"=".repeat(60)}`); + console.log(`表: ${op.table} — ${op.description}`); + console.log("=".repeat(60)); + + for (const sql of op.sql) { + try { + // 检查是否已存在(ADD COLUMN / ADD INDEX / ADD CONSTRAINT) + const isColumn = sql.includes("ADD COLUMN"); + const isIndex = sql.includes("ADD INDEX"); + const isConstraint = sql.includes("ADD CONSTRAINT"); + + if (isColumn) { + const match = sql.match(/ADD COLUMN `(\w+)`/); + if (match && await columnExists(conn, op.table, match[1])) { + console.log(` ⏭️ 跳过已存在的列: ${match[1]}`); + continue; + } + } else if (isIndex) { + const match = sql.match(/ADD INDEX `(\w+)`/); + if (match && await indexExists(conn, op.table, match[1])) { + console.log(` ⏭️ 跳过已存在的索引: ${match[1]}`); + continue; + } + } + + await conn.execute(sql); + console.log(` ✅ 执行成功: ${sql.slice(0, 100)}...`); + } catch (err) { + if (err.code === "ER_DUP_FIELDNAME" || err.code === "ER_DUP_KEYNAME" || err.errno === 1060 || err.errno === 1061) { + console.log(` ⏭️ 已存在,跳过: ${err.message}`); + } else { + console.error(` ❌ 执行失败: ${err.message}`); + console.error(` SQL: ${sql}`); + } + } + } + console.log(""); + } + + // 验证最终列结构 + console.log(`${"=".repeat(60)}`); + console.log("最终验证 — 检查缺失列是否已添加"); + console.log("=".repeat(60)); + + const checks = [ + ["learning_diagnostic_reports", "class_id"], + ["announcements", "is_pinned"], + ["messages", "is_starred"], + ["message_notifications", "priority"], + ["message_notifications", "is_archived"], + ]; + + let allOk = true; + for (const [table, col] of checks) { + const exists = await columnExists(conn, table, col); + const status = exists ? "✅" : "❌"; + if (!exists) allOk = false; + console.log(` ${status} ${table}.${col}`); + } + + console.log(allOk ? "\n✅ 所有缺失列已就绪" : "\n❌ 仍有缺失列,请检查错误"); + + await conn.end(); + process.exitCode = allOk ? 0 : 1; +} + +main().catch((err) => { + console.error("致命错误:", err); + process.exit(1); +}); diff --git a/scripts/clear-error-book.ts b/scripts/clear-error-book.ts new file mode 100644 index 0000000..25e2a6e --- /dev/null +++ b/scripts/clear-error-book.ts @@ -0,0 +1,15 @@ +import "dotenv/config" +import { db } from "../src/shared/db" +import { sql } from "drizzle-orm" + +async function clear() { + await db.execute(sql`DELETE FROM error_book_reviews`) + await db.execute(sql`DELETE FROM error_book_items`) + console.log("✓ 已清空 error_book_reviews 和 error_book_items 表") + process.exit(0) +} + +clear().catch((e) => { + console.error("❌ 清空失败:", e) + process.exit(1) +}) diff --git a/scripts/create-missing-tables.js b/scripts/create-missing-tables.js new file mode 100644 index 0000000..d7d5e85 --- /dev/null +++ b/scripts/create-missing-tables.js @@ -0,0 +1,229 @@ +/** + * 直接创建 4 个失败模块依赖的数据库表。 + * + * 背景:drizzle-kit push 因 exam_questions 表的 FK 约束冲突(ER_DROP_INDEX_FK) + * 无法执行,导致以下表始终未创建: + * - announcements / announcement_reads (公告模块) + * - message_notifications (消息模块) + * - learning_diagnostic_reports (学情诊断模块) + * - error_book_items / error_book_reviews (错题分析模块) + * + * 本脚本使用 CREATE TABLE IF NOT EXISTS 直接建表,绕过 drizzle-kit 的全量 diff。 + * 表结构严格对齐 src/shared/db/schema.ts 中的 Drizzle 定义。 + */ +require("dotenv/config"); +const mysql = require("mysql2/promise"); + +const TABLES_TO_CHECK = [ + "announcements", + "announcement_reads", + "message_notifications", + "learning_diagnostic_reports", + "error_book_items", + "error_book_reviews", +]; + +// 按依赖顺序排列:父表在前,子表在后 +const CREATE_STATEMENTS = [ + // --- 1. announcements --- + `CREATE TABLE IF NOT EXISTS \`announcements\` ( + \`id\` varchar(128) NOT NULL, + \`title\` varchar(255) NOT NULL, + \`content\` text NOT NULL, + \`type\` enum('school','grade','class') NOT NULL DEFAULT 'school', + \`status\` enum('draft','published','archived') NOT NULL DEFAULT 'draft', + \`target_grade_id\` varchar(128), + \`target_class_id\` varchar(128), + \`author_id\` varchar(128) NOT NULL, + \`published_at\` datetime, + \`is_pinned\` boolean NOT NULL DEFAULT false, + \`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + INDEX \`announcements_author_idx\` (\`author_id\`), + INDEX \`announcements_status_idx\` (\`status\`), + INDEX \`announcements_type_idx\` (\`type\`), + INDEX \`announcements_target_grade_idx\` (\`target_grade_id\`), + INDEX \`announcements_target_class_idx\` (\`target_class_id\`), + INDEX \`announcements_status_pinned_idx\` (\`status\`, \`is_pinned\`), + CONSTRAINT \`announcements_author_fk\` FOREIGN KEY (\`author_id\`) REFERENCES \`users\` (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + // --- 2. announcement_reads --- + `CREATE TABLE IF NOT EXISTS \`announcement_reads\` ( + \`id\` varchar(128) NOT NULL, + \`announcement_id\` varchar(128) NOT NULL, + \`user_id\` varchar(128) NOT NULL, + \`read_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`announcement_reads_unique_idx\` (\`announcement_id\`, \`user_id\`), + INDEX \`announcement_reads_announcement_idx\` (\`announcement_id\`), + INDEX \`announcement_reads_user_idx\` (\`user_id\`), + CONSTRAINT \`announcement_reads_announcement_fk\` FOREIGN KEY (\`announcement_id\`) REFERENCES \`announcements\` (\`id\`) ON DELETE CASCADE, + CONSTRAINT \`announcement_reads_user_fk\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + // --- 3. message_notifications --- + `CREATE TABLE IF NOT EXISTS \`message_notifications\` ( + \`id\` varchar(128) NOT NULL, + \`user_id\` varchar(128) NOT NULL, + \`type\` varchar(128) NOT NULL, + \`title\` varchar(255) NOT NULL, + \`content\` text, + \`link\` varchar(512), + \`is_read\` boolean NOT NULL DEFAULT false, + \`priority\` varchar(16) NOT NULL DEFAULT 'normal', + \`is_archived\` boolean NOT NULL DEFAULT false, + \`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + INDEX \`message_notifications_user_idx\` (\`user_id\`), + INDEX \`message_notifications_is_read_idx\` (\`is_read\`), + INDEX \`message_notifications_user_read_idx\` (\`user_id\`, \`is_read\`), + INDEX \`message_notifications_created_at_idx\` (\`created_at\`), + INDEX \`message_notifications_priority_idx\` (\`priority\`), + INDEX \`message_notifications_user_archived_idx\` (\`user_id\`, \`is_archived\`), + CONSTRAINT \`message_notifications_user_fk\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + // --- 4. learning_diagnostic_reports --- + `CREATE TABLE IF NOT EXISTS \`learning_diagnostic_reports\` ( + \`id\` varchar(128) NOT NULL, + \`student_id\` varchar(128), + \`generated_by\` varchar(128), + \`class_id\` varchar(128), + \`report_type\` enum('individual','class','grade') NOT NULL DEFAULT 'individual', + \`period\` varchar(50), + \`summary\` text, + \`strengths\` json, + \`weaknesses\` json, + \`recommendations\` json, + \`overall_score\` decimal(5,2), + \`status\` enum('draft','published','archived') NOT NULL DEFAULT 'draft', + \`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + INDEX \`diagnostic_student_idx\` (\`student_id\`), + INDEX \`diagnostic_generated_by_idx\` (\`generated_by\`), + INDEX \`diagnostic_status_idx\` (\`status\`), + INDEX \`diagnostic_report_type_idx\` (\`report_type\`), + INDEX \`diagnostic_class_idx\` (\`class_id\`), + CONSTRAINT \`diagnostic_student_fk\` FOREIGN KEY (\`student_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE, + CONSTRAINT \`diagnostic_generated_by_fk\` FOREIGN KEY (\`generated_by\`) REFERENCES \`users\` (\`id\`) ON DELETE SET NULL, + CONSTRAINT \`diagnostic_class_fk\` FOREIGN KEY (\`class_id\`) REFERENCES \`classes\` (\`id\`) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + // --- 5. error_book_items --- + `CREATE TABLE IF NOT EXISTS \`error_book_items\` ( + \`id\` varchar(128) NOT NULL, + \`student_id\` varchar(128) NOT NULL, + \`question_id\` varchar(128) NOT NULL, + \`source_type\` enum('exam','homework','manual') NOT NULL DEFAULT 'manual', + \`source_id\` varchar(128), + \`student_answer\` json, + \`correct_answer\` json, + \`subject_id\` varchar(128), + \`knowledge_point_ids\` json, + \`status\` enum('new','learning','mastered','archived') NOT NULL DEFAULT 'new', + \`mastery_level\` int NOT NULL DEFAULT 0, + \`next_review_at\` timestamp NULL, + \`review_interval\` int NOT NULL DEFAULT 1, + \`review_count\` int NOT NULL DEFAULT 0, + \`correct_streak\` int NOT NULL DEFAULT 0, + \`note\` text, + \`error_tags\` json, + \`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + INDEX \`eb_item_student_idx\` (\`student_id\`), + INDEX \`eb_item_student_status_idx\` (\`student_id\`, \`status\`), + INDEX \`eb_item_student_review_idx\` (\`student_id\`, \`next_review_at\`), + INDEX \`eb_item_question_idx\` (\`question_id\`), + INDEX \`eb_item_subject_idx\` (\`subject_id\`), + INDEX \`eb_item_source_idx\` (\`source_type\`, \`source_id\`), + CONSTRAINT \`eb_item_student_fk\` FOREIGN KEY (\`student_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE, + CONSTRAINT \`eb_item_question_fk\` FOREIGN KEY (\`question_id\`) REFERENCES \`questions\` (\`id\`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + // --- 6. error_book_reviews --- + `CREATE TABLE IF NOT EXISTS \`error_book_reviews\` ( + \`id\` varchar(128) NOT NULL, + \`item_id\` varchar(128) NOT NULL, + \`student_id\` varchar(128) NOT NULL, + \`result\` enum('again','hard','good','easy') NOT NULL, + \`reviewed_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + \`new_interval\` int, + \`new_mastery_level\` int, + \`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (\`id\`), + INDEX \`eb_review_item_idx\` (\`item_id\`), + INDEX \`eb_review_student_idx\` (\`student_id\`), + INDEX \`eb_review_student_reviewed_idx\` (\`student_id\`, \`reviewed_at\`), + CONSTRAINT \`eb_review_item_fk\` FOREIGN KEY (\`item_id\`) REFERENCES \`error_book_items\` (\`id\`) ON DELETE CASCADE, + CONSTRAINT \`eb_review_student_fk\` FOREIGN KEY (\`student_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, +]; + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("❌ DATABASE_URL 未设置"); + process.exit(1); + } + + const conn = await mysql.createConnection({ uri: url }); + console.log("✅ 已连接数据库"); + + // 1. 检查哪些表已存在 + const [rows] = await conn.execute( + `SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (${TABLES_TO_CHECK.map(() => "?").join(",")})`, + TABLES_TO_CHECK, + ); + const existing = new Set(rows.map((r) => r.TABLE_NAME)); + console.log(`📋 已存在的表: ${[...existing].join(", ") || "(无)"}`); + + // 2. 按顺序创建缺失的表 + for (const sql of CREATE_STATEMENTS) { + const match = sql.match(/CREATE TABLE IF NOT EXISTS `(\w+)`/); + const tableName = match ? match[1] : "(unknown)"; + if (existing.has(tableName)) { + console.log(`⏭️ 跳过已存在的表: ${tableName}`); + continue; + } + try { + await conn.execute(sql); + console.log(`✅ 创建表成功: ${tableName}`); + } catch (err) { + console.error(`❌ 创建表失败: ${tableName}`); + console.error(` 错误: ${err.message}`); + console.error(` SQL: ${sql.slice(0, 200)}...`); + } + } + + // 3. 最终验证 + const [finalRows] = await conn.execute( + `SELECT TABLE_NAME FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME IN (${TABLES_TO_CHECK.map(() => "?").join(",")})`, + TABLES_TO_CHECK, + ); + const finalExisting = new Set(finalRows.map((r) => r.TABLE_NAME)); + const missing = TABLES_TO_CHECK.filter((t) => !finalExisting.has(t)); + + console.log("\n📊 最终状态:"); + console.log(` 已存在: ${[...finalExisting].join(", ")}`); + if (missing.length > 0) { + console.log(` 仍缺失: ${missing.join(", ")}`); + process.exitCode = 1; + } else { + console.log(" ✅ 所有目标表均已就绪"); + } + + await conn.end(); +} + +main().catch((err) => { + console.error("致命错误:", err); + process.exit(1); +}); diff --git a/scripts/diagnose-error-book.ts b/scripts/diagnose-error-book.ts new file mode 100644 index 0000000..96dd6f7 --- /dev/null +++ b/scripts/diagnose-error-book.ts @@ -0,0 +1,181 @@ +/** + * 诊断脚本:检查错题本数据状态 + * 用法:npx tsx scripts/diagnose-error-book.ts + */ +import "dotenv/config" +import { db } from "../src/shared/db" +import { + errorBookItems, + errorBookReviews, + examSubmissions, + homeworkSubmissions, + users, + usersToRoles, + roles, + classes, + classEnrollments, + classSubjectTeachers, + grades, + exams, + homeworkAssignments, +} from "../src/shared/db/schema" +import { eq, inArray, count } from "drizzle-orm" + +async function diagnose() { + console.log("🔍 错题本数据诊断开始...\n") + + // 1. 检查表是否存在且有数据 + const [itemsCount] = await db.select({ value: count() }).from(errorBookItems) + const [reviewsCount] = await db.select({ value: count() }).from(errorBookReviews) + console.log(`📊 错题本表数据:`) + console.log(` error_book_items: ${itemsCount.value} 行`) + console.log(` error_book_reviews: ${reviewsCount.value} 行`) + + if (Number(itemsCount.value) === 0) { + console.log("\n❌ error_book_items 表为空!需要运行 seed-error-book.ts") + return + } + + // 2. 检查错题的 subjectId 分布 + const subjectDist = await db + .select({ + subjectId: errorBookItems.subjectId, + count: count(), + }) + .from(errorBookItems) + .groupBy(errorBookItems.subjectId) + console.log(`\n📊 错题 subjectId 分布:`) + for (const row of subjectDist) { + console.log(` subjectId=${row.subjectId ?? "NULL"}: ${row.count} 条`) + } + + // 3. 检查错题的 sourceType 分布 + const sourceDist = await db + .select({ + sourceType: errorBookItems.sourceType, + count: count(), + }) + .from(errorBookItems) + .groupBy(errorBookItems.sourceType) + console.log(`\n📊 错题 sourceType 分布:`) + for (const row of sourceDist) { + console.log(` ${row.sourceType}: ${row.count} 条`) + } + + // 4. 检查有错题的学生 + const studentsWithErrors = await db + .select({ + studentId: errorBookItems.studentId, + itemCount: count(), + }) + .from(errorBookItems) + .groupBy(errorBookItems.studentId) + console.log(`\n📊 有错题的学生 (${studentsWithErrors.length} 名):`) + for (const row of studentsWithErrors) { + console.log(` ${row.studentId}: ${row.itemCount} 条`) + } + + // 5. 检查教师角色用户 + const teacherRole = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, "teacher")).limit(1) + if (teacherRole.length > 0) { + const teachers = await db + .select({ userId: usersToRoles.userId }) + .from(usersToRoles) + .where(eq(usersToRoles.roleId, teacherRole[0].id)) + console.log(`\n📊 教师用户 (${teachers.length} 名):`) + for (const t of teachers) { + const user = await db.select({ name: users.name }).from(users).where(eq(users.id, t.userId)).limit(1) + // 查询该教师能访问的班级 + const homeroomClasses = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.teacherId, t.userId)) + const subjectClasses = await db + .select({ classId: classSubjectTeachers.classId }) + .from(classSubjectTeachers) + .where(eq(classSubjectTeachers.teacherId, t.userId)) + const allClassIds = [...new Set([...homeroomClasses.map((c) => c.id), ...subjectClasses.map((c) => c.classId)])] + + // 查询这些班级的学生 + let studentCount = 0 + let errorCount = 0 + if (allClassIds.length > 0) { + const students = await db + .select({ studentId: classEnrollments.studentId }) + .from(classEnrollments) + .where(inArray(classEnrollments.classId, allClassIds)) + const studentIds = [...new Set(students.map((s) => s.studentId))] + studentCount = studentIds.length + + if (studentIds.length > 0) { + const errorItems = await db + .select({ value: count() }) + .from(errorBookItems) + .where(inArray(errorBookItems.studentId, studentIds)) + errorCount = Number(errorItems[0]?.value ?? 0) + } + } + + console.log(` ${user[0]?.name ?? t.userId} (${t.userId}): 班级=${allClassIds.length}, 学生=${studentCount}, 错题=${errorCount}`) + } + } + + // 6. 检查年级组长 + const gradeHeadRole = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, "grade_head")).limit(1) + if (gradeHeadRole.length > 0) { + const gradeHeads = await db + .select({ userId: usersToRoles.userId }) + .from(usersToRoles) + .where(eq(usersToRoles.roleId, gradeHeadRole[0].id)) + console.log(`\n📊 年级组长 (${gradeHeads.length} 名):`) + for (const gh of gradeHeads) { + const user = await db.select({ name: users.name }).from(users).where(eq(users.id, gh.userId)).limit(1) + const managedGrades = await db.select({ id: grades.id, name: grades.name }).from(grades).where(eq(grades.gradeHeadId, gh.userId)) + console.log(` ${user[0]?.name ?? gh.userId} (${gh.userId}): 管理年级=${managedGrades.length}`) + for (const g of managedGrades) { + const gradeClasses = await db.select({ id: classes.id }).from(classes).where(eq(classes.gradeId, g.id)) + console.log(` 年级 ${g.name}: ${gradeClasses.length} 个班级`) + } + } + } + + // 7. 检查已批改的提交数量 + const gradedExams = await db.select({ value: count() }).from(examSubmissions).where(eq(examSubmissions.status, "graded")) + const gradedHw = await db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "graded")) + console.log(`\n📊 已批改提交:`) + console.log(` 考试提交: ${gradedExams[0]?.value ?? 0}`) + console.log(` 作业提交: ${gradedHw[0]?.value ?? 0}`) + + // 8. 检查 exams 表的 subjectId 是否有值 + const examSubjectStats = await db + .select({ + subjectId: exams.subjectId, + count: count(), + }) + .from(exams) + .groupBy(exams.subjectId) + console.log(`\n📊 exams 表 subjectId 分布:`) + for (const row of examSubjectStats) { + console.log(` subjectId=${row.subjectId ?? "NULL"}: ${row.count} 个考试`) + } + + // 9. 检查 homeworkAssignments 是否有 subjectId 字段(应该没有) + console.log(`\n📊 homeworkAssignments 表结构检查:`) + try { + const hwRows = await db.select().from(homeworkAssignments).limit(1) + if (hwRows.length > 0) { + const keys = Object.keys(hwRows[0]) + console.log(` 字段列表: ${keys.join(", ")}`) + console.log(` 是否有 subjectId 字段: ${keys.includes("subjectId") ? "是" : "否"}`) + } else { + console.log(` 表为空`) + } + } catch (e) { + console.log(` 查询失败: ${(e as Error).message}`) + } + + console.log("\n✅ 诊断完成") + process.exit(0) +} + +diagnose().catch((e) => { + console.error("❌ 诊断失败:", e) + process.exit(1) +}) diff --git a/scripts/diagnose-tables.js b/scripts/diagnose-tables.js new file mode 100644 index 0000000..42f5969 --- /dev/null +++ b/scripts/diagnose-tables.js @@ -0,0 +1,65 @@ +/** + * 诊断 4 个失败模块的数据库表结构和查询问题 + */ +require("dotenv/config"); +const mysql = require("mysql2/promise"); + +async function main() { + const conn = await mysql.createConnection({ uri: process.env.DATABASE_URL }); + + const tables = [ + "learning_diagnostic_reports", + "announcements", + "messages", + "message_notifications", + "error_book_items", + "error_book_reviews", + ]; + + for (const table of tables) { + console.log(`\n${"=".repeat(60)}`); + console.log(`表: ${table}`); + console.log("=".repeat(60)); + + // 检查表是否存在 + const [exists] = await conn.execute( + `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, + [table], + ); + + if (exists.length === 0) { + console.log(" ❌ 表不存在!"); + continue; + } + + console.log(" ✅ 表存在"); + + // 获取列信息 + const [cols] = await conn.execute( + `SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_TYPE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION`, + [table], + ); + console.log(" 列:"); + for (const c of cols) { + console.log(` - ${c.COLUMN_NAME}: ${c.COLUMN_TYPE} (NULL=${c.IS_NULLABLE}, DEFAULT=${c.COLUMN_DEFAULT})`); + } + + // 获取行数 + try { + const [count] = await conn.execute(`SELECT COUNT(*) as cnt FROM \`${table}\``); + console.log(` 行数: ${count[0].cnt}`); + } catch (err) { + console.log(` 行数查询失败: ${err.message}`); + } + } + + await conn.end(); +} + +main().catch((err) => { + console.error("致命错误:", err); + process.exit(1); +}); diff --git a/scripts/seed-error-book.ts b/scripts/seed-error-book.ts new file mode 100644 index 0000000..dd4aa10 --- /dev/null +++ b/scripts/seed-error-book.ts @@ -0,0 +1,413 @@ +/** + * 错题本种子数据脚本 + * + * 从现有的考试/作业提交中自动采集错题,并模拟部分复习记录。 + * 直接操作数据库,不依赖 data-access.ts(避免 server-only 包冲突)。 + * 用法:npx tsx scripts/seed-error-book.ts + */ + +import "dotenv/config" +import { db } from "../src/shared/db" +import { + examSubmissions, + homeworkSubmissions, + submissionAnswers, + homeworkAnswers, + examQuestions, + homeworkAssignmentQuestions, + errorBookItems, + errorBookReviews, + questionsToKnowledgePoints, + exams, + homeworkAssignments, +} from "../src/shared/db/schema" +import { eq, and, inArray, sql } from "drizzle-orm" +import { createId } from "@paralleldrive/cuid2" + +// SM-2 算法常量(与 sm2-algorithm.ts 保持一致) +const REVIEW_INTERVALS = { + again: { interval: 1, masteryDelta: -1, streakDelta: -999 }, + hard: { interval: 2, masteryDelta: 0, streakDelta: 0 }, + good: { interval: 4, masteryDelta: 1, streakDelta: 1 }, + easy: { interval: 7, masteryDelta: 2, streakDelta: 1 }, +} as const + +const INTERVAL_MULTIPLIERS = { + again: 1, + hard: 1.2, + good: 1.5, + easy: 2, +} as const + +type ReviewResult = keyof typeof REVIEW_INTERVALS + +function calculateNewInterval(currentInterval: number, result: ReviewResult, reviewCount: number): number { + const base = REVIEW_INTERVALS[result] + if (result === "again") return 1 + if (reviewCount === 0) return base.interval + const multiplier = INTERVAL_MULTIPLIERS[result] + return Math.max(base.interval, Math.round(currentInterval * multiplier)) +} + +function calculateNewMastery(currentMastery: number, result: ReviewResult, correctStreak: number): number { + const delta = REVIEW_INTERVALS[result].masteryDelta + const newMastery = Math.max(0, Math.min(5, currentMastery + delta)) + if (correctStreak >= 3) return Math.max(newMastery, 5) + return newMastery +} + +function deriveStatus(masteryLevel: number, correctStreak: number): "new" | "learning" | "mastered" { + if (masteryLevel >= 5 || correctStreak >= 3) return "mastered" + if (masteryLevel >= 1) return "learning" + return "new" +} + +function calculateNewCorrectStreak(currentStreak: number, result: ReviewResult): number { + const streakDelta = REVIEW_INTERVALS[result].streakDelta + if (streakDelta < 0) return 0 + return currentStreak + streakDelta +} + +async function collectFromExamSubmission(submissionId: string, studentId: string): Promise { + const submission = await db.query.examSubmissions.findFirst({ + where: and(eq(examSubmissions.id, submissionId), eq(examSubmissions.studentId, studentId)), + }) + if (!submission) return 0 + + // 查询考试以获取 subjectId + const exam = await db.query.exams.findFirst({ + where: eq(exams.id, submission.examId), + }) + const examSubjectId = exam?.subjectId ?? null + + const answers = await db + .select({ + questionId: submissionAnswers.questionId, + answerContent: submissionAnswers.answerContent, + score: submissionAnswers.score, + feedback: submissionAnswers.feedback, + }) + .from(submissionAnswers) + .where(eq(submissionAnswers.submissionId, submissionId)) + + const questionIds = answers.map((a) => a.questionId) + const examQuestionScores = await db + .select({ questionId: examQuestions.questionId, maxScore: examQuestions.score }) + .from(examQuestions) + .where(and(eq(examQuestions.examId, submission.examId), inArray(examQuestions.questionId, questionIds))) + + const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0])) + const wrongAnswers = answers.filter((a) => { + const max = maxScoreMap.get(a.questionId) ?? 0 + return (a.score ?? 0) < max + }) + + if (wrongAnswers.length === 0) return 0 + + // 去重 + const existing = await db + .select({ questionId: errorBookItems.questionId }) + .from(errorBookItems) + .where( + and( + eq(errorBookItems.studentId, studentId), + inArray(errorBookItems.questionId, wrongAnswers.map((a) => a.questionId)) + ) + ) + const existingSet = new Set(existing.map((e) => e.questionId)) + + // 查询知识点 + const kpRows = await db + .select({ + questionId: questionsToKnowledgePoints.questionId, + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, + }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.questionId, wrongAnswers.map((a) => a.questionId))) + const kpMap = new Map() + for (const kp of kpRows) { + const list = kpMap.get(kp.questionId) ?? [] + list.push(kp.knowledgePointId) + kpMap.set(kp.questionId, list) + } + + const now = new Date() + const toInsert = wrongAnswers + .filter((a) => !existingSet.has(a.questionId)) + .map((a) => ({ + id: createId(), + studentId, + questionId: a.questionId, + sourceType: "exam" as const, + sourceId: submissionId, + studentAnswer: a.answerContent, + correctAnswer: null, + subjectId: examSubjectId, // 从考试中获取学科 + knowledgePointIds: kpMap.get(a.questionId) ?? null, + status: "new" as const, + masteryLevel: 0, + nextReviewAt: now, + reviewInterval: 1, + reviewCount: 0, + correctStreak: 0, + note: a.feedback ?? null, + errorTags: null, + })) + + if (toInsert.length > 0) { + await db.insert(errorBookItems).values(toInsert) + } + + return toInsert.length +} + +async function collectFromHomeworkSubmission(submissionId: string, studentId: string): Promise { + const submission = await db.query.homeworkSubmissions.findFirst({ + where: and(eq(homeworkSubmissions.id, submissionId), eq(homeworkSubmissions.studentId, studentId)), + }) + if (!submission) return 0 + + // 查询作业以获取 subjectId。 + // homeworkAssignments 表本身没有 subjectId 字段, + // 若作业派生自试卷(sourceExamId 不为空),则从源试卷的 subjectId 获取。 + const assignment = await db.query.homeworkAssignments.findFirst({ + where: eq(homeworkAssignments.id, submission.assignmentId), + }) + let hwSubjectId: string | null = null + if (assignment?.sourceExamId) { + const sourceExam = await db.query.exams.findFirst({ + where: eq(exams.id, assignment.sourceExamId), + }) + hwSubjectId = sourceExam?.subjectId ?? null + } + + const answers = await db + .select({ + questionId: homeworkAnswers.questionId, + answerContent: homeworkAnswers.answerContent, + score: homeworkAnswers.score, + feedback: homeworkAnswers.feedback, + }) + .from(homeworkAnswers) + .where(eq(homeworkAnswers.submissionId, submissionId)) + + const questionIds = answers.map((a) => a.questionId) + const hwQuestionScores = await db + .select({ questionId: homeworkAssignmentQuestions.questionId, maxScore: homeworkAssignmentQuestions.score }) + .from(homeworkAssignmentQuestions) + .where( + and( + eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId), + inArray(homeworkAssignmentQuestions.questionId, questionIds) + ) + ) + + const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0])) + const wrongAnswers = answers.filter((a) => { + const max = maxScoreMap.get(a.questionId) ?? 0 + return (a.score ?? 0) < max + }) + + if (wrongAnswers.length === 0) return 0 + + const existing = await db + .select({ questionId: errorBookItems.questionId }) + .from(errorBookItems) + .where( + and( + eq(errorBookItems.studentId, studentId), + inArray(errorBookItems.questionId, wrongAnswers.map((a) => a.questionId)) + ) + ) + const existingSet = new Set(existing.map((e) => e.questionId)) + + const kpRows = await db + .select({ + questionId: questionsToKnowledgePoints.questionId, + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, + }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.questionId, wrongAnswers.map((a) => a.questionId))) + const kpMap = new Map() + for (const kp of kpRows) { + const list = kpMap.get(kp.questionId) ?? [] + list.push(kp.knowledgePointId) + kpMap.set(kp.questionId, list) + } + + const now = new Date() + const toInsert = wrongAnswers + .filter((a) => !existingSet.has(a.questionId)) + .map((a) => ({ + id: createId(), + studentId, + questionId: a.questionId, + sourceType: "homework" as const, + sourceId: submissionId, + studentAnswer: a.answerContent, + correctAnswer: null, + subjectId: hwSubjectId, // 从作业中获取学科 + knowledgePointIds: kpMap.get(a.questionId) ?? null, + status: "new" as const, + masteryLevel: 0, + nextReviewAt: now, + reviewInterval: 1, + reviewCount: 0, + correctStreak: 0, + note: a.feedback ?? null, + errorTags: null, + })) + + if (toInsert.length > 0) { + await db.insert(errorBookItems).values(toInsert) + } + + return toInsert.length +} + +async function recordReview(itemId: string, studentId: string, result: ReviewResult): Promise { + const item = await db.query.errorBookItems.findFirst({ + where: and(eq(errorBookItems.id, itemId), eq(errorBookItems.studentId, studentId)), + }) + if (!item) throw new Error("错题不存在") + + const newStreak = calculateNewCorrectStreak(item.correctStreak, result) + const newInterval = calculateNewInterval(item.reviewInterval, result, item.reviewCount) + const newMastery = calculateNewMastery(item.masteryLevel, result, newStreak) + const newStatus = deriveStatus(newMastery, newStreak) + const nextReviewAt = newStatus === "mastered" ? null : new Date(Date.now() + newInterval * 86400_000) + + await db.insert(errorBookReviews).values({ + id: createId(), + itemId, + studentId, + reviewResult: result, + reviewedAt: new Date(), + newInterval, + newMasteryLevel: newMastery, + }) + + await db + .update(errorBookItems) + .set({ + reviewInterval: newInterval, + reviewCount: item.reviewCount + 1, + correctStreak: newStreak, + masteryLevel: newMastery, + status: newStatus, + nextReviewAt, + updatedAt: new Date(), + }) + .where(eq(errorBookItems.id, itemId)) +} + +async function seedErrorBook() { + console.log("🌱 开始生成错题本种子数据...") + + // 1. 查询所有已批改的考试提交 + const gradedExamSubmissions = await db + .select({ + id: examSubmissions.id, + studentId: examSubmissions.studentId, + examId: examSubmissions.examId, + }) + .from(examSubmissions) + .where(eq(examSubmissions.status, "graded")) + + console.log(`📋 找到 ${gradedExamSubmissions.length} 份已批改考试提交`) + + // 2. 从考试提交中自动采集错题 + let examCollected = 0 + for (const sub of gradedExamSubmissions) { + try { + const count = await collectFromExamSubmission(sub.id, sub.studentId) + examCollected += count + } catch (e) { + console.warn(`⚠️ 考试提交 ${sub.id} 采集失败:`, (e as Error).message) + } + } + console.log(`✓ 从考试提交中采集 ${examCollected} 条错题`) + + // 3. 查询所有已批改的作业提交 + const gradedHomeworkSubmissions = await db + .select({ + id: homeworkSubmissions.id, + studentId: homeworkSubmissions.studentId, + }) + .from(homeworkSubmissions) + .where(eq(homeworkSubmissions.status, "graded")) + + console.log(`📋 找到 ${gradedHomeworkSubmissions.length} 份已批改作业提交`) + + // 4. 从作业提交中自动采集错题 + let homeworkCollected = 0 + for (const sub of gradedHomeworkSubmissions) { + try { + const count = await collectFromHomeworkSubmission(sub.id, sub.studentId) + homeworkCollected += count + } catch (e) { + console.warn(`⚠️ 作业提交 ${sub.id} 采集失败:`, (e as Error).message) + } + } + console.log(`✓ 从作业提交中采集 ${homeworkCollected} 条错题`) + + // 5. 模拟复习记录:为每个学生的前 3 条错题添加复习 + const allErrorItems = await db + .select({ + id: errorBookItems.id, + studentId: errorBookItems.studentId, + }) + .from(errorBookItems) + .where(eq(errorBookItems.status, "new")) + + const byStudent = new Map() + for (const item of allErrorItems) { + const list = byStudent.get(item.studentId) ?? [] + list.push(item.id) + byStudent.set(item.studentId, list) + } + + let reviewCount = 0 + const reviewResults: ReviewResult[] = ["again", "hard", "good"] + for (const [studentId, itemIds] of byStudent) { + // 为前 3 条错题添加复习记录 + for (let i = 0; i < Math.min(3, itemIds.length); i++) { + const itemId = itemIds[i] + const result = reviewResults[i % 3] + try { + await recordReview(itemId, studentId, result) + reviewCount++ + } catch (e) { + console.warn(`⚠️ 复习记录 ${itemId} 创建失败:`, (e as Error).message) + } + } + } + console.log(`✓ 创建 ${reviewCount} 条复习记录`) + + // 6. 统计结果 + const [totalItems] = await db + .select({ count: sql`count(*)` }) + .from(errorBookItems) + const [totalReviews] = await db + .select({ count: sql`count(*)` }) + .from(errorBookReviews) + const [statusStats] = await db + .select({ + newCount: sql`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`, + learningCount: sql`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`, + masteredCount: sql`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`, + }) + .from(errorBookItems) + + console.log("\n📊 错题本数据统计:") + console.log(` 总错题数: ${totalItems.count}`) + console.log(` 总复习记录: ${totalReviews.count}`) + console.log(` 状态分布: new=${statusStats.newCount}, learning=${statusStats.learningCount}, mastered=${statusStats.masteredCount}`) + console.log("\n✅ 错题本种子数据生成完成") + process.exit(0) +} + +seedErrorBook().catch((e) => { + console.error("❌ 错题本种子数据生成失败:", e) + process.exit(1) +}) diff --git a/scripts/test-failing-modules.py b/scripts/test-failing-modules.py new file mode 100644 index 0000000..417344c --- /dev/null +++ b/scripts/test-failing-modules.py @@ -0,0 +1,151 @@ +""" +检查学情诊断、错题分析、公告、消息 4 个模块的实际渲染状态 +截取截图 + 捕获控制台错误 + 检查页面内容 +""" +import os +import time +from playwright.sync_api import sync_playwright + +BASE_URL = "http://localhost:3000" +TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn" +TEACHER_PASSWORD = "123456" +SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "bugs", "screenshots") +os.makedirs(SCREENSHOT_DIR, exist_ok=True) + +ROUTES = [ + ("/teacher/diagnostic", "学情诊断"), + ("/teacher/error-book", "错题分析"), + ("/announcements", "公告"), + ("/messages", "消息"), +] + + +def main(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + + # Login + print(">>> 登录...") + page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded") + page.wait_for_timeout(3000) + page.locator('input[name="email"]').fill(TEACHER_EMAIL) + page.locator('input[type="password"]').first.fill(TEACHER_PASSWORD) + page.evaluate("""() => { + const form = document.querySelector('form'); + if (form) { + const event = new Event('submit', { cancelable: true, bubbles: true }); + form.dispatchEvent(event); + } + }""") + page.wait_for_timeout(5000) + print(f"登录后 URL: {page.url}") + + for route, name in ROUTES: + print(f"\n{'='*60}") + print(f">>> 测试: {name} ({route})") + print(f"{'='*60}") + + console_errors = [] + console_warnings = [] + + def on_console(msg): + if msg.type == "error": + console_errors.append(msg.text) + elif msg.type == "warning": + console_warnings.append(msg.text) + + def on_page_error(err): + console_errors.append(f"PageError: {str(err)[:300]}") + + page.on("console", on_console) + page.on("pageerror", on_page_error) + + try: + response = page.goto(f"{BASE_URL}{route}", timeout=30000, wait_until="domcontentloaded") + page.wait_for_timeout(3000) # Wait for client-side JS to execute + + print(f" HTTP Status: {response.status if response else 'N/A'}") + print(f" Final URL: {page.url}") + + # Check for error indicators on page + body_text = page.locator("body").text_content() or "" + print(f" Body text length: {len(body_text)}") + + # Check for common error patterns + error_elements = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600') + error_count = error_elements.count() + if error_count > 0: + print(f" Error elements on page: {error_count}") + for i in range(min(error_count, 5)): + try: + text = error_elements.nth(i).text_content() + if text and text.strip(): + print(f" [{i}] {text.strip()[:200]}") + except: + pass + + # Check for loading spinners still visible + spinners = page.locator('.animate-spin, [role="status"], .loading') + spinner_count = spinners.count() + if spinner_count > 0: + print(f" Loading spinners still visible: {spinner_count}") + + # Check for empty state + if "暂无" in body_text or "No data" in body_text or "没有" in body_text: + # Find the context + for keyword in ["暂无", "No data", "没有"]: + idx = body_text.find(keyword) + if idx >= 0: + context_text = body_text[max(0, idx-30):idx+80] + print(f" Empty state: ...{context_text}...") + break + + # Check for "加载失败" or "error" text + for keyword in ["加载失败", "load", "error", "错误", "失败", "Error"]: + if keyword.lower() in body_text.lower(): + idx = body_text.lower().find(keyword.lower()) + context_text = body_text[max(0, idx-30):idx+100] + print(f" Found '{keyword}': ...{context_text}...") + break + + # Take screenshot + screenshot_name = route.replace("/", "_").strip("_") + ".png" + screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name) + page.screenshot(path=screenshot_path, full_page=True) + print(f" Screenshot: {screenshot_path}") + + # Print console errors + if console_errors: + print(f"\n Console Errors ({len(console_errors)}):") + for err in console_errors[:10]: + print(f" - {err[:300]}") + else: + print(f" Console Errors: 0") + + if console_warnings: + print(f"\n Console Warnings ({len(console_warnings)}):") + for w in console_warnings[:5]: + print(f" - {w[:200]}") + + except Exception as e: + print(f" EXCEPTION: {type(e).__name__}: {str(e)[:300]}") + screenshot_name = route.replace("/", "_").strip("_") + "_error.png" + screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name) + try: + page.screenshot(path=screenshot_path, full_page=True) + except: + pass + finally: + page.remove_listener("console", on_console) + page.remove_listener("pageerror", on_page_error) + + browser.close() + print(f"\n{'='*60}") + print("测试完成!") + print(f"{'='*60}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test-teacher-pages.py b/scripts/test-teacher-pages.py new file mode 100644 index 0000000..403c1dd --- /dev/null +++ b/scripts/test-teacher-pages.py @@ -0,0 +1,395 @@ +""" +教师端全功能 Web 测试 (Post-Audit) +测试范围: 所有教师端页面路由 + 详情页发现 + 控制台错误捕获 +""" +import json +import os +import time +from playwright.sync_api import sync_playwright + +BASE_URL = "http://localhost:3000" +TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn" +TEACHER_PASSWORD = "123456" +SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "bugs", "screenshots") +os.makedirs(SCREENSHOT_DIR, exist_ok=True) + +# 教师端所有路由 +TEACHER_ROUTES = [ + {"category": "Dashboard", "routes": ["/teacher/dashboard"]}, + {"category": "Textbooks", "routes": ["/teacher/textbooks"]}, + {"category": "Questions", "routes": ["/teacher/questions"]}, + {"category": "Exams", "routes": ["/teacher/exams", "/teacher/exams/all", "/teacher/exams/create"]}, + {"category": "Homework", "routes": ["/teacher/homework", "/teacher/homework/assignments", "/teacher/homework/assignments/create", "/teacher/homework/submissions"]}, + {"category": "Grades", "routes": ["/teacher/grades", "/teacher/grades/entry", "/teacher/grades/stats", "/teacher/grades/analytics"]}, + {"category": "Classes", "routes": ["/teacher/classes", "/teacher/classes/my", "/teacher/classes/students", "/teacher/classes/schedule"]}, + {"category": "Course Plans", "routes": ["/teacher/course-plans"]}, + {"category": "Lesson Plans", "routes": ["/teacher/lesson-plans", "/teacher/lesson-plans/new"]}, + {"category": "Attendance", "routes": ["/teacher/attendance", "/teacher/attendance/sheet", "/teacher/attendance/stats"]}, + {"category": "Schedule Changes", "routes": ["/teacher/schedule-changes"]}, + {"category": "Diagnostic", "routes": ["/teacher/diagnostic"]}, + {"category": "Elective", "routes": ["/teacher/elective"]}, + {"category": "Error Book", "routes": ["/teacher/error-book"]}, +] + +# 详情页发现模式 +DETAIL_PATTERNS = [ + {"category": "Textbooks Detail", "listRoute": "/teacher/textbooks", "linkPattern": "/teacher/textbooks/"}, + {"category": "Classes Detail", "listRoute": "/teacher/classes/my", "linkPattern": "/teacher/classes/my/"}, + {"category": "Course Plans Detail", "listRoute": "/teacher/course-plans", "linkPattern": "/teacher/course-plans/"}, + {"category": "Lesson Plans Detail", "listRoute": "/teacher/lesson-plans", "linkPattern": "/teacher/lesson-plans/"}, + {"category": "Homework Detail", "listRoute": "/teacher/homework/assignments", "linkPattern": "/teacher/homework/assignments/"}, + {"category": "Exams Detail", "listRoute": "/teacher/exams/all", "linkPattern": "/teacher/exams/"}, +] + + +class TestResult: + def __init__(self, url, category): + self.url = url + self.category = category + self.status = "unknown" + self.http_status = None + self.final_url = url + self.errors = [] + self.warnings = [] + self.screenshot = None + + +all_results = [] + + +def add_result(result): + all_results.append(result) + + +def test_single_page(page, route, category): + result = TestResult(route, category) + console_errors = [] + + def on_console(msg): + if msg.type == "error": + console_errors.append(msg.text) + + page.on("console", on_console) + + # Also capture page errors (uncaught exceptions) + def on_page_error(err): + console_errors.append(f"PageError: {str(err)[:200]}") + + page.on("pageerror", on_page_error) + + try: + response = page.goto(f"{BASE_URL}{route}", timeout=30000, wait_until="domcontentloaded") + page.wait_for_timeout(2000) # Wait for JS to execute and render + + result.http_status = response.status if response else None + result.final_url = page.url + + http_status = result.http_status + final_url = result.final_url + + if http_status and http_status >= 500: + result.status = "failed" + result.errors.append(f"HTTP {http_status} error") + elif http_status and http_status >= 400: + result.status = "warning" + result.warnings.append(f"HTTP {http_status} error") + elif "/login" in final_url: + result.status = "failed" + result.errors.append("Redirected to login - auth issue") + elif final_url.rstrip("/").endswith("/error") or "/500" in final_url: + result.status = "failed" + result.errors.append("Redirected to error page") + else: + result.status = "passed" + + # Check for error elements on page + error_elements = page.locator('[role="alert"], .text-destructive, .text-red-500') + error_count = error_elements.count() + if error_count > 0: + for i in range(min(error_count, 3)): + try: + text = error_elements.nth(i).text_content() + if text and text.strip(): + result.warnings.append(f"Error text: {text.strip()[:100]}") + except: + pass + + # Check if page is empty + body_text = page.locator("body").text_content() or "" + if len(body_text.strip()) < 50: + result.warnings.append("Page appears empty") + + if console_errors: + result.errors.extend(console_errors[:5]) + + # Take screenshot for failed/warning pages + if result.status in ("failed", "warning"): + screenshot_name = route.replace("/", "_").strip("_") + ".png" + screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name) + try: + page.screenshot(path=screenshot_path, full_page=True) + result.screenshot = screenshot_path + except: + pass + + icon = "PASS" if result.status == "passed" else ("WARN" if result.status == "warning" else "FAIL") + print(f" [{icon}] {result.status} (HTTP {result.http_status}) - {route}") + + except Exception as e: + result.status = "failed" + err_msg = str(e)[:200] + result.errors.append(f"Exception: {err_msg}") + print(f" [FAIL] ERROR: {err_msg[:100]} - {route}") + + # Try screenshot even on failure + try: + screenshot_name = route.replace("/", "_").strip("_") + "_error.png" + screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name) + page.screenshot(path=screenshot_path, full_page=True) + result.screenshot = screenshot_path + except: + pass + finally: + page.remove_listener("console", on_console) + page.remove_listener("pageerror", on_page_error) + + return result + + +def discover_detail_links(page, list_route, link_pattern): + urls = [] + try: + page.goto(f"{BASE_URL}{list_route}", timeout=25000, wait_until="domcontentloaded") + page.wait_for_timeout(2000) + + links = page.locator(f'a[href*="{link_pattern}"]') + count = links.count() + seen = set() + for i in range(min(count, 10)): + href = links.nth(i).get_attribute("href") + if href and link_pattern in href and href not in seen: + # Avoid the list page itself + if href != list_route: + seen.add(href) + urls.append(href) + except Exception as e: + print(f" Warning: Failed to discover links on {list_route}: {e}") + + return urls[:3] # Max 3 detail pages per category + + +def generate_report(): + lines = [] + passed = sum(1 for r in all_results if r.status == "passed") + failed = sum(1 for r in all_results if r.status == "failed") + warnings = sum(1 for r in all_results if r.status == "warning") + total = len(all_results) + + lines.append("# 教师端 Web 功能测试报告 (Post-Audit)") + lines.append("") + lines.append(f"> 测试日期: {time.strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("> 测试范围: 所有教师端页面功能 (审计修复后)") + lines.append("> 测试工具: Playwright + Chromium (Python)") + lines.append(f"> 测试账号: {TEACHER_EMAIL}") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## 一、测试概览") + lines.append("") + lines.append("| 指标 | 数值 |") + lines.append("|------|------|") + lines.append(f"| 总测试页面数 | {total} |") + lines.append(f"| PASS | {passed} |") + lines.append(f"| FAIL | {failed} |") + lines.append(f"| WARN | {warnings} |") + pass_rate = f"{(passed / total * 100):.1f}%" if total > 0 else "N/A" + lines.append(f"| 通过率 | {pass_rate} |") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## 二、页面测试详情") + lines.append("") + + # Group by category + by_category = {} + for r in all_results: + if r.category not in by_category: + by_category[r.category] = [] + by_category[r.category].append(r) + + for category, results in by_category.items(): + lines.append(f"### {category}") + lines.append("") + lines.append("| 页面 | HTTP状态 | 结果 | 备注 |") + lines.append("|------|----------|------|------|") + + for r in results: + icon = "PASS" if r.status == "passed" else ("WARN" if r.status == "warning" else "FAIL") + notes = [] + if r.final_url != f"{BASE_URL}{r.url}" and r.final_url != r.url: + notes.append(f"重定向: {r.final_url}") + if r.errors: + notes.append(f"错误: {'; '.join(r.errors[:2])}") + if r.warnings: + notes.append(f"警告: {'; '.join(r.warnings[:2])}") + note_str = "
".join(notes) if notes else "-" + + lines.append(f"| {icon} `{r.url}` | {r.http_status or '-'} | {r.status} | {note_str} |") + + lines.append("") + + # Failed details + failed_results = [r for r in all_results if r.status == "failed"] + if failed_results: + lines.append("---") + lines.append("") + lines.append("## 三、失败页面详情") + lines.append("") + for r in failed_results: + lines.append(f"### FAIL `{r.url}`") + lines.append("") + lines.append(f"- **分类**: {r.category}") + lines.append(f"- **HTTP状态**: {r.http_status or '-'}") + if r.final_url != r.url: + lines.append(f"- **重定向**: {r.final_url}") + if r.errors: + lines.append("- **错误信息**:") + for e in r.errors: + lines.append(f" - {e}") + if r.screenshot: + lines.append(f"- **截图**: {r.screenshot}") + lines.append("") + + # Warning details + warning_results = [r for r in all_results if r.status == "warning"] + if warning_results: + lines.append("---") + lines.append("") + lines.append("## 四、警告页面") + lines.append("") + for r in warning_results: + lines.append(f"### WARN `{r.url}`") + lines.append("") + lines.append(f"- **分类**: {r.category}") + if r.warnings: + for w in r.warnings: + lines.append(f" - {w}") + if r.screenshot: + lines.append(f"- **截图**: {r.screenshot}") + lines.append("") + + lines.append("---") + lines.append("") + lines.append(f"*报告自动生成于 {time.strftime('%Y-%m-%d %H:%M:%S')}*") + + return "\n".join(lines) + + +def main(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1440, "height": 900}) + page = context.new_page() + + # ====== Step 1: Login ====== + print("\n>>> 登录教师账号...") + page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded") + page.wait_for_timeout(3000) # Wait for form to fully render + + # Fill login form + email_input = page.locator('input[name="email"]') + if email_input.count() == 0: + email_input = page.locator('input[type="email"]').first + + email_input.fill(TEACHER_EMAIL) + page.locator('input[type="password"], input[name="password"]').first.fill(TEACHER_PASSWORD) + + # Submit form via JavaScript (avoids Next.js dev overlay intercepting clicks) + page.evaluate("""() => { + const form = document.querySelector('form'); + if (form) { + const event = new Event('submit', { cancelable: true, bubbles: true }); + form.dispatchEvent(event); + } + }""") + + page.wait_for_timeout(5000) # Wait for redirect after login + + current_url = page.url + print(f"登录后 URL: {current_url}") + + if "/login" in current_url: + print("FAIL: 登录失败,仍在登录页!") + # Take screenshot of login failure + page.screenshot(path=os.path.join(SCREENSHOT_DIR, "login_failure.png"), full_page=True) + browser.close() + return + + print("PASS: 登录成功!") + + # ====== Step 2: Test all routes ====== + print("\n>>> 测试所有教师端路由...") + for group in TEACHER_ROUTES: + category = group["category"] + routes = group["routes"] + print(f"\n === {category} ===") + for route in routes: + print(f" 测试: {route}") + result = test_single_page(page, route, category) + add_result(result) + + # ====== Step 3: Discover and test detail pages ====== + print("\n\n>>> 发现详情页链接...") + for pattern in DETAIL_PATTERNS: + category = pattern["category"] + list_route = pattern["listRoute"] + link_pattern = pattern["linkPattern"] + print(f"\n 发现: {category} (from {list_route})") + detail_urls = discover_detail_links(page, list_route, link_pattern) + if detail_urls: + print(f" 找到 {len(detail_urls)} 个详情页") + for detail_url in detail_urls: + result = test_single_page(page, detail_url, category) + add_result(result) + else: + print(f" 未发现详情页链接") + + # ====== Step 4: Generate report ====== + report = generate_report() + bugs_dir = os.path.join(os.path.dirname(__file__), "..", "bugs") + os.makedirs(bugs_dir, exist_ok=True) + output_path = os.path.join(bugs_dir, "teacher_web_test_post_audit.md") + with open(output_path, "w", encoding="utf-8") as f: + f.write(report) + + # Also save JSON results + json_path = os.path.join(bugs_dir, "teacher_web_test_post_audit.json") + json_data = [] + for r in all_results: + json_data.append({ + "url": r.url, + "category": r.category, + "status": r.status, + "http_status": r.http_status, + "final_url": r.final_url, + "errors": r.errors, + "warnings": r.warnings, + }) + with open(json_path, "w", encoding="utf-8") as f: + json.dump(json_data, f, ensure_ascii=False, indent=2) + + passed = sum(1 for r in all_results if r.status == "passed") + failed = sum(1 for r in all_results if r.status == "failed") + warnings = sum(1 for r in all_results if r.status == "warning") + + print(f"\n{'=' * 60}") + print(f"测试完成: 总计 {len(all_results)}, PASS {passed}, FAIL {failed}, WARN {warnings}") + print(f"报告已写入: {output_path}") + print(f"JSON 结果: {json_path}") + print(f"{'=' * 60}") + + browser.close() + + +if __name__ == "__main__": + main()