fix: resume video playback and advance linked exams with attempt controls
This commit is contained in:
+45
-22
@@ -16,9 +16,16 @@
|
||||
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, manualConfirmIncomplete: true, associations: {} };
|
||||
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
|
||||
const pendingLearningSettings = new Map();
|
||||
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
|
||||
const inScope = () => YianSiteScope.isAllowedURL(location.href);
|
||||
|
||||
function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; }
|
||||
function attemptPolicy() {
|
||||
return learning?.getAttemptPolicy() || window.YianAttemptPolicy.read(document.querySelector('app-examdetail,app-examtest,.pc_examtest'));
|
||||
}
|
||||
|
||||
function topicPreflight() {
|
||||
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
|
||||
}
|
||||
@@ -69,13 +76,15 @@
|
||||
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
||||
const signature = route.root ? structureSignature(bank) : '';
|
||||
const topic = route.mode === 'test' ? topicPreflight() : null;
|
||||
const attempt = route.mode === 'test' ? attemptPolicy() : null;
|
||||
const attemptBlocked = !!attempt && skipSingleAttempt() && attempt.blocked;
|
||||
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
|
||||
&& page.root === route.root && page.signature === signature,
|
||||
&& page.root === route.root && page.signature === signature && !attemptBlocked,
|
||||
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, submitted: route.submitted, expired: route.expired,
|
||||
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
|
||||
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations]) : '' };
|
||||
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations, skipSingleAttempt(), attempt.signature]) : '' };
|
||||
}
|
||||
|
||||
const runner = window.YianAnswerRunner.create({ getContext,
|
||||
@@ -99,10 +108,12 @@
|
||||
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, {
|
||||
const rendered = 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 }) });
|
||||
currentTitle: window.YianLearningEngine.describePage(document, location.href).title });
|
||||
for (const [key, pending] of pendingLearningSettings) rendered[key] = pending.value;
|
||||
panel.setState({ learning: rendered });
|
||||
}
|
||||
function refreshPanel(extra) {
|
||||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||||
@@ -129,6 +140,9 @@
|
||||
function setAutoAnswer(enabled) {
|
||||
if (!enabled) actionEpoch++;
|
||||
const context = getContext();
|
||||
if (enabled && context.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked) {
|
||||
panel.setNotice(attemptPolicy().reason, 'warning'); refreshPanel(); return false;
|
||||
}
|
||||
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
|
||||
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
|
||||
}
|
||||
@@ -195,11 +209,11 @@
|
||||
});
|
||||
|
||||
async function changeLearningSetting(key, value) {
|
||||
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
|
||||
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, '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('请选择顺序或并行');
|
||||
if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关');
|
||||
if (policyKeys.includes(key) && typeof value !== 'boolean') 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);
|
||||
@@ -211,15 +225,20 @@
|
||||
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
|
||||
throw new Error('主题关联须使用完整标题及题库课程名称列表');
|
||||
}
|
||||
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) {
|
||||
actionEpoch++;
|
||||
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
|
||||
await learning.pause();
|
||||
const pending = { value }; pendingLearningSettings.set(key, pending);
|
||||
try {
|
||||
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].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);
|
||||
} finally {
|
||||
if (pendingLearningSettings.get(key) === pending) pendingLearningSettings.delete(key);
|
||||
refreshPanel(); renderLearning();
|
||||
}
|
||||
learningSettings = Object.assign({}, learningSettings, { [key]: value });
|
||||
await learning.configure({ [key]: value });
|
||||
await saveSetting('learningSettings', learningSettings);
|
||||
refreshPanel(); renderLearning();
|
||||
}
|
||||
async function taskAction(action) {
|
||||
actionEpoch++;
|
||||
@@ -245,8 +264,9 @@
|
||||
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: () => Object.assign({}, learningSettings, {
|
||||
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false
|
||||
}), getRunnerState: () => runnerState,
|
||||
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false,
|
||||
skipSingleAttemptQuiz: skipSingleAttempt()
|
||||
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
|
||||
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
|
||||
onState: state => { examState = state; renderLearning(); } });
|
||||
learning = window.YianLearningEngine.create({
|
||||
@@ -266,7 +286,7 @@
|
||||
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 },
|
||||
sourceUrl: location.href, answerInterval: quizContext.answerInterval, skipSingleAttemptQuiz: quizContext.skipSingleAttemptQuiz },
|
||||
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 });
|
||||
@@ -281,9 +301,7 @@
|
||||
},
|
||||
onState: state => {
|
||||
learningState = state;
|
||||
if (!loading && typeof state.manualConfirmIncomplete === 'boolean') {
|
||||
learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete;
|
||||
}
|
||||
if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key];
|
||||
renderLearning();
|
||||
}
|
||||
});
|
||||
@@ -308,6 +326,9 @@
|
||||
}
|
||||
const route = routeInfo();
|
||||
if (!loading) {
|
||||
if (route.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked && config.autoAnswer) {
|
||||
config.autoAnswer = false; runKey = ''; runner.stop(attemptPolicy().reason); panel.setNotice(attemptPolicy().reason, 'warning');
|
||||
}
|
||||
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||||
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
|
||||
}
|
||||
@@ -386,7 +407,9 @@
|
||||
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);
|
||||
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings, { restore: true });
|
||||
const restoredPolicies = learning.getState();
|
||||
for (const key of policyKeys) learningSettings[key] = restoredPolicies[key];
|
||||
config.allowCopy = values[2] !== false;
|
||||
const position = values[3];
|
||||
// A drag during asynchronous storage loading wins over the older saved position.
|
||||
|
||||
Reference in New Issue
Block a user