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
This commit is contained in:
118
scripts/add-ai-provider-visibility.js
Normal file
118
scripts/add-ai-provider-visibility.js
Normal file
@@ -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);
|
||||
});
|
||||
146
scripts/add-missing-columns.js
Normal file
146
scripts/add-missing-columns.js
Normal file
@@ -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);
|
||||
});
|
||||
15
scripts/clear-error-book.ts
Normal file
15
scripts/clear-error-book.ts
Normal file
@@ -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)
|
||||
})
|
||||
229
scripts/create-missing-tables.js
Normal file
229
scripts/create-missing-tables.js
Normal file
@@ -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);
|
||||
});
|
||||
181
scripts/diagnose-error-book.ts
Normal file
181
scripts/diagnose-error-book.ts
Normal file
@@ -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)
|
||||
})
|
||||
65
scripts/diagnose-tables.js
Normal file
65
scripts/diagnose-tables.js
Normal file
@@ -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);
|
||||
});
|
||||
413
scripts/seed-error-book.ts
Normal file
413
scripts/seed-error-book.ts
Normal file
@@ -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<number> {
|
||||
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<string, string[]>()
|
||||
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<number> {
|
||||
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<string, string[]>()
|
||||
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<void> {
|
||||
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<string, string[]>()
|
||||
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<number>`count(*)` })
|
||||
.from(errorBookItems)
|
||||
const [totalReviews] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(errorBookReviews)
|
||||
const [statusStats] = await db
|
||||
.select({
|
||||
newCount: sql<number>`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`,
|
||||
learningCount: sql<number>`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`,
|
||||
masteredCount: sql<number>`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)
|
||||
})
|
||||
151
scripts/test-failing-modules.py
Normal file
151
scripts/test-failing-modules.py
Normal file
@@ -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()
|
||||
395
scripts/test-teacher-pages.py
Normal file
395
scripts/test-teacher-pages.py
Normal file
@@ -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 = "<br>".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()
|
||||
Reference in New Issue
Block a user