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
+148 -20
View File
@@ -14,8 +14,15 @@
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
let positionRevision = 0;
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
let actionEpoch = 0;
let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {} };
const inScope = () => YianSiteScope.isAllowedURL(location.href);
function topicPreflight() {
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
}
function visible(node) {
if (!node || node.closest('[hidden],.hidden')) return false;
const style = getComputedStyle(node);
@@ -61,29 +68,47 @@
const route = routeInfo();
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
const signature = route.root ? structureSignature(bank) : '';
const topic = route.mode === 'test' ? topicPreflight() : null;
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
pageSignature: signature, revision: snapshot.revision, snapshot };
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations]) : '' };
}
const runner = window.YianAnswerRunner.create({ getContext,
match: (question, context) => matcher.match(question, context, snapshot),
getInterval: () => learningSettings.answerInterval,
onProgress: progress => { metrics = progress; panel.setState({ metrics }); },
onState: (state, reason) => {
if (disposed) return;
runnerState = state;
if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); }
if (state === 'paused') { examController?.disable(reason); learning?.configure({ examPaused: true }); }
if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success');
}
});
function courses() {
return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort();
return Array.from(new Set(snapshot.records.map(record => record.course))).sort();
}
function renderLearning() {
if (!learning) return;
const video = learningState.video ? Object.assign({}, learningState.video, { learnedTime: learningState.video.learnedSeconds,
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
const route = routeInfo();
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
panel.setState({ learning: Object.assign({}, learningSettings, learningState, {
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
currentTitle: window.YianLearningEngine.describePage(document, location.href).title }) });
}
function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(),
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
bankTotal: snapshot.total, uniqueTotal: snapshot.questionTotal, conflictTotal: snapshot.conflictTotal,
roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
}
function performSearch(query = searchQuery, options = {}) {
searchQuery = String(query || '');
@@ -91,7 +116,6 @@
const result = searchQuery.trim() ? matcher.search(searchQuery,
{ role: config.role, course: config.course, limit: 20, offset }, snapshot) : { items: [], total: 0 };
panel.renderSearch(Object.assign({}, result, { offset }));
if (!config.role && !options.quiet) panel.setNotice('请先选择主操、班长或副操。', 'warning');
return result;
}
async function saveSetting(key, value) {
@@ -99,28 +123,33 @@
catch (error) { panel.setNotice('设置保存失败:' + error.message, 'error'); }
}
async function setRole(role) {
if (!store.roles.includes(role)) throw new Error('请选择主操、班长或副操');
runner.stop('岗位已切换'); config.role = role; config.course = ''; runKey = ''; metrics = emptyMetrics();
refreshPanel(); performSearch(); tick(); await saveSetting('role', role); return role;
// Compatibility for old integrations; a role no longer filters any answers.
config.role = ''; return '';
}
function setAutoAnswer(enabled) {
if (!enabled) actionEpoch++;
const context = getContext();
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !config.role || !snapshot.counts[config.role])) {
panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
}
if (enabled && context.mode === 'test' && topicPreflight().status !== 'matched') {
panel.setNotice('请先在题库栏确认当前考试标题与题库课程/主题的关联。', 'warning'); return false;
}
config.autoAnswer = !!enabled; runKey = '';
if (!enabled) runner.stop('自动选择已关闭,已生效的选择保留。');
if (!enabled) { runner.stop('自动选择已关闭,已生效的选择保留。'); examController?.disable('自动选择已关闭'); }
refreshPanel(); tick(); return config.autoAnswer;
}
function setCopy(enabled) {
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
}
async function importFiles(files) {
examController?.disable('题库正在更新');
if (learning) await learning.pause();
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
try {
snapshot = await store.importFiles(Array.from(files || []), { role: config.role });
metrics = emptyMetrics(); config.course = '';
panel.setNotice('题库导入成功:' + snapshot.total + ' 条记录。请选择岗位后搜索或自动选择。', 'success');
panel.setNotice('统一题库导入成功:' + snapshot.total + ' 条来源记录,' + snapshot.questionTotal + ' 道题,' + snapshot.conflictTotal + ' 道存在答案冲突。', 'success');
return snapshot;
} catch (error) {
panel.setNotice('导入失败:' + error.message + '。原有题库保留。', 'error'); throw error;
@@ -154,6 +183,97 @@
onSelectionSearch: text => performSearch(text),
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
onExportEnabledChange: setExportEnabled
,onTaskAction: action => { taskAction(action).catch(error => panel.setNotice(error.message, 'error')); },
onLearningSetting: (key, value) => { changeLearningSetting(key, value).catch(error => panel.setNotice(error.message, 'error')); },
onLogAction: action => {
if (action === 'clear') learning?.clearLogs?.();
if (action === 'copy') {
const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n');
if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text).catch(() => {});
}
}
});
async function changeLearningSetting(key, value) {
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
if (!allowed.includes(key)) return;
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
if (ranges[key]) {
const range = ranges[key]; value = Number(value);
if (!Number.isFinite(value) || value < range[0] || value > range[1] || key === 'maxRefresh' && !Number.isInteger(value)) {
throw new Error('设置数值须在 ' + range[0] + '~' + range[1] + ' 范围内');
}
}
if (key === 'associations' && (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
throw new Error('主题关联须使用完整标题及题库课程名称列表');
}
if (['mode', 'execution', 'associations'].includes(key)) {
actionEpoch++;
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
await learning.pause();
}
learningSettings = Object.assign({}, learningSettings, { [key]: value });
await learning.configure({ [key]: value });
await saveSetting('learningSettings', learningSettings);
refreshPanel(); renderLearning();
}
async function taskAction(action) {
actionEpoch++;
await ready;
if (!inScope()) return;
if (action === 'start' || action === 'resume') {
if (action === 'start') await learning.start(); else await learning.resume();
} else if (action === 'pause' || action === 'stop') {
config.autoAnswer = false; runner.stop('用户暂停或停止任务'); examController.disable('用户暂停或停止任务');
if (action === 'pause') await learning.pause(); else await learning.stop();
} else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' });
else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); }
else if (action === 'examResume') {
await learning.configure({ examPaused: false });
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true);
}
renderLearning();
}
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
getTopic: topicPreflight, getSettings: () => learningSettings, getRunnerState: () => runnerState,
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
onState: state => { examState = state; renderLearning(); } });
learning = window.YianLearningEngine.create({
getSnapshot: () => ({ document, url: location.href, courses: courses(), bank: snapshot,
bankVersion: snapshot.revision, accountId: document.querySelector('[data-user-id]')?.getAttribute('data-user-id') || '', planId: '' }),
getExamState: () => examController.getState(),
onExamStart: async () => {
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch && learning.getState().status === 'running'
&& !learning.getState().examPaused) return setAutoAnswer(true);
return false;
},
onExamPause: reason => { setAutoAnswer(false); examController.disable(reason); },
solveQuiz: async (_popup, quizContext) => {
const revision = snapshot.revision;
const mode = learningSettings.mode;
const epoch = actionEpoch;
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
sourceUrl: location.href, answerInterval: quizContext.answerInterval },
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
},
storage: learningStorage,
navigate: url => { if (YianSiteScope.isAllowedURL(url)) location.assign(url); else throw new Error('任务链接不在脚本范围内'); },
reload: () => location.reload(),
openWorker: url => {
if (!YianSiteScope.isAllowedURL(url)) throw new Error('任务链接不在脚本范围内');
if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true });
const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child;
},
onState: state => { learningState = state; renderLearning(); }
});
function tick() {
@@ -163,6 +283,7 @@
scopedOut = true;
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
examController.disable('页面已离开脚本范围'); learning.pause();
panel.host.style.setProperty('display', 'none', 'important');
previous = page; page = null; metrics = emptyMetrics();
}
@@ -174,6 +295,10 @@
copy.setEnabled(config.allowCopy);
}
const route = routeInfo();
if (!loading) {
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
}
if (!route.root) {
if (runner.running) runner.stop('当前页面未识别到练习题目');
if (page) previous = page;
@@ -207,15 +332,15 @@
else if (!bank.total || now - page.since < settleMs) status = '正在读取题目与隐藏答案…';
else if (!countComplete) status = bank.expectedTotal === null ? '等待考试答题卡总题数,确认完整试卷后才能自动选择。'
: '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按所选岗位题库严格核对题干、选项和正确答案全文。'
+ (route.local ? ' 保存网页只用于核对,自动选择需要在线考试页面。' : '完成后请检查结果并自行交卷。');
else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按统一题库严格核对题干、选项和正确答案全文。'
+ (examController.getState().enabled ? '自动交卷条件已启用,请留意预计得分与倒计时。' : '仅选择答案;通过任务总览启动考试任务后才按条件自动交卷。');
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
if (route.mode === 'test' && !route.exam) { config.autoAnswer = false; runKey = ''; }
refreshPanel({ pageStatus: status, pageMode: route.mode === 'test' ? 'test' : route.practice ? 'exercise' : '',
canExport: route.practice && !!bank.total,
canAnswer: route.answerSupported && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
canAnswer: route.answerSupported && !route.local && page.ready && !loading && !!snapshot.total });
if (!route.answerSupported || !page.ready || loading) { if (runner.running) runner.stop('等待可作答页面准备完成'); return; }
if (route.practice && config.exportEnabled && bank.complete) {
const key = route.key + '|' + signature;
@@ -224,8 +349,8 @@
catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
}
}
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
if (config.autoAnswer && config.role && snapshot.counts[config.role] && runKey !== nextRunKey && !runner.running) {
const nextRunKey = route.key + '|' + signature + '|' + snapshot.revision;
if (config.autoAnswer && snapshot.total && runKey !== nextRunKey && !runner.running) {
runKey = nextRunKey;
runner.start().catch(error => { config.autoAnswer = false; refreshPanel(); panel.setNotice('自动选择失败:' + error.message, 'error'); });
}
@@ -241,13 +366,15 @@
function dispose() {
disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer);
window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation);
runner.destroy(); copy.destroy(); panel.destroy();
runner.destroy(); examController.destroy(); learning.destroy(); copy.destroy(); panel.destroy();
}
const ready = (async () => {
const positionRevisionAtRead = positionRevision;
try {
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = '';
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings);
config.allowCopy = values[2] !== false;
const position = values[3];
// A drag during asynchronous storage loading wins over the older saved position.
@@ -259,7 +386,8 @@
loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot;
})();
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
setLearningSetting: changeLearningSetting, taskAction, getLearningState: () => learning.getState(), getExamState: () => examController.getState(),
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, learning: learning.getState(), exam: examController.getState(), pageReady: !!page && page.ready }),
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
window.YianAssistant = api; window.YianQuestionBankExporter = api;
refreshPanel(); tick();