feat: add unified learning workflows and six-tab assistant
This commit is contained in:
+45
-15
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user