feat(grades): gpa formula system - linear per-band gp (start~top), course category weights (editable), formula display, seed aligned to school standard
This commit is contained in:
+54
-26
@@ -317,12 +317,12 @@ function ensureBuiltin(db) {
|
||||
['维度:兴趣生活', '兴趣生活', '#db2777', 40]
|
||||
]);
|
||||
|
||||
seed('grade_band', ['label', 'color', 'min', 'max', 'sort', 'uuid', 'hlc', 'origin'], [
|
||||
['梯度:优秀', '优秀', '#16803c', 90, 100, 10],
|
||||
['梯度:良好', '良好', '#0891b2', 80, 90, 20],
|
||||
['梯度:中等', '中等', '#d97706', 70, 80, 30],
|
||||
['梯度:及格', '及格', '#ea580c', 60, 70, 40],
|
||||
['梯度:不及格', '不及格', '#dc2626', 0, 60, 50]
|
||||
seed('grade_band', ['label', 'color', 'min', 'max', 'sort', 'gp', 'gp_top', 'uuid', 'hlc', 'origin'], [
|
||||
['梯度:优秀', '优秀', '#16803c', 90, 101, 10, 4.0, 5.0],
|
||||
['梯度:良好', '良好', '#0891b2', 80, 90, 20, 3.0, 3.9],
|
||||
['梯度:中等', '中等', '#d97706', 70, 80, 30, 2.0, 2.9],
|
||||
['梯度:及格', '及格', '#ea580c', 60, 70, 40, 1.0, 1.9],
|
||||
['梯度:不及格', '不及格', '#dc2626', 0, 60, 50, 0, 0]
|
||||
]);
|
||||
|
||||
seed('metric_def', ['key', 'label', 'unit', 'agg', 'section', 'uuid', 'hlc', 'origin'], [
|
||||
@@ -338,9 +338,16 @@ function openDb(dbPath) {
|
||||
d.exec(fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf8'));
|
||||
// 个人信息表兜底(确保任何挂载形态下都存在)
|
||||
d.exec("CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, content TEXT NOT NULL DEFAULT '{}', deleted_at TEXT, origin TEXT, hlc TEXT)");
|
||||
// 绩点公式:梯度带携带每档绩点(gp),各校规则不同可自行修改
|
||||
// 绩点公式:梯度带携带每档绩点(gp 起)与满档绩点(gp_top,档内线性折算),各校规则不同可自行修改
|
||||
const hasGp = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp'").get().c;
|
||||
if (Number(hasGp) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp REAL");
|
||||
const hasGpTop = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('grade_band') WHERE name='gp_top'").get().c;
|
||||
if (Number(hasGpTop) === 0) d.exec("ALTER TABLE grade_band ADD COLUMN gp_top REAL");
|
||||
// 课程类别(权重系数)
|
||||
const hasCat = d.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('course_grade') WHERE name='category'").get().c;
|
||||
if (Number(hasCat) === 0) d.exec("ALTER TABLE course_grade ADD COLUMN category TEXT");
|
||||
// 应用设置(绩点权重等)
|
||||
d.exec("CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
||||
migrateSync(d);
|
||||
ensureBuiltin(d);
|
||||
alignBuiltinUuids(d);
|
||||
@@ -417,20 +424,20 @@ export const DEMO_PROFILE = {
|
||||
// 学期: { term, gpa, rank, total, courses: [课程名, 学分, 分数, 绩点][] }
|
||||
export const DEMO_SEMESTERS = [
|
||||
{ term: '2023-2024-1', gpa: 3.42, rank: 38, total: 156, courses: [
|
||||
['高等数学(上)', 5, 78, 3.0], ['大学英语(一)', 3, 85, 3.7], ['程序设计基础', 4, 91, 4.0],
|
||||
['思想道德与法治', 2, 88, 3.9], ['体育(一)', 1, 82, 3.5]
|
||||
['高等数学(上)', 5, 78, 3.0, '必修课'], ['大学英语(一)', 3, 85, 3.7, '必修课'], ['程序设计基础', 4, 91, 4.0, '必修课'],
|
||||
['思想道德与法治', 2, 88, 3.9, '必修课'], ['体育(一)', 1, 82, 3.5, '必修课']
|
||||
]},
|
||||
{ term: '2023-2024-2', gpa: 3.61, rank: 29, total: 156, courses: [
|
||||
['高等数学(下)', 5, 84, 3.7], ['线性代数', 3, 76, 2.9], ['数据结构', 4, 90, 4.0],
|
||||
['大学英语(二)', 3, 87, 3.8], ['中国近现代史纲要', 2, 84, 3.5], ['体育(二)', 1, 86, 3.8]
|
||||
['高等数学(下)', 5, 84, 3.7, '必修课'], ['线性代数', 3, 76, 2.9, '必修课'], ['数据结构', 4, 90, 4.0, '必修课'],
|
||||
['大学英语(二)', 3, 87, 3.8, '必修课'], ['中国近现代史纲要', 2, 84, 3.5, '必修课'], ['体育(二)', 1, 86, 3.8, '必修课']
|
||||
]},
|
||||
{ term: '2024-2025-1', gpa: 3.78, rank: 18, total: 156, courses: [
|
||||
['概率论与数理统计', 3, 88, 3.9], ['操作系统', 4, 92, 4.0], ['计算机网络', 4, 86, 3.8],
|
||||
['数据库原理', 3, 94, 4.0], ['马克思主义基本原理', 2, 81, 3.3]
|
||||
['概率论与数理统计', 3, 88, 3.9, '必修课'], ['操作系统', 4, 92, 4.0, '必修课'], ['计算机网络', 4, 86, 3.8, '必修课'],
|
||||
['数据库原理', 3, 94, 4.0, '必修课'], ['马克思主义基本原理', 2, 81, 3.3, '必修课']
|
||||
]},
|
||||
{ term: '2024-2025-2', gpa: 3.85, rank: 12, total: 156, courses: [
|
||||
['软件工程', 3, 90, 4.0], ['信息安全导论', 3, 95, 4.0], ['密码学基础', 3, 89, 3.9],
|
||||
['Web 应用开发', 2, 96, 4.0], ['数字逻辑', 3, 79, 3.1], ['体育(四)', 1, 90, 4.0]
|
||||
['软件工程', 3, 90, 4.0, '必修课'], ['信息安全导论', 3, 95, 4.0, '必修课'], ['密码学基础', 3, 89, 3.9, '必修课'],
|
||||
['Web 应用开发', 2, 96, 4.0, '专业选修课'], ['数字逻辑', 3, 79, 3.1, '必修课'], ['体育(四)', 1, 90, 4.0, '必修课']
|
||||
]}
|
||||
];
|
||||
|
||||
@@ -892,7 +899,7 @@ function listFootprints(d, query) {
|
||||
if (score == null) return null;
|
||||
let hit = bands.find(b => score >= b.min && score < b.max);
|
||||
if (!hit) hit = bands.find(b => score >= b.min && score <= b.max);
|
||||
return hit ? { id: hit.id, label: hit.label, color: hit.color, gp: hit.gp ?? null } : null;
|
||||
return hit ? { id: hit.id, label: hit.label, color: hit.color, gp: hit.gp ?? null, gpTop: hit.gp_top ?? null } : null;
|
||||
};
|
||||
const semesters = d.prepare('SELECT * FROM semester WHERE ' + DEL + ' ORDER BY term DESC').all();
|
||||
const activeSemIds = new Set(semesters.map(s => s.id));
|
||||
@@ -922,9 +929,15 @@ function listFootprints(d, query) {
|
||||
.filter(s => s.gpa != null)
|
||||
.sort((a, b) => a.term.localeCompare(b.term))
|
||||
.map(s => ({ term: s.term, gpa: s.gpa, avg: s.stats.avg }));
|
||||
let weights = { 必修课: 1.2, 专业选修课: 1.1, 人文素质选修课: 1.0, 其他: 1.0 };
|
||||
try {
|
||||
const wr = d.prepare("SELECT value FROM app_settings WHERE key='gpa_formula'").get();
|
||||
if (wr) weights = { ...weights, ...JSON.parse(wr.value).weights };
|
||||
} catch {}
|
||||
return {
|
||||
semesters,
|
||||
bands,
|
||||
weights,
|
||||
overall: {
|
||||
avg: overallAvg,
|
||||
credits: Math.round(courses.reduce((a, c) => a + (c.credit || 0), 0) * 10) / 10,
|
||||
@@ -982,14 +995,14 @@ function listFootprints(d, query) {
|
||||
withTx(d, () => {
|
||||
if (id) {
|
||||
const h = hlcNext(d);
|
||||
d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, hlc=? WHERE id=?')
|
||||
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, h, id);
|
||||
d.prepare('UPDATE course_grade SET semester_id=?, course_name=?, credit=?, score=?, grade_point=?, category=?, hlc=? WHERE id=?')
|
||||
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, p.category ?? null, h, id);
|
||||
touchOplog(d, 'course_grade', getUuid(d, 'course_grade', id), 'upsert', h);
|
||||
} else {
|
||||
const h = hlcNext(d);
|
||||
const uuid = uuidv7();
|
||||
id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?)')
|
||||
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||||
id = Number(d.prepare('INSERT INTO course_grade (semester_id, course_name, credit, score, grade_point, category, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
.run(p.semester_id, name, p.credit ?? 0, p.score ?? null, p.grade_point ?? null, p.category ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||||
touchOplog(d, 'course_grade', uuid, 'upsert', h);
|
||||
}
|
||||
});
|
||||
@@ -1010,14 +1023,14 @@ function listFootprints(d, query) {
|
||||
withTx(d, () => {
|
||||
if (id) {
|
||||
const h = hlcNext(d);
|
||||
d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, gp=?, hlc=? WHERE id=?')
|
||||
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, h, id);
|
||||
d.prepare('UPDATE grade_band SET label=?, color=?, min=?, max=?, sort=?, gp=?, gp_top=?, hlc=? WHERE id=?')
|
||||
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, p.gpTop ?? null, h, id);
|
||||
touchOplog(d, 'grade_band', getUuid(d, 'grade_band', id), 'upsert', h);
|
||||
} else {
|
||||
const h = hlcNext(d);
|
||||
const uuid = uuidv7();
|
||||
id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, gp, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||||
id = Number(d.prepare('INSERT INTO grade_band (label, color, min, max, sort, gp, gp_top, uuid, hlc, origin) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||
.run(label, p.color ?? '#64748b', p.min, p.max, p.sort ?? 100, p.gp ?? null, p.gpTop ?? null, uuid, h, getDeviceId(d)).lastInsertRowid);
|
||||
touchOplog(d, 'grade_band', uuid, 'upsert', h);
|
||||
}
|
||||
});
|
||||
@@ -1125,6 +1138,21 @@ function listFootprints(d, query) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'gpa_settings_get': {
|
||||
const row = d.prepare("SELECT value FROM app_settings WHERE key='gpa_formula'").get();
|
||||
let weights = { 必修课: 1.2, 专业选修课: 1.1, 人文素质选修课: 1.0, 其他: 1.0 };
|
||||
if (row) { try { weights = { ...weights, ...JSON.parse(row.value).weights }; } catch {} }
|
||||
return { weights };
|
||||
}
|
||||
|
||||
case 'gpa_settings_upsert': {
|
||||
const p = payload;
|
||||
const weights = p.weights ?? {};
|
||||
d.prepare("INSERT INTO app_settings (key, value) VALUES ('gpa_formula', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
|
||||
.run(JSON.stringify({ weights }));
|
||||
return { ok: true, weights };
|
||||
}
|
||||
|
||||
case 'profile_get': {
|
||||
// 个人信息(单行 JSON):供个人信息页与简历生成使用
|
||||
const row = d.prepare('SELECT * FROM profile WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 1').get();
|
||||
@@ -1209,8 +1237,8 @@ function listFootprints(d, query) {
|
||||
for (const s of DEMO_SEMESTERS) {
|
||||
const r = handleCommand('semester_upsert', { term: s.term, gpa: s.gpa, rank: s.rank, total_students: s.total });
|
||||
const sid = r.item.id;
|
||||
for (const [name, credit, score, gp] of s.courses) {
|
||||
handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp });
|
||||
for (const [name, credit, score, gp, category] of s.courses) {
|
||||
handleCommand('course_upsert', { semester_id: sid, course_name: name, credit, score, grade_point: gp, category });
|
||||
}
|
||||
sems++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user