feat: add draggable panel and restrict assistant to site directory

This commit is contained in:
薇星幻海
2026-10-12 14:19:31 +08:00
parent 3d7a1ffed0
commit 20abb33740
12 changed files with 968 additions and 77 deletions
+33 -7
View File
@@ -4,15 +4,17 @@
const core = window.QuestionBankCore;
const store = window.YianBankStore;
const matcher = window.YianAnswerMatcher;
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '' };
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '', panelPosition: null };
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
let loading = true, disposed = false, page = null, previous = null;
let loading = true, disposed = false, scopedOut = false, page = null, previous = null;
let searchQuery = '', runKey = '', debounceTimer;
const exported = new Set();
const settleMs = 1200;
const copy = window.YianCopyController.create();
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
let positionRevision = 0;
const inScope = () => YianSiteScope.isAllowedURL(location.href);
function visible(node) {
if (!node || node.closest('[hidden],.hidden')) return false;
@@ -59,7 +61,7 @@
const route = routeInfo();
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
const signature = route.root ? structureSignature(bank) : '';
return { ready: !!page && page.ready && !loading && page.key === route.key
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,
@@ -80,7 +82,7 @@
}
function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
exportEnabled: config.exportEnabled, role: config.role, course: config.course, courses: courses(),
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 || {}));
}
function performSearch(query = searchQuery, options = {}) {
@@ -111,7 +113,7 @@
refreshPanel(); tick(); return config.autoAnswer;
}
function setCopy(enabled) {
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
}
async function importFiles(files) {
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
@@ -125,6 +127,7 @@
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
}
function exportCurrent(format = 'csv') {
if (!inScope()) throw new Error('当前页面不在谊安学脚本生效范围内');
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
const route = routeInfo();
if (!route.practice) throw new Error('请先进入支持的练习页面');
@@ -144,6 +147,7 @@
}
const panel = window.YianAssistantPanel.create({
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
onPositionChange: position => { positionRevision++; config.panelPosition = position; saveSetting('panelPosition', position); },
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
onCourseChange: course => { config.course = course; performSearch(); },
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
@@ -154,6 +158,21 @@
function tick() {
if (disposed) return;
if (!inScope()) {
if (!scopedOut) {
scopedOut = true;
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
panel.host.style.setProperty('display', 'none', 'important');
previous = page; page = null; metrics = emptyMetrics();
}
return;
}
if (scopedOut) {
scopedOut = false;
panel.host.style.setProperty('display', 'block', 'important');
copy.setEnabled(config.allowCopy);
}
const route = routeInfo();
if (!route.root) {
if (runner.running) runner.stop('当前页面未识别到练习题目');
@@ -225,12 +244,19 @@
runner.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)]);
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] : '';
config.allowCopy = values[2] !== false;
const position = values[3];
// A drag during asynchronous storage loading wins over the older saved position.
if (positionRevision === positionRevisionAtRead) {
config.panelPosition = position && typeof position.left === 'number' && Number.isFinite(position.left)
&& typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null;
}
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
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 }),