feat: add unified learning workflows and six-tab assistant
This commit is contained in:
+331
-57
@@ -2,7 +2,6 @@
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const ROLES = ['主操', '班长', '副操'];
|
||||
const PAGE_SIZE = 20;
|
||||
const TYPE_NAMES = { single: '单选', multiple: '多选', judgment: '判断', unknown: '未知' };
|
||||
|
||||
@@ -21,7 +20,9 @@
|
||||
const state = {
|
||||
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
|
||||
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
|
||||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null
|
||||
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
|
||||
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
|
||||
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
|
||||
};
|
||||
let destroyed = false;
|
||||
let selectionText = '';
|
||||
@@ -48,6 +49,10 @@
|
||||
`;
|
||||
style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}';
|
||||
style.textContent += '.header{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.panel.dragging .header{cursor:grabbing}.panel.dragging,.panel.dragging *{-webkit-user-select:none!important;user-select:none!important}';
|
||||
style.textContent += `
|
||||
.panel{width:640px;max-width:calc(100vw - 24px)}.panel.collapsed{width:auto;max-width:calc(100vw - 24px);min-width:210px}.brand{font-size:14px}.body{padding:0;overflow:hidden;max-height:calc(100vh - 88px);max-height:calc(100dvh - 88px);display:flex;flex-direction:column}.global-bar{flex:none;padding:11px 13px;border-bottom:1px solid var(--line);background:#f8faff}.global-top{display:flex;align-items:center;gap:8px}.global-status{font-weight:650;flex:1;min-width:0;overflow-wrap:anywhere}.global-action{font-size:12px;white-space:nowrap}.global-progress-row{display:flex;align-items:center;gap:9px;margin-top:7px}.global-progress{min-width:30px;flex:1;height:7px;border:0;border-radius:5px;overflow:hidden;accent-color:var(--accent)}.global-progress::-webkit-progress-bar{background:#e1e8f3;border-radius:5px}.global-progress::-webkit-progress-value{background:var(--accent);border-radius:5px}.global-progress-label{font-size:11px;color:var(--muted);white-space:nowrap}.worker-status{font-size:11px;color:var(--muted);margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere}.workspace{display:flex;min-height:0;height:500px;max-height:calc(100vh - 230px);max-height:calc(100dvh - 230px)}.navigation{display:flex;flex-direction:column;gap:4px;flex:none;width:123px;padding:12px 8px;border-right:1px solid var(--line);background:#f8faff;overflow:auto}.nav-tab{border:0;background:transparent;text-align:left;padding:9px 10px;border-radius:7px;font-size:12px;white-space:nowrap}.nav-tab[aria-selected=true]{background:#e6efff;color:#174fae;font-weight:700}.nav-tab[data-alert=true]::after{content:' ·';color:#ad6921;font-weight:800}.panes{flex:1;min-width:0;min-height:0}.tab-pane{height:100%;padding:13px;overflow:auto;overscroll-behavior:contain}.tab-pane>.section:first-child{padding-top:0}.tab-pane .page-section:first-child{margin-top:0;padding-top:0;border-top:0}.notice{flex:none;margin:0;padding:8px 13px;border-radius:0;max-height:65px;overflow:auto}.setting-row{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:9px;margin:10px 0}.setting-row>span{font-size:12px;color:#43546d}.setting-row input[type=number]{min-width:0;width:100%;border:1px solid #cfd8e7;border-radius:7px;padding:6px 8px;color:#17253d;background:white}.task-actions,.console-actions{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.summary-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:10px 0}.summary-card{padding:9px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow-wrap:anywhere}.summary-card strong{display:block;font-size:20px;font-weight:650;color:#214e91}.summary-card span{font-size:11px;color:var(--muted)}.worker-card{display:block;text-align:left;width:100%;margin-top:8px;padding:10px;white-space:pre-wrap;overflow-wrap:anywhere}.task-list{display:grid;gap:7px}.task-row{padding:9px 10px;border:1px solid var(--line);border-radius:7px;overflow-wrap:anywhere}.task-name{font-weight:600;margin:0 0 3px;font-size:12px}.task-state{font-size:11px;color:var(--muted)}.info-grid{display:grid;grid-template-columns:80px minmax(0,1fr);gap:7px;font-size:12px;margin:10px 0}.info-grid dt{color:var(--muted)}.info-grid dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.explanation{padding:9px 10px;background:var(--surface);border-radius:7px;font-size:12px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere}.mode-note{font-size:11px;color:#926214;margin:7px 0}.console-filter{max-width:125px}.log-list{display:grid;gap:6px;font-size:11px;-webkit-user-select:text;user-select:text}.log-entry{padding:7px 8px;border-radius:5px;background:#f4f7fb;white-space:pre-wrap;overflow-wrap:anywhere;border-left:3px solid #b9c9de}.log-entry.warning{background:#fff6e6;border-left-color:#d0a354}.log-entry.error{background:#fff0ed;border-left-color:#d68370}.log-time{color:var(--muted);margin-right:7px}.threshold{font-size:12px;font-weight:600;color:#825b20;margin:9px 0}.export-section{padding-top:13px!important}.collapse-summary{font-size:10px;color:#bbd3fa;margin-left:7px;white-space:nowrap}.bank-counts{font-size:11px;color:var(--muted);margin:8px 0}.topic-row{display:flex;gap:6px;margin-top:9px}.topic-row input{flex:1;min-width:0}.theme-associations{font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);margin:8px 0}.tab-pane .switches{margin-bottom:12px}
|
||||
@media(max-width:600px){.panel{width:calc(100vw - 24px)}.workspace{flex-direction:column;height:480px;max-height:calc(100vh - 244px);max-height:calc(100dvh - 244px)}.navigation{flex-direction:row;width:100%;border-right:0;border-bottom:1px solid var(--line);padding:7px;overflow-x:auto;flex-shrink:0}.nav-tab{padding:8px 9px}.panes{min-height:0}.tab-pane{padding:11px}.global-bar{padding:9px 11px}.brand{font-size:13px}.collapsed .header{gap:8px}.global-top{gap:5px}.global-action{padding:5px 7px}.setting-row{grid-template-columns:80px minmax(0,1fr)}}
|
||||
`;
|
||||
root.appendChild(style);
|
||||
const visibilityStyle = doc.createElement('style');
|
||||
visibilityStyle.textContent = '[hidden]{display:none!important}';
|
||||
@@ -56,17 +61,79 @@
|
||||
panel.setAttribute('aria-label', '谊安学助手 2.0');
|
||||
const header = el(doc, 'div', 'header');
|
||||
header.title = '拖动标题栏移动面板';
|
||||
const brand = el(doc, 'span', 'brand', '题库助手');
|
||||
const brand = el(doc, 'span', 'brand', '谊安学助手 2.0');
|
||||
const collapseSummary = el(doc, 'span', 'collapse-summary');
|
||||
const expand = el(doc, 'button', 'toggle', '展开');
|
||||
expand.type = 'button';
|
||||
expand.setAttribute('aria-label', '展开题库助手');
|
||||
expand.setAttribute('aria-expanded', 'false');
|
||||
header.append(brand, expand);
|
||||
header.append(brand, collapseSummary, expand);
|
||||
const body = el(doc, 'div', 'body');
|
||||
body.hidden = true;
|
||||
panel.append(header, body);
|
||||
root.appendChild(panel);
|
||||
|
||||
const globalBar = el(doc, 'div', 'global-bar');
|
||||
const globalTop = el(doc, 'div', 'global-top');
|
||||
const globalStatus = el(doc, 'span', 'global-status', '尚未开始');
|
||||
globalStatus.setAttribute('data-testid', 'learning-status');
|
||||
globalStatus.setAttribute('aria-live', 'polite');
|
||||
const globalPause = el(doc, 'button', 'global-action', '暂停全部');
|
||||
globalPause.type = 'button';
|
||||
globalPause.setAttribute('data-testid', 'global-pause');
|
||||
globalPause.addEventListener('click', function () { invoke('onTaskAction', ['paused', 'stopped'].includes(state.learning.status) ? 'resume' : 'pause'); });
|
||||
globalTop.append(globalStatus, globalPause);
|
||||
const globalProgressRow = el(doc, 'div', 'global-progress-row');
|
||||
const globalProgress = el(doc, 'progress', 'global-progress');
|
||||
globalProgress.max = 1; globalProgress.value = 0;
|
||||
globalProgress.setAttribute('aria-label', '已完成任务进度');
|
||||
const globalProgressLabel = el(doc, 'span', 'global-progress-label', '0 / 0');
|
||||
const workerStatus = el(doc, 'div', 'worker-status');
|
||||
globalProgressRow.append(globalProgress, globalProgressLabel);
|
||||
globalBar.append(globalTop, globalProgressRow, workerStatus);
|
||||
const workspace = el(doc, 'div', 'workspace');
|
||||
const navigation = el(doc, 'nav', 'navigation');
|
||||
navigation.setAttribute('role', 'tablist');
|
||||
navigation.setAttribute('aria-label', '助手功能');
|
||||
const panes = el(doc, 'div', 'panes');
|
||||
const tabs = {}, views = {};
|
||||
const tabOrder = ['overview', 'video', 'exam', 'bank', 'console', 'settings'];
|
||||
const tabNames = { overview: '任务总览', video: '视频学习', exam: '考试答题', bank: '题库搜索', console: '控制台', settings: '其他设置' };
|
||||
function selectTab(key, focus) {
|
||||
if (!views[key]) return;
|
||||
state.activeTab = key;
|
||||
tabOrder.forEach(function (id) {
|
||||
const active = id === key;
|
||||
tabs[id].setAttribute('aria-selected', String(active));
|
||||
tabs[id].tabIndex = active ? 0 : -1;
|
||||
views[id].hidden = !active;
|
||||
});
|
||||
if (focus) tabs[key].focus();
|
||||
scheduleClamp(true);
|
||||
}
|
||||
tabOrder.forEach(function (key, index) {
|
||||
const tab = el(doc, 'button', 'nav-tab', tabNames[key]);
|
||||
tab.type = 'button'; tab.id = 'yian-tab-' + key;
|
||||
tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', 'yian-pane-' + key);
|
||||
tab.setAttribute('data-testid', 'tab-' + key);
|
||||
tab.addEventListener('click', function () { selectTab(key, false); });
|
||||
tab.addEventListener('keydown', function (event) {
|
||||
let target;
|
||||
if (event.key === 'Home') target = tabOrder[0];
|
||||
if (event.key === 'End') target = tabOrder[tabOrder.length - 1];
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') target = tabOrder[(index + 1) % tabOrder.length];
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') target = tabOrder[(index + tabOrder.length - 1) % tabOrder.length];
|
||||
if (target) { event.preventDefault(); selectTab(target, true); }
|
||||
});
|
||||
const view = el(doc, 'section', 'tab-pane');
|
||||
view.id = 'yian-pane-' + key; view.setAttribute('role', 'tabpanel');
|
||||
view.setAttribute('aria-labelledby', tab.id); view.setAttribute('data-testid', 'pane-' + key);
|
||||
tabs[key] = tab; views[key] = view;
|
||||
navigation.appendChild(tab); panes.appendChild(view);
|
||||
});
|
||||
workspace.append(navigation, panes);
|
||||
body.append(globalBar, workspace);
|
||||
|
||||
function invoke(name) {
|
||||
if (destroyed || typeof callbacks[name] !== 'function') return;
|
||||
const args = Array.prototype.slice.call(arguments, 1);
|
||||
@@ -213,7 +280,8 @@
|
||||
const copySwitch = makeSwitch('允许复制文字', function () { state.allowCopy = copySwitch.input.checked; invoke('onCopyChange', state.allowCopy); refresh(); });
|
||||
const answerHint = el(doc, 'p', 'hint');
|
||||
switches.appendChild(answerHint);
|
||||
body.appendChild(switches);
|
||||
views.exam.appendChild(switches);
|
||||
views.settings.appendChild(copySwitch.label);
|
||||
|
||||
const bankSection = el(doc, 'div', 'section');
|
||||
const importRow = el(doc, 'div', 'import-row');
|
||||
@@ -228,7 +296,7 @@
|
||||
importRow.append(importButton, bankInfo, fileInput);
|
||||
const storageInfo = el(doc, 'p', 'storage');
|
||||
bankSection.append(importRow, storageInfo);
|
||||
body.appendChild(bankSection);
|
||||
views.bank.appendChild(bankSection);
|
||||
importButton.addEventListener('click', function () { fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
@@ -238,37 +306,6 @@
|
||||
|
||||
const searchSection = el(doc, 'div', 'section');
|
||||
searchSection.appendChild(el(doc, 'h2', 'section-title', '搜索题库'));
|
||||
const roles = el(doc, 'div', 'roles');
|
||||
roles.setAttribute('role', 'radiogroup');
|
||||
roles.setAttribute('aria-label', '选择岗位');
|
||||
const roleInputs = {};
|
||||
const roleLabels = {};
|
||||
const roleCounters = {};
|
||||
ROLES.forEach(function (role) {
|
||||
const label = el(doc, 'label', 'role');
|
||||
const input = el(doc, 'input');
|
||||
input.type = 'radio';
|
||||
input.name = 'yian-search-role';
|
||||
input.value = role;
|
||||
input.setAttribute('aria-label', role);
|
||||
const counter = el(doc, 'span', 'role-count');
|
||||
label.append(input, el(doc, 'span', '', role), counter);
|
||||
roles.appendChild(label);
|
||||
roleInputs[role] = input;
|
||||
roleLabels[role] = label;
|
||||
roleCounters[role] = counter;
|
||||
input.addEventListener('change', function () {
|
||||
if (!input.checked) return;
|
||||
const query = searchInput.value;
|
||||
state.role = role;
|
||||
state.course = '';
|
||||
currentOffset = 0;
|
||||
refresh();
|
||||
invoke('onRoleChange', role);
|
||||
if (normalized(query)) invoke('onSearch', query, { offset: 0 });
|
||||
});
|
||||
});
|
||||
searchSection.appendChild(roles);
|
||||
const courseRow = el(doc, 'label', 'course-row');
|
||||
const courseSelect = el(doc, 'select');
|
||||
courseSelect.setAttribute('aria-label', '选择课程');
|
||||
@@ -297,7 +334,7 @@
|
||||
notice.hidden = true;
|
||||
notice.setAttribute('role', 'status');
|
||||
notice.setAttribute('aria-live', 'polite');
|
||||
searchSection.appendChild(notice);
|
||||
body.appendChild(notice);
|
||||
const resultsTitle = el(doc, 'p', 'results-title');
|
||||
const results = el(doc, 'div', 'results');
|
||||
const pagination = el(doc, 'div', 'pagination');
|
||||
@@ -308,10 +345,9 @@
|
||||
pagination.append(previous, pageLabel, next);
|
||||
pagination.hidden = true;
|
||||
searchSection.append(resultsTitle, results, pagination);
|
||||
body.appendChild(searchSection);
|
||||
views.bank.appendChild(searchSection);
|
||||
|
||||
function search(offset) {
|
||||
if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; }
|
||||
if (!normalized(searchInput.value)) { setNotice('请输入题干、关键词或选项文字。', 'warning'); searchInput.focus(); return; }
|
||||
currentOffset = Math.max(0, Number(offset) || 0);
|
||||
invoke('onSearch', searchInput.value, { offset: currentOffset });
|
||||
@@ -331,7 +367,6 @@
|
||||
selectionButton.addEventListener('pointerdown', rememberSelection);
|
||||
selectionButton.addEventListener('click', function () {
|
||||
rememberSelection();
|
||||
if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; }
|
||||
if (!selectionText) { setNotice('请先在网页正文选中题目文字。', 'warning'); return; }
|
||||
searchInput.value = selectionText;
|
||||
currentOffset = 0;
|
||||
@@ -355,7 +390,7 @@
|
||||
unprocessed.append(unprocessedSummary, unprocessedList);
|
||||
unprocessed.hidden = true;
|
||||
pageSection.append(pageStatus, metrics, answerCheck, unprocessed);
|
||||
body.appendChild(pageSection);
|
||||
views.exam.appendChild(pageSection);
|
||||
|
||||
const exportSection = el(doc, 'section', 'page-section export-section');
|
||||
exportSection.setAttribute('aria-label', '题库导出设置');
|
||||
@@ -382,11 +417,143 @@
|
||||
});
|
||||
exportContent.appendChild(exportRow);
|
||||
exportSection.appendChild(exportContent);
|
||||
body.appendChild(exportSection);
|
||||
views.settings.appendChild(exportSection);
|
||||
|
||||
const learningControls = {};
|
||||
function learningSetting(container, key, label, values, fallback, range) {
|
||||
const row = el(doc, 'label', 'setting-row');
|
||||
const input = el(doc, values ? 'select' : 'input');
|
||||
input.setAttribute('aria-label', label);
|
||||
input.setAttribute('data-testid', 'setting-' + key);
|
||||
if (values) values.forEach(function (entry) {
|
||||
const option = el(doc, 'option', '', entry[1]); option.value = plain(entry[0]); input.appendChild(option);
|
||||
});
|
||||
else { input.type = 'number'; input.min = plain(range && range[0] || 0); input.max = plain(range && range[1] || 3600); input.step = plain(range && range[2] || 1); }
|
||||
row.append(el(doc, 'span', '', label), input); container.appendChild(row);
|
||||
learningControls[key] = { input: input, fallback: fallback, numeric: !values || typeof fallback === 'number' };
|
||||
input.addEventListener('change', function () {
|
||||
if (!input.checkValidity()) { setNotice('请输入有效的' + label + '。', 'warning'); return; }
|
||||
const value = learningControls[key].numeric ? Number(input.value) : input.value;
|
||||
state.learning[key] = value;
|
||||
invoke('onLearningSetting', key, value);
|
||||
refreshLearning();
|
||||
});
|
||||
return input;
|
||||
}
|
||||
function taskButton(container, label, action, className) {
|
||||
const button = el(doc, 'button', className || '', label); button.type = 'button';
|
||||
button.setAttribute('data-testid', 'task-' + action);
|
||||
button.addEventListener('click', function () { invoke('onTaskAction', action); });
|
||||
container.appendChild(button); return button;
|
||||
}
|
||||
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
|
||||
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频、课中题与考试']], 'all');
|
||||
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
|
||||
const taskActions = el(doc, 'div', 'task-actions');
|
||||
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
|
||||
const resumeTask = taskButton(taskActions, '继续上次任务', 'resume');
|
||||
const stopTask = taskButton(taskActions, '停止本轮', 'stop');
|
||||
views.overview.appendChild(taskActions);
|
||||
const learningMessage = el(doc, 'p', 'explanation');
|
||||
const summaryGrid = el(doc, 'div', 'summary-grid');
|
||||
const summaryNodes = {};
|
||||
[['total', '任务总数'], ['completed', '已完成'], ['skipped', '已跳过'], ['waiting', '等待人工'], ['failed', '失败'], ['etaSeconds', '预计剩余']].forEach(function (entry) {
|
||||
const card = el(doc, 'div', 'summary-card'); const value = el(doc, 'strong', '', '0');
|
||||
card.append(value, el(doc, 'span', '', entry[1])); summaryNodes[entry[0]] = value; summaryGrid.appendChild(card);
|
||||
});
|
||||
const videoSummary = el(doc, 'button', 'worker-card'); videoSummary.type = 'button';
|
||||
const examSummary = el(doc, 'button', 'worker-card'); examSummary.type = 'button';
|
||||
videoSummary.addEventListener('click', function () { selectTab('video', true); });
|
||||
examSummary.addEventListener('click', function () { selectTab('exam', true); });
|
||||
const taskList = el(doc, 'div', 'task-list');
|
||||
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
|
||||
|
||||
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
|
||||
const videoModeNote = el(doc, 'p', 'mode-note'); views.video.appendChild(videoModeNote);
|
||||
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
|
||||
const videoInfo = el(doc, 'dl', 'info-grid');
|
||||
const videoActions = el(doc, 'div', 'task-actions');
|
||||
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
|
||||
const videoResume = taskButton(videoActions, '继续视频任务', 'videoResume');
|
||||
const videoMessage = el(doc, 'p', 'explanation');
|
||||
views.video.append(videoInfo, videoActions, videoMessage, el(doc, 'p', 'hint', '播放结束后等待平台确认完成;倍速不会保证平台要求的学习时长按比例缩短。课中题出现时暂停播放,精确匹配后处理。'));
|
||||
|
||||
const examIntro = el(doc, 'div', 'section');
|
||||
examIntro.appendChild(el(doc, 'h2', 'section-title', '考试设置与进度'));
|
||||
const examModeNote = el(doc, 'p', 'mode-note'); examIntro.appendChild(examModeNote);
|
||||
learningSetting(examIntro, 'answerInterval', '答题间隔(秒)', null, 4, [0, 120, 0.5]);
|
||||
learningSetting(examIntro, 'minSubmitMinutes', '最早交卷(分)', null, 6, [0.5, 180, 0.5]);
|
||||
const examInfo = el(doc, 'dl', 'info-grid');
|
||||
const examMessage = el(doc, 'p', 'explanation');
|
||||
const examActions = el(doc, 'div', 'task-actions');
|
||||
const examPause = taskButton(examActions, '暂停考试任务', 'examPause');
|
||||
const examResume = taskButton(examActions, '继续考试任务', 'examResume');
|
||||
examIntro.append(examInfo, el(doc, 'p', 'threshold', '预计得分必须严格大于 80 分;正好 80 分等待人工。'), examMessage, examActions,
|
||||
el(doc, 'p', 'hint', '仅已精确匹配、点击并核验完整答案文字的题目计分。进入原试卷满设定时长、核验完成且满足条件后才自动交卷;预计分数以本地题库为依据。练习与课中题不使用考试交卷门槛。'));
|
||||
views.exam.insertBefore(examIntro, switches);
|
||||
|
||||
const bankCounts = el(doc, 'p', 'bank-counts'); bankSection.appendChild(bankCounts);
|
||||
const associationSection = el(doc, 'details', 'page-section');
|
||||
associationSection.appendChild(el(doc, 'summary', 'section-title', '课程与考试主题关联'));
|
||||
const associationTitle = el(doc, 'input'); associationTitle.type = 'text';
|
||||
associationTitle.placeholder = '课程或考试的完整标题'; associationTitle.setAttribute('aria-label', '关联的完整标题');
|
||||
const associationCourses = el(doc, 'textarea');
|
||||
associationCourses.placeholder = '填写题库中的完整课程名,多个课程用换行或逗号分隔';
|
||||
associationCourses.setAttribute('aria-label', '关联题库课程');
|
||||
associationCourses.style.cssText = 'font:inherit;width:100%;min-height:70px;margin-top:7px;padding:8px;border:1px solid #cfd8e7;border-radius:7px;resize:vertical;color:#17253d;background:white;';
|
||||
const associationActions = el(doc, 'div', 'task-actions');
|
||||
const saveAssociation = el(doc, 'button', '', '保存关联'); saveAssociation.type = 'button';
|
||||
const deleteAssociation = el(doc, 'button', '', '删除关联'); deleteAssociation.type = 'button';
|
||||
associationActions.append(saveAssociation, deleteAssociation);
|
||||
const associationList = el(doc, 'p', 'theme-associations');
|
||||
associationSection.append(associationTitle, associationCourses, associationActions,
|
||||
el(doc, 'p', 'hint', '填写精确标题和题库课程名称。相似关键词只能帮助查找,不能授权自动答题。'), associationList);
|
||||
views.bank.appendChild(associationSection);
|
||||
let associationSignature = '';
|
||||
associationTitle.addEventListener('change', function () {
|
||||
associationCourses.value = ((state.learning.associations || {})[normalized(associationTitle.value)] || []).join('\n');
|
||||
});
|
||||
saveAssociation.addEventListener('click', function () {
|
||||
const title = normalized(associationTitle.value);
|
||||
const courses = Array.from(new Set(associationCourses.value.split(/[\n,,]+/).map(normalized).filter(Boolean)));
|
||||
if (!title || !courses.length) { setNotice('请填写完整标题及至少一个完整题库课程名。', 'warning'); return; }
|
||||
const knownCourses = (state.courses || []).map(function (course) { return typeof course === 'string' ? course : plain(course.value == null ? course.course || course.name : course.value); });
|
||||
const missing = courses.filter(function (course) { return !knownCourses.includes(course); });
|
||||
if (missing.length) { setNotice('题库中没有找到这些完整课程名:' + missing.join('、'), 'warning'); return; }
|
||||
const associations = Object.assign({}, state.learning.associations || {});
|
||||
associations[title] = courses; state.learning.associations = associations;
|
||||
invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已保存,将重新检查受影响的答题任务。', 'success');
|
||||
});
|
||||
deleteAssociation.addEventListener('click', function () {
|
||||
const title = normalized(associationTitle.value);
|
||||
if (!title || !Object.prototype.hasOwnProperty.call(state.learning.associations || {}, title)) { setNotice('该完整标题尚未保存关联。', 'warning'); return; }
|
||||
const associations = Object.assign({}, state.learning.associations || {});
|
||||
delete associations[title]; state.learning.associations = associations; associationCourses.value = '';
|
||||
invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success');
|
||||
});
|
||||
|
||||
views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台'));
|
||||
const logActions = el(doc, 'div', 'console-actions');
|
||||
const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别');
|
||||
[['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); });
|
||||
const copyLog = el(doc, 'button', '', '复制日志'); copyLog.type = 'button'; copyLog.addEventListener('click', function () { invoke('onLogAction', 'copy'); });
|
||||
const clearLog = el(doc, 'button', '', '清空显示'); clearLog.type = 'button'; clearLog.addEventListener('click', function () { invoke('onLogAction', 'clear'); });
|
||||
logActions.append(logFilter, copyLog, clearLog);
|
||||
const logCount = el(doc, 'p', 'hint'); const logList = el(doc, 'div', 'log-list');
|
||||
logList.setAttribute('data-testid', 'learning-logs');
|
||||
views.console.append(logActions, logCount, logList);
|
||||
logFilter.addEventListener('change', function () { refreshLearning(); });
|
||||
|
||||
const recoverySection = el(doc, 'div', 'page-section');
|
||||
recoverySection.appendChild(el(doc, 'h2', 'section-title', '异常恢复'));
|
||||
learningSetting(recoverySection, 'stallSeconds', '停滞检查(秒)', null, 60, [5, 600, 1]);
|
||||
learningSetting(recoverySection, 'retryDelaySeconds', '续播等待(秒)', null, 15, [1, 120, 1]);
|
||||
learningSetting(recoverySection, 'maxRefresh', '最多自动刷新', null, 3, [0, 10, 1]);
|
||||
recoverySection.appendChild(el(doc, 'p', 'hint', '主动暂停、课中题、等待平台确认、等待交卷和等待人工不会触发故障刷新。两次刷新至少间隔 30 秒。'));
|
||||
views.settings.append(recoverySection, el(doc, 'p', 'explanation', '切换标签页或最小化后尽量继续运行,回到前台会按网页实际状态核对。浏览器关闭、设备休眠或标签冻结期间无法保证继续;不会虚增平台学习时长。'));
|
||||
|
||||
function autoUnavailableReason() {
|
||||
if (!state.role) return '请选择岗位后再开启自动选择。';
|
||||
if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。';
|
||||
if (!(Number(state.bankTotal) > 0)) return '请先在题库搜索栏导入统一题库。';
|
||||
if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习或考试题目。';
|
||||
return '';
|
||||
}
|
||||
@@ -470,12 +637,119 @@
|
||||
});
|
||||
}
|
||||
|
||||
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
|
||||
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
|
||||
function firstValue(object, keys, fallback) {
|
||||
object = object || {};
|
||||
for (let i = 0; i < keys.length; i += 1) if (object[keys[i]] != null && object[keys[i]] !== '') return object[keys[i]];
|
||||
return fallback;
|
||||
}
|
||||
function duration(value) {
|
||||
if (value == null || value === '' || !Number.isFinite(Number(value))) return '待确认';
|
||||
const seconds = Math.max(0, Math.ceil(Number(value)));
|
||||
return (seconds >= 3600 ? Math.floor(seconds / 3600) + ':' : '') + String(Math.floor(seconds / 60) % 60).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
|
||||
}
|
||||
function infoRows(container, entries) {
|
||||
container.replaceChildren();
|
||||
entries.forEach(function (entry) { container.append(el(doc, 'dt', '', entry[0]), el(doc, 'dd', '', entry[1])); });
|
||||
}
|
||||
let taskSignature = '', logSignature = '';
|
||||
function refreshLearning() {
|
||||
const learning = state.learning || {};
|
||||
const video = learning.video || {}, exam = learning.exam || {};
|
||||
const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0);
|
||||
const paused = ['paused', 'stopped'].includes(learning.status);
|
||||
const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle');
|
||||
setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : ''));
|
||||
setText(globalPause, paused ? '继续全部' : '暂停全部');
|
||||
globalPause.disabled = !active && !paused;
|
||||
globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total);
|
||||
setText(globalProgressLabel, completed + ' / ' + total + ' 已完成');
|
||||
const videoTitle = plain(firstValue(video, ['title', 'course', 'name'], '暂无视频任务'));
|
||||
const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务'));
|
||||
const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle');
|
||||
setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : ''));
|
||||
setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : ''));
|
||||
collapseSummary.hidden = Boolean(state.expanded);
|
||||
setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。');
|
||||
Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); });
|
||||
startTask.disabled = active;
|
||||
resumeTask.disabled = !paused && !['waiting', 'waitingHuman'].includes(learning.status);
|
||||
stopTask.disabled = !active && !paused;
|
||||
setText(videoSummary, '视频 · ' + videoState + '\n' + videoTitle);
|
||||
setText(examSummary, '考试 · ' + examState + '\n' + examTitle);
|
||||
const tasks = Array.isArray(learning.tasks) ? learning.tasks : [];
|
||||
const taskNextSignature = JSON.stringify(tasks);
|
||||
if (taskNextSignature !== taskSignature || !taskList.childElementCount) {
|
||||
taskSignature = taskNextSignature; taskList.replaceChildren();
|
||||
if (!tasks.length) taskList.appendChild(el(doc, 'p', 'empty', '尚未读取到任务。完成状态以平台记录为准,跳过项目不会计为已完成。'));
|
||||
tasks.forEach(function (task) {
|
||||
const row = el(doc, 'article', 'task-row');
|
||||
row.append(el(doc, 'p', 'task-name', firstValue(task, ['title', 'name', 'course'], '未命名任务')),
|
||||
el(doc, 'div', 'task-state', ([(task.kind || task.type) === 'video' ? '视频' : (task.kind || task.type) === 'exam' ? '考试' : (task.kind || task.type) === 'course' ? '课程' : task.kind || task.type, statusName(task.status), task.message || task.reason].filter(Boolean)).join(' · ')));
|
||||
taskList.appendChild(row);
|
||||
});
|
||||
}
|
||||
Object.keys(learningControls).forEach(function (key) {
|
||||
const setting = learningControls[key];
|
||||
if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]);
|
||||
});
|
||||
const videoEnabled = learning.mode !== 'exam';
|
||||
setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled;
|
||||
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
|
||||
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
|
||||
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
|
||||
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
|
||||
infoRows(videoInfo, [['当前课程', videoTitle], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
|
||||
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
|
||||
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
|
||||
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
|
||||
setText(videoMessage, video.message || video.reason || '等待视频任务。播放结束与平台确认完成分别记录。');
|
||||
const videoHasTask = Boolean(video.title || video.name || video.course || video.taskId || video.id);
|
||||
videoPause.disabled = !videoHasTask || ['paused', 'completed', 'done', 'idle'].includes(video.status || 'idle');
|
||||
videoResume.disabled = !videoHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(video.status);
|
||||
infoRows(examInfo, [['考试主题', examTitle], ['题库预检', plain(firstValue(exam, ['preflightMessage', 'themeStatus', 'preflight'], '尚未预检'))],
|
||||
['预计得分', firstValue(exam, ['predictedScore', 'score'], null) == null ? '待核算' : plain(firstValue(exam, ['predictedScore', 'score'], 0)) + ' 分(题库估计)'],
|
||||
['网页剩余', duration(firstValue(exam, ['remainingSeconds', 'remainingTime'], null))], ['进入试卷', duration(firstValue(exam, ['elapsedSeconds', 'elapsedTime'], null))],
|
||||
['最早交卷', duration(firstValue(exam, ['submitWaitSeconds', 'waitSeconds'], null))], ['交卷状态', statusName(exam.status || 'idle')]]);
|
||||
setText(examMessage, exam.message || exam.reason || '先确认主题与题库对应。没有对应题库则跳过;不明确的答案等待人工。');
|
||||
const examHasTask = Boolean(exam.title || exam.name || exam.theme || exam.taskId || exam.id);
|
||||
examPause.disabled = !examHasTask || ['paused', 'submitted', 'completed', 'done', 'idle'].includes(exam.status || 'idle');
|
||||
examResume.disabled = !examHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(exam.status);
|
||||
tabs.video.setAttribute('data-alert', String(['waiting', 'waitingHuman', 'failed', 'error'].includes(video.status)));
|
||||
tabs.exam.setAttribute('data-alert', String(['waiting', 'waitingHuman', 'failed', 'error'].includes(exam.status)));
|
||||
const associations = learning.associations && typeof learning.associations === 'object' ? learning.associations : {};
|
||||
const nextAssociationSignature = JSON.stringify(associations);
|
||||
if (!associationTitle.value && learning.currentTitle && root.activeElement !== associationTitle) associationTitle.value = plain(learning.currentTitle);
|
||||
if (associationSignature !== nextAssociationSignature) {
|
||||
associationSignature = nextAssociationSignature;
|
||||
setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。');
|
||||
if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n');
|
||||
}
|
||||
const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500);
|
||||
const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; });
|
||||
const nextLogSignature = JSON.stringify([logFilter.value, logs]);
|
||||
if (nextLogSignature !== logSignature || !logList.childElementCount) {
|
||||
logSignature = nextLogSignature; logList.replaceChildren();
|
||||
if (!filtered.length) logList.appendChild(el(doc, 'p', 'empty', logs.length ? '该级别暂无日志。' : '暂无日志。清空显示不会删除任务检查点。'));
|
||||
filtered.forEach(function (entry) {
|
||||
const level = entry.level === 'warn' ? 'warning' : entry.level || 'info';
|
||||
const row = el(doc, 'div', 'log-entry ' + level);
|
||||
let time = plain(entry.time || '');
|
||||
if (typeof entry.time === 'number' || /^\d{4}-\d{2}-\d{2}T/.test(time)) { const date = new Date(entry.time); if (!Number.isNaN(date.getTime())) time = date.toLocaleTimeString('zh-CN', { hour12: false }); }
|
||||
row.append(el(doc, 'span', 'log-time', time), doc.createTextNode((entry.task ? '[' + plain(entry.task) + '] ' : '') + plain(entry.message)));
|
||||
logList.appendChild(row);
|
||||
});
|
||||
}
|
||||
setText(logCount, '显示 ' + filtered.length + ' 条 / 最近 ' + logs.length + ' 条;最多保留 500 条。');
|
||||
}
|
||||
|
||||
let courseSignature = '';
|
||||
function refresh() {
|
||||
if (destroyed) return;
|
||||
panel.classList.toggle('collapsed', !state.expanded);
|
||||
body.hidden = !state.expanded;
|
||||
brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
|
||||
brand.textContent = '谊安学助手 2.0';
|
||||
expand.textContent = state.expanded ? '收起' : '展开';
|
||||
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
|
||||
expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
|
||||
@@ -488,18 +762,13 @@
|
||||
autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading));
|
||||
autoSwitch.label.title = unavailable;
|
||||
copySwitch.input.checked = Boolean(state.allowCopy);
|
||||
answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭即可停止后续操作。' : '支持练习和考试,按题干和选项全文匹配答案;交卷由你操作。');
|
||||
bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : '');
|
||||
answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭会停止后续选择并取消自动交卷计划。' : '使用统一题库,按题干和选项全文匹配;练习页保留自动选择和答案核对。');
|
||||
bankInfo.textContent = state.loading ? '正在读取题库…' : '统一题库已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条来源记录';
|
||||
bankCounts.textContent = '题目 ' + Number(state.uniqueTotal || 0).toLocaleString('zh-CN') + ' 道 · 答案冲突 ' + Number(state.conflictTotal || 0).toLocaleString('zh-CN') + ' 道';
|
||||
bankInfo.classList.toggle('busy', Boolean(state.loading));
|
||||
importButton.disabled = Boolean(state.loading);
|
||||
storageInfo.textContent = state.storageMode === 'origin' ? '此模式仅在当前网站保存题库和设置。' : '';
|
||||
storageInfo.hidden = !storageInfo.textContent;
|
||||
ROLES.forEach(function (role) {
|
||||
roleInputs[role].checked = state.role === role;
|
||||
roleLabels[role].classList.toggle('active', state.role === role);
|
||||
const count = Number(state.roleCounts && state.roleCounts[role] || 0);
|
||||
roleCounters[role].textContent = count ? '(' + count.toLocaleString('zh-CN') + ')' : '';
|
||||
});
|
||||
const availableCourses = (state.courses || []).map(function (course) {
|
||||
return typeof course === 'string' ? { value: course, label: course } : { value: plain(course.value == null ? (course.course || course.name) : course.value), label: plain(course.label || course.name || course.course || course.value) };
|
||||
});
|
||||
@@ -513,7 +782,7 @@
|
||||
availableCourses.forEach(function (course) { const option = el(doc, 'option', '', course.label); option.value = course.value; courseSelect.appendChild(option); });
|
||||
}
|
||||
courseSelect.value = state.course || '';
|
||||
courseSelect.disabled = !state.role || Boolean(state.loading);
|
||||
courseSelect.disabled = Boolean(state.loading);
|
||||
searchButton.disabled = Boolean(state.loading);
|
||||
selectionButton.disabled = Boolean(state.loading);
|
||||
setText(pageTitle, state.pageMode === 'test' ? '当前考试' : state.pageMode === 'exercise' ? '当前练习' : '当前页面');
|
||||
@@ -524,6 +793,8 @@
|
||||
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
|
||||
previous.disabled = currentOffset === 0 || Boolean(state.loading);
|
||||
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
|
||||
refreshLearning();
|
||||
selectTab(views[state.activeTab] ? state.activeTab : 'overview', false);
|
||||
}
|
||||
|
||||
function optionsOf(item) {
|
||||
@@ -571,9 +842,9 @@
|
||||
const items = (result.items || []).slice(0, PAGE_SIZE);
|
||||
resultTotal = Number(result.total == null ? items.length : result.total) || 0;
|
||||
currentOffset = Math.max(0, Number(result.offset == null ? currentOffset : result.offset) || 0);
|
||||
resultsTitle.textContent = state.role ? '找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题 · 当前岗位:' + state.role : '先选择岗位,再搜索题目';
|
||||
resultsTitle.textContent = '统一题库 · 找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题';
|
||||
results.replaceChildren();
|
||||
if (!items.length) results.appendChild(el(doc, 'div', 'empty', state.role ? '当前岗位未找到题目,可调整关键词、课程或切换岗位。' : '选择主操、班长或副操,导入题库后即可查找答案。'));
|
||||
if (!items.length) results.appendChild(el(doc, 'div', 'empty', Number(state.bankTotal) > 0 ? '未找到题目,可调整关键词或课程筛选。' : '导入统一题库后,输入题干、关键词或选项文字查找答案。'));
|
||||
items.forEach(function (item) {
|
||||
const card = el(doc, 'article', 'result');
|
||||
const title = el(doc, 'p', 'question');
|
||||
@@ -618,6 +889,9 @@
|
||||
lastPositionInput = signature;
|
||||
delete update.position;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(update, 'learning')) {
|
||||
update.learning = Object.assign({}, state.learning || {}, update.learning || {});
|
||||
}
|
||||
Object.assign(state, update);
|
||||
refresh();
|
||||
if (acceptPosition) {
|
||||
|
||||
Reference in New Issue
Block a user