feat: add unified learning workflows and six-tab assistant

This commit is contained in:
薇星幻海
2026-10-18 01:11:33 +08:00
parent 20abb33740
commit eb9c610b9f
32 changed files with 23806 additions and 353 deletions
+45 -15
View File
@@ -3,10 +3,12 @@
'use strict';
const ROLES = Object.freeze(['主操', '班长', '副操']);
const BANK_KEY = 'yianxue.assistant.bank.v2';
const BANK_KEY = 'yianxue.assistant.bank.v3';
const LEGACY_BANK_KEY = 'yianxue.assistant.bank.v2';
const SETTING_PREFIX = 'yianxue.assistant.setting.v2.';
const MAX_BYTES = 60 * 1024 * 1024;
let current = null;
let loading = null;
let importQueue = Promise.resolve();
function normalize(value) {
@@ -61,9 +63,20 @@
}
function snapshot(records, revision, storageName) {
const counts = { 主操: 0, 班长: 0, 副操: 0 };
records.forEach(record => { counts[record.role]++; });
return { schemaVersion: 2, records, counts, total: records.length, revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
const counts = { 主操: 0, 班长: 0, 副操: 0, 未标明: 0 };
const questions = new Map(), variants = new Set();
records.forEach(record => {
const role = record.role || '未标明';
counts[role] = (counts[role] || 0) + 1;
const key = record.questionKey + '\n' + record.optionKey;
const answer = JSON.stringify(record.correctTexts.map(normalize).sort());
if (!questions.has(key)) questions.set(key, new Set());
questions.get(key).add(answer);
variants.add(key + '\n' + answer);
});
return { schemaVersion: 3, records, counts, total: records.length, uniqueTotal: variants.size, questionTotal: questions.size,
conflictTotal: Array.from(questions.values()).filter(answers => answers.size > 1).length,
revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
}
// RFC 4180: quoted commas, escaped quotes, CRLF and embedded line breaks.
@@ -137,7 +150,7 @@
context = context || {};
if (!raw || typeof raw !== 'object') throw new Error('题目记录格式不正确');
const role = String(raw.role || context.role || '').trim();
if (!ROLES.includes(role)) throw new Error('题库未标明岗位;请先选择主操、班长或副操再导入');
// Role is provenance only. Older exports and role-free banks share one index.
if (context.pathRole && raw.role && raw.role !== context.pathRole) throw new Error('题目岗位与 ZIP 目录岗位不一致');
const type = normalizeType(raw.type);
if (!['单选', '多选', '判断'].includes(type)) throw new Error('题型不支持或缺失:' + (type || '空'));
@@ -214,6 +227,7 @@
role: read(row, '岗位') || read(row, '角色') || context.role,
course: read(row, '题库名称') || read(row, '课程') || context.course,
sourceUrl: read(row, '来源网址') || context.sourceUrl,
sourceFile: read(row, '来源文件') || context.sourceFile,
number: read(row, '题号') || index + 1,
type: read(row, '题型'), question: read(row, '题目'), options,
answer: read(row, '参考答案'), explanation: read(row, '题目解析')
@@ -251,28 +265,45 @@
function deduplicate(records) {
const seen = new Set();
return records.filter(record => {
const key = JSON.stringify([sourceScope(record), record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
const key = JSON.stringify([sourceScope(record), record.sourceFile, record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
if (seen.has(key)) return false;
seen.add(key); return true;
});
}
async function load() {
if (current) return current;
async function readSaved() {
const backend = storage();
let saved = await backend.get(BANK_KEY, null);
const legacy = saved == null;
if (legacy) saved = await backend.get(LEGACY_BANK_KEY, null);
if (typeof saved === 'string') {
try { saved = JSON.parse(saved); } catch (_) { throw new Error('已缓存题库格式损坏;请重新导入题库'); }
}
if (!saved) return (current = snapshot([], 0, backend.name));
if (!Array.isArray(saved.records)) throw new Error('已缓存题库格式损坏;请重新导入题库');
if (saved == null) return snapshot([], 0, backend.name);
if (!saved || !Array.isArray(saved.records) || (!legacy && saved.schemaVersion !== 3)) throw new Error('已缓存题库格式损坏;请重新导入题库');
const records = saved.records.map((record, index) => {
try { return validateRecord(record, { number: index + 1 }); }
catch (error) { throw new Error('已缓存题库的第 ' + (index + 1) + ' 道题无效;请重新导入:' + error.message); }
});
current = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
if (saved.updatedAt) current.updatedAt = saved.updatedAt;
return current;
const next = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
if (saved.updatedAt) next.updatedAt = saved.updatedAt;
if (legacy) {
// Do not alter the v2 value: a failed migration always leaves a recoverable copy.
try {
await backend.set(BANK_KEY, next);
next.migration = { from: 2, status: 'complete' };
} catch (error) {
// The validated legacy bank remains usable when a storage quota is exhausted.
next.migration = { from: 2, status: 'pending', error: String(error.message || error) };
}
}
return next;
}
async function load() {
if (current) return current;
if (!loading) loading = readSaved().then(value => (current = value)).finally(() => { loading = null; });
return loading;
}
async function fileBytes(file) {
@@ -293,7 +324,6 @@
async function performImport(files, options) {
const selected = Array.from(files || []).filter(file => !isIgnored(file.webkitRelativePath || file.name));
if (!selected.length) throw new Error('请选择 ZIP、CSV 或 JSON 题库文件');
if (options.role && !ROLES.includes(options.role)) throw new Error('岗位只能选择主操、班长或副操');
const zipFiles = selected.filter(file => /\.zip$/i.test(file.name || ''));
if (zipFiles.length && (zipFiles.length !== 1 || selected.length !== 1)) throw new Error('ZIP 全量导入时请只选择一个 ZIP 文件');
const incoming = [];
@@ -354,7 +384,7 @@
async function getSetting(key, fallback) { return storage().get(SETTING_PREFIX + String(key), fallback); }
async function setSetting(key, value) { await storage().set(SETTING_PREFIX + String(key), value); return value; }
const api = Object.freeze({ version: '2.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
const api = Object.freeze({ version: '3.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
root.YianBankStore = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);