Initial English PK app
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export type Grade = "小班" | "中班" | "大班";
|
||||
|
||||
export type ClassType = {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AppSettings = {
|
||||
ttsVoice: string;
|
||||
ttsRate: string;
|
||||
scorePerCorrect: number;
|
||||
scorePerWrong: number;
|
||||
autoSpeak: boolean;
|
||||
wordDisplayMode: "uppercase" | "capitalize" | "input";
|
||||
pkWordCount: number;
|
||||
pkOptionCount: number;
|
||||
};
|
||||
|
||||
export type WordRecord = {
|
||||
id: number;
|
||||
grade: Grade;
|
||||
classTypeId: number;
|
||||
classTypeName: string;
|
||||
english: string;
|
||||
chinese: string;
|
||||
image: string;
|
||||
audio: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type WordInput = {
|
||||
grade: Grade;
|
||||
classTypeId: number;
|
||||
english: string;
|
||||
chinese: string;
|
||||
image: string;
|
||||
audio?: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type DbWordRow = {
|
||||
id: number;
|
||||
grade: Grade;
|
||||
class_type_id: number;
|
||||
class_type_name: string;
|
||||
english: string;
|
||||
chinese: string | null;
|
||||
image: string | null;
|
||||
audio: string | null;
|
||||
enabled: 0 | 1;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type DbClassTypeRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: 0 | 1;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const dbDirectory = path.join(process.cwd(), "data");
|
||||
const dbPath = path.join(dbDirectory, "english-pk.sqlite");
|
||||
|
||||
let db: DatabaseSync | null = null;
|
||||
|
||||
const defaultClassTypes = ["基础班", "提高班", "复习班"];
|
||||
const defaultWords = [
|
||||
["中班", "基础班", "box", "盒子", "📦"],
|
||||
["中班", "基础班", "fox", "狐狸", "🦊"],
|
||||
["中班", "基础班", "ox", "公牛", "🐂"],
|
||||
["中班", "基础班", "ax", "斧头", "🪓"],
|
||||
["中班", "提高班", "yarn", "毛线", "🧶"],
|
||||
["中班", "提高班", "yo-yo", "悠悠球", "🪀"],
|
||||
["中班", "提高班", "yam", "山药", "🍠"],
|
||||
["中班", "提高班", "yellow", "黄色", "💛"],
|
||||
["中班", "复习班", "zoo", "动物园", "🏞️"],
|
||||
["中班", "复习班", "zero", "零", "0️⃣"],
|
||||
["中班", "复习班", "zebra", "斑马", "🦓"],
|
||||
["中班", "复习班", "zipper", "拉链", "🧥"],
|
||||
] satisfies Array<[Grade, string, string, string, string]>;
|
||||
|
||||
function mapClassType(row: DbClassTypeRow): ClassType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
enabled: Boolean(row.enabled),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWord(row: DbWordRow): WordRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
grade: row.grade,
|
||||
classTypeId: row.class_type_id,
|
||||
classTypeName: row.class_type_name,
|
||||
english: row.english,
|
||||
chinese: row.chinese ?? "",
|
||||
image: row.image ?? "",
|
||||
audio: row.audio ?? "",
|
||||
enabled: Boolean(row.enabled),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function getDatabase() {
|
||||
if (db) return db;
|
||||
|
||||
if (!existsSync(dbDirectory)) {
|
||||
mkdirSync(dbDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
db = new DatabaseSync(dbPath);
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
initializeDatabase(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function initializeDatabase(database: DatabaseSync) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS words (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
grade TEXT NOT NULL,
|
||||
class_type_id INTEGER NOT NULL,
|
||||
english TEXT NOT NULL,
|
||||
chinese TEXT NOT NULL DEFAULT '',
|
||||
image TEXT NOT NULL DEFAULT '',
|
||||
audio TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (class_type_id) REFERENCES class_types(id) ON DELETE RESTRICT,
|
||||
UNIQUE (grade, class_type_id, english)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
const wordColumns = database.prepare("PRAGMA table_info(words)").all() as Array<{ name: string }>;
|
||||
if (!wordColumns.some((column) => column.name === "image")) {
|
||||
database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
if (!wordColumns.some((column) => column.name === "chinese")) {
|
||||
database.exec("ALTER TABLE words ADD COLUMN chinese TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
if (!wordColumns.some((column) => column.name === "audio")) {
|
||||
database.exec("ALTER TABLE words ADD COLUMN audio TEXT NOT NULL DEFAULT '';");
|
||||
}
|
||||
|
||||
const classTypeCount = database.prepare("SELECT COUNT(*) AS count FROM class_types").get() as { count: number };
|
||||
if (classTypeCount.count === 0) {
|
||||
const insertClassType = database.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)");
|
||||
for (const name of defaultClassTypes) {
|
||||
insertClassType.run(name);
|
||||
}
|
||||
}
|
||||
|
||||
const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number };
|
||||
if (wordCount.count === 0) {
|
||||
const findClassType = database.prepare("SELECT id FROM class_types WHERE name = ?");
|
||||
const insertWord = database.prepare(
|
||||
"INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)",
|
||||
);
|
||||
|
||||
for (const [grade, classTypeName, english, chinese, image] of defaultWords) {
|
||||
const classType = findClassType.get(classTypeName) as { id: number } | undefined;
|
||||
if (classType) {
|
||||
insertWord.run(grade, classType.id, english, chinese, image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultIconUpdates = database.prepare(
|
||||
"UPDATE words SET image = ? WHERE english = ? AND (image IS NULL OR image = '')",
|
||||
);
|
||||
for (const [, , english, , image] of defaultWords) {
|
||||
defaultIconUpdates.run(image, english);
|
||||
}
|
||||
const defaultChineseUpdates = database.prepare(
|
||||
"UPDATE words SET chinese = ? WHERE english = ? AND (chinese IS NULL OR chinese = '')",
|
||||
);
|
||||
for (const [, , english, chinese] of defaultWords) {
|
||||
defaultChineseUpdates.run(chinese, english);
|
||||
}
|
||||
|
||||
const defaultSettings: AppSettings = {
|
||||
ttsVoice: "en-US-EmmaMultilingualNeural",
|
||||
ttsRate: "-10%",
|
||||
scorePerCorrect: 10,
|
||||
scorePerWrong: 0,
|
||||
autoSpeak: true,
|
||||
wordDisplayMode: "uppercase",
|
||||
pkWordCount: 0,
|
||||
pkOptionCount: 4,
|
||||
};
|
||||
const insertSetting = database.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
|
||||
for (const [key, value] of Object.entries(defaultSettings)) {
|
||||
insertSetting.run(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
export function listClassTypes(includeDisabled = true) {
|
||||
const database = getDatabase();
|
||||
const sql = includeDisabled
|
||||
? "SELECT * FROM class_types ORDER BY id ASC"
|
||||
: "SELECT * FROM class_types WHERE enabled = 1 ORDER BY id ASC";
|
||||
return (database.prepare(sql).all() as DbClassTypeRow[]).map(mapClassType);
|
||||
}
|
||||
|
||||
export function createClassType(name: string) {
|
||||
const database = getDatabase();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new Error("班级类型不能为空");
|
||||
}
|
||||
|
||||
const result = database
|
||||
.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)")
|
||||
.run(trimmedName);
|
||||
return getClassType(Number(result.lastInsertRowid));
|
||||
}
|
||||
|
||||
export function updateClassType(id: number, input: { name: string; enabled: boolean }) {
|
||||
const database = getDatabase();
|
||||
const trimmedName = input.name.trim();
|
||||
if (!trimmedName) {
|
||||
throw new Error("班级类型不能为空");
|
||||
}
|
||||
|
||||
database
|
||||
.prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.run(trimmedName, input.enabled ? 1 : 0, id);
|
||||
return getClassType(id);
|
||||
}
|
||||
|
||||
export function deleteClassType(id: number) {
|
||||
const database = getDatabase();
|
||||
database.prepare("DELETE FROM class_types WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
function getClassType(id: number) {
|
||||
const row = getDatabase().prepare("SELECT * FROM class_types WHERE id = ?").get(id) as
|
||||
| DbClassTypeRow
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return mapClassType(row);
|
||||
}
|
||||
|
||||
export function listWords(filters?: { grade?: string; classTypeId?: number; enabledOnly?: boolean }) {
|
||||
const database = getDatabase();
|
||||
const clauses: string[] = [];
|
||||
const values: Array<string | number> = [];
|
||||
|
||||
if (filters?.grade) {
|
||||
clauses.push("words.grade = ?");
|
||||
values.push(filters.grade);
|
||||
}
|
||||
|
||||
if (filters?.classTypeId) {
|
||||
clauses.push("words.class_type_id = ?");
|
||||
values.push(filters.classTypeId);
|
||||
}
|
||||
|
||||
if (filters?.enabledOnly) {
|
||||
clauses.push("words.enabled = 1");
|
||||
clauses.push("class_types.enabled = 1");
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
||||
const rows = database
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
words.id,
|
||||
words.grade,
|
||||
words.class_type_id,
|
||||
class_types.name AS class_type_name,
|
||||
words.english,
|
||||
words.chinese,
|
||||
words.image,
|
||||
words.audio,
|
||||
words.enabled,
|
||||
words.created_at,
|
||||
words.updated_at
|
||||
FROM words
|
||||
JOIN class_types ON class_types.id = words.class_type_id
|
||||
${where}
|
||||
ORDER BY words.id ASC
|
||||
`,
|
||||
)
|
||||
.all(...values) as DbWordRow[];
|
||||
|
||||
return rows.map(mapWord);
|
||||
}
|
||||
|
||||
export function findWord(id: number) {
|
||||
return getWord(id);
|
||||
}
|
||||
|
||||
export function createWord(input: WordInput) {
|
||||
const database = getDatabase();
|
||||
validateWordInput(input);
|
||||
const result = database
|
||||
.prepare("INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(
|
||||
input.grade,
|
||||
input.classTypeId,
|
||||
input.english.trim(),
|
||||
input.chinese.trim(),
|
||||
input.image.trim(),
|
||||
input.audio?.trim() ?? "",
|
||||
input.enabled ? 1 : 0,
|
||||
);
|
||||
return getWord(Number(result.lastInsertRowid));
|
||||
}
|
||||
|
||||
export function updateWord(id: number, input: WordInput) {
|
||||
const database = getDatabase();
|
||||
validateWordInput(input);
|
||||
const existing = getWord(id);
|
||||
const shouldClearAudio =
|
||||
existing &&
|
||||
(existing.grade !== input.grade ||
|
||||
existing.classTypeId !== input.classTypeId ||
|
||||
existing.english !== input.english.trim());
|
||||
|
||||
database
|
||||
.prepare(
|
||||
"UPDATE words SET grade = ?, class_type_id = ?, english = ?, chinese = ?, image = ?, audio = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
)
|
||||
.run(
|
||||
input.grade,
|
||||
input.classTypeId,
|
||||
input.english.trim(),
|
||||
input.chinese.trim(),
|
||||
input.image.trim(),
|
||||
shouldClearAudio ? "" : input.audio?.trim() ?? existing?.audio ?? "",
|
||||
input.enabled ? 1 : 0,
|
||||
id,
|
||||
);
|
||||
return getWord(id);
|
||||
}
|
||||
|
||||
export function deleteWord(id: number) {
|
||||
const database = getDatabase();
|
||||
database.prepare("DELETE FROM words WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function updateWordAudio(id: number, audio: string) {
|
||||
const database = getDatabase();
|
||||
database
|
||||
.prepare("UPDATE words SET audio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.run(audio.trim(), id);
|
||||
return getWord(id);
|
||||
}
|
||||
|
||||
function getWord(id: number) {
|
||||
return listWords().find((word) => word.id === id) ?? null;
|
||||
}
|
||||
|
||||
function validateWordInput(input: WordInput) {
|
||||
if (!["小班", "中班", "大班"].includes(input.grade)) {
|
||||
throw new Error("年级不正确");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
|
||||
throw new Error("班级类型不正确");
|
||||
}
|
||||
|
||||
if (!input.english.trim()) {
|
||||
throw new Error("题库英语不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
export function getSettings(): AppSettings {
|
||||
const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}>;
|
||||
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
|
||||
return {
|
||||
ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural",
|
||||
ttsRate: settings.ttsRate || "-10%",
|
||||
scorePerCorrect: Number(settings.scorePerCorrect || 10),
|
||||
scorePerWrong: Number(settings.scorePerWrong || 0),
|
||||
autoSpeak: settings.autoSpeak !== "false",
|
||||
wordDisplayMode: parseWordDisplayMode(settings.wordDisplayMode),
|
||||
pkWordCount: Math.max(0, Number(settings.pkWordCount || 0)),
|
||||
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount || 4)),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSettings(input: AppSettings) {
|
||||
const database = getDatabase();
|
||||
const statement = database.prepare(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||
);
|
||||
statement.run("ttsVoice", input.ttsVoice.trim() || "en-US-EmmaMultilingualNeural");
|
||||
statement.run("ttsRate", input.ttsRate.trim() || "-10%");
|
||||
statement.run("scorePerCorrect", String(Math.max(1, Number(input.scorePerCorrect) || 10)));
|
||||
statement.run("scorePerWrong", String(Math.max(0, Number(input.scorePerWrong) || 0)));
|
||||
statement.run("autoSpeak", String(Boolean(input.autoSpeak)));
|
||||
statement.run("wordDisplayMode", parseWordDisplayMode(input.wordDisplayMode));
|
||||
statement.run("pkWordCount", String(Math.max(0, Math.floor(Number(input.pkWordCount) || 0))));
|
||||
statement.run("pkOptionCount", String(normalizeOptionCount(Number(input.pkOptionCount) || 4)));
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
function parseWordDisplayMode(value: unknown): AppSettings["wordDisplayMode"] {
|
||||
return value === "capitalize" || value === "input" || value === "uppercase" ? value : "uppercase";
|
||||
}
|
||||
|
||||
function normalizeOptionCount(value: number) {
|
||||
const count = Math.floor(value);
|
||||
if (!Number.isFinite(count)) return 4;
|
||||
if (count < 2) return 2;
|
||||
if (count > 8) return 8;
|
||||
return count % 2 === 0 ? count : count + 1;
|
||||
}
|
||||
Reference in New Issue
Block a user