设置面板 UI (进度/日志/导出/黑名单) — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。
بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.sleazyfork.org/scripts/581110/1842609/JM%20Shelf%20-%20UI%20Panel.js
// ==UserScript==
// @name JM Shelf - UI Panel
// @namespace jmshelf-lib
// @version 1.0.0
// @author Kesdi
// @description 设置面板 UI (进度/日志/导出/黑名单) — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。
// @license MIT
// ==/UserScript==
//
// 此文件是 GreasyFork 库(library),不直接安装。
// 请安装主脚本: JM Shelf 给杂鱼的个性化推荐
//
// ═══ [12] UI PANEL ═══
// ============================================================
function createPanel() {
const existing = document.getElementById('jms-panel');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.id = 'jms-panel';
panel.innerHTML = `
<div id="jms-panel-header">
<span>JM Shelf</span>
<button id="jms-panel-close-x">×</button>
</div>
<div id="jms-panel-body">
<div id="jms-status">就绪</div>
<div id="jms-progress-container" style="display:none">
<div id="jms-progress-bar"><div id="jms-progress-fill"></div></div>
<div id="jms-progress-text"></div>
</div>
<div id="jms-stats"></div>
<div id="jms-actions" style="display:grid;grid-template-columns:1fr 1fr;gap:4px">
<button id="jms-btn-scan" style="padding:4px 8px;background:#1a5c2a;color:#ddd;border:none;border-radius:2px;cursor:pointer;font-size:12px">🔄 全量扫描</button>
<button id="jms-btn-recalc" style="padding:4px 8px;background:#1a3f6b;color:#ddd;border:none;border-radius:2px;cursor:pointer;font-size:12px">🧮 重新计算推荐</button>
<button id="jms-btn-clear-recs" style="padding:4px 8px;background:#6b1a1a;color:#ddd;border:none;border-radius:2px;cursor:pointer;font-size:11px">🗑️ 清除推荐缓存</button>
<button id="jms-btn-clear-viewed" style="padding:4px 8px;background:#6b1a1a;color:#ddd;border:none;border-radius:2px;cursor:pointer;font-size:11px">🧹 清空浏览+爱心</button>
</div>
<details id="jms-history-details">
<summary>📜 历史记录</summary>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin:8px 0">
<button id="jms-btn-show-viewed" style="padding:4px 8px;background:#333;color:#ddd;border:1px solid #666;border-radius:4px;cursor:pointer;font-size:11px;width:100%">📖 浏览 <span id="jms-viewed-count">0</span></button>
<button id="jms-btn-show-liked" style="padding:4px 8px;background:#333;color:#ddd;border:1px solid #666;border-radius:4px;cursor:pointer;font-size:11px;width:100%">❤️ 爱心 <span id="jms-liked-count">0</span></button>
</div>
<div id="jms-viewed-list" style="display:none;max-height:200px;overflow-y:auto;background:#1a1a1a;color:#ccc;font:11px monospace;padding:8px;border-radius:4px;margin-bottom:6px;white-space:pre-wrap;word-break:break-all"></div>
<div id="jms-liked-list" style="display:none;max-height:200px;overflow-y:auto;background:#1a1a1a;color:#ccc;font:11px monospace;padding:8px;border-radius:4px;margin-bottom:6px;white-space:pre-wrap;word-break:break-all"></div>
<div style="display:none">
<button id="jms-btn-export" style="flex:1;padding:3px 8px;background:#2a2a3e;color:#aac;border:1px solid #446;border-radius:4px;cursor:pointer;font-size:10px">📤 导出数据</button>
<button id="jms-btn-import" style="flex:1;padding:3px 8px;background:#2a2a3e;color:#aac;border:1px solid #446;border-radius:4px;cursor:pointer;font-size:10px">📥 导入数据</button>
</div>
<input type="file" id="jms-import-file" accept=".json" style="display:none">
</details>
<details id="jms-settings-details">
<summary>⚙️ 设置</summary>
<div id="jms-settings" style="padding:8px 0">
<label style="display:block;margin-bottom:8px;color:#aaa;font-size:11px">🎲 推荐随机度(T): <span id="jms-temp-val">${CONFIG.TEMPERATURE.toFixed(2)}</span></label>
<div style="display:flex;gap:6px;margin-bottom:12px">
<input id="jms-temp-slider" type="range" min="0" max="1" step="0.05" value="${CONFIG.TEMPERATURE}" style="flex:1;accent-color:#2196f3">
<span style="font-size:9px;color:#666;width:90px">T=0原序 | T=0.3默认 | T=1全洗牌</span>
</div>
<div style="border-top:1px solid #333;margin:12px 0 8px;padding-top:8px"><div style="font-size:11px;color:#aaa;margin-bottom:6px">📦 数据迁移</div><div style="display:flex;gap:6px;margin-bottom:12px"><button id="jms-btn-export" style="flex:1;padding:3px 8px;background:#2a2a3e;color:#aac;border:1px solid #446;border-radius:4px;cursor:pointer;font-size:10px">📤 导出全部</button><button id="jms-btn-import" style="flex:1;padding:3px 8px;background:#2a2a3e;color:#aac;border:1px solid #446;border-radius:4px;cursor:pointer;font-size:10px">📥 导入合并</button></div><input type="file" id="jms-import-file" accept=".json" style="display:none"></div><div style="margin-bottom:6px;font-size:11px;color:#aaa">硬黑名单 (含这些标签的本子直接排除):</div>
<div style="display:flex;gap:4px;margin-bottom:8px">
<input id="jms-hard-input" placeholder="输入标签, 逗号分隔" size="30" style="flex:1;padding:4px 6px;background:#1a1a2e;border:1px solid #444;color:#ddd;border-radius:3px;font-size:11px">
<button id="jms-hard-save" style="padding:4px 8px;background:#633;color:#faa;border:1px solid #844;border-radius:3px;cursor:pointer;font-size:10px">保存</button>
</div>
<div style="margin-bottom:6px;font-size:11px;color:#aaa">软黑名单 (标签不参与加分, 但本子仍可推荐):</div>
<div style="display:flex;gap:4px">
<input id="jms-soft-input" placeholder="输入标签, 逗号分隔" size="30" style="flex:1;padding:4px 6px;background:#1a1a2e;border:1px solid #444;color:#ddd;border-radius:3px;font-size:11px">
<button id="jms-soft-save" style="padding:4px 8px;background:#633;color:#faa;border:1px solid #844;border-radius:3px;cursor:pointer;font-size:10px">保存</button>
</div>
</div>
</details>
<details id="jms-log-details">
<summary>📋 运行日志</summary>
<div id="jms-log-view" style="max-height:200px;overflow-y:auto;background:#111;color:#0f0;font:11px monospace;padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">点击刷新查看...</div>
<button id="jms-btn-refresh-log" style="margin-top:6px;padding:4px 10px;font-size:11px;background:#333;color:#aaa;border:1px solid #555;border-radius:4px;cursor:pointer">🔄 刷新日志</button>
<button id="jms-btn-copy-log" style="margin-top:6px;margin-left:4px;padding:4px 10px;font-size:11px;background:#333;color:#aaa;border:1px solid #555;border-radius:4px;cursor:pointer">📋 复制</button>
<button id="jms-btn-clean-log" style="margin-top:6px;margin-left:4px;padding:4px 10px;font-size:11px;background:#433;color:#faa;border:1px solid #855;border-radius:4px;cursor:pointer">🧹 清理+摘要</button>
</details>
</div>
`;
document.body.appendChild(panel);
// Event handlers
document.getElementById('jms-btn-scan').addEventListener('click', runFullScan);
document.getElementById('jms-btn-recalc').addEventListener('click', async () => {
const panel = document.getElementById('jms-panel');
if (panel) panel.style.display = 'block';
updateProgress({ phase: 'recalc', progress: 10, message: '🔄 正在重新计算...' });
await recalcRecommendations();
updateProgress({ phase: 'recalc', progress: 90, message: '📊 正在重排推荐...' });
await new Promise(r => setTimeout(r, 50));
_cachedReranked = lightRescore();
_lastLightRescore = Date.now();
if (_cachedReranked.length > 0) {
await new Promise(r => setTimeout(r, 0));
injectRecommendations(_cachedReranked);
}
updateProgress({ phase: 'recalc', progress: 100, message: '✅ 计算完成, 推荐已更新!' });
setTimeout(() => updateProgress(null), 3000);
});
document.getElementById('jms-btn-clear-recs').addEventListener('click', () => {
if (confirm('确定清除推荐缓存?这将重置所有推荐数据。')) { clearAllCache(); location.reload(); }
});
document.getElementById('jms-btn-clear-viewed').addEventListener('click', () => {
const vn = State.getViewedAlbums().length, ln = State.getLikedAlbums().length;
if (confirm('确定清空 ' + vn + ' 条浏览 + ' + ln + ' 条爱心吗?')) {
State.clearViewedAlbums(); State.saveLikedAlbums([]);
showNotification('已清空 ' + vn + '浏览 + ' + ln + '爱心');
}
});
const closeX = document.getElementById('jms-panel-close-x');
if (closeX) {
closeX.addEventListener('click', () => {
document.getElementById('jms-panel').style.display = 'none';
});
}
// 浏览记录查看
const showViewedBtn = document.getElementById('jms-btn-show-viewed');
const viewedListDiv = document.getElementById('jms-viewed-list');
const viewedCountSpan = document.getElementById('jms-viewed-count');
if (viewedCountSpan) viewedCountSpan.textContent = State.getViewedAlbums().length;
if (showViewedBtn && viewedListDiv) {
showViewedBtn.addEventListener('click', () => {
const viewed = State.getViewedAlbums();
if (viewedCountSpan) viewedCountSpan.textContent = viewed.length;
if (viewed.length === 0) { viewedListDiv.style.display = 'block'; viewedListDiv.textContent = '(空)'; return; }
const cache = State.getAlbumCache();
const favsMap = {};
for (const f of State.getFavorites()) favsMap[String(f.id)] = f;
const histMap = {};
for (const h of (State.getHistory() || [])) histMap[String(h.id)] = h;
const lines = viewed.slice(-200).reverse().map((v, i) => {
const vid = typeof v === 'string' ? v : v.id;
const al = cache[String(vid)] || favsMap[String(vid)] || histMap[String(vid)];
const title = al ? (al.title || '').substring(0, 35) : '(未缓存)';
const ts = (v.viewedAt) ? new Date(v.viewedAt).toLocaleString('zh-CN').slice(5) : '';
return `${(i+1).toString().padStart(3)}. [${vid}] ${ts ? ts+' ' : ''}${title}`;
});
const likedDiv = document.getElementById('jms-liked-list');
if (likedDiv) likedDiv.style.display = 'none';
viewedListDiv.style.display = viewedListDiv.style.display === 'none' ? 'block' : 'none';
viewedListDiv.textContent = `共 ${viewed.length} 条 (最近200):\n\n${lines.join('\n')}`;
});
}
// 爱心记录查看
const showLikedBtn = document.getElementById('jms-btn-show-liked');
const likedListDiv = document.getElementById('jms-liked-list');
const likedCountSpan = document.getElementById('jms-liked-count');
if (likedCountSpan) likedCountSpan.textContent = State.getLikedAlbums().length;
if (showLikedBtn && likedListDiv) {
showLikedBtn.addEventListener('click', () => {
const liked = State.getLikedAlbums();
if (likedCountSpan) likedCountSpan.textContent = liked.length;
if (liked.length === 0) { likedListDiv.style.display = 'block'; likedListDiv.textContent = '(空)'; return; }
const cache = State.getAlbumCache();
const lines = liked.map((f, i) => {
const al = cache[String(f.id)];
const title = al ? (al.title || f.title || '').substring(0, 35) : (f.title || '').substring(0,35) || '(未缓存)';
const ts = f.likedAt ? new Date(f.likedAt).toLocaleString('zh-CN').slice(5) : '';
return `${(i+1).toString().padStart(3)}. [${f.id}] ${ts ? ts+' ' : ''}${title}`;
});
const viewedDiv = document.getElementById('jms-viewed-list');
if (viewedDiv) viewedDiv.style.display = 'none';
likedListDiv.style.display = likedListDiv.style.display === 'none' ? 'block' : 'none';
likedListDiv.textContent = `共 ${liked.length} 条:\n\n${lines.join('\n')}`;
const uncachedL = liked.filter(f => (!cache[String(f.id)]||!cache[String(f.id)].title)).slice(0, 30);
if (uncachedL.length > 0) {
(async () => {
for (const f of uncachedL) {
try {
const h = await fetcher.enqueue('https://18comic.vip/album/'+f.id+'/', null, 1);
if (h) { const d = Parser.parseDetail(h); if (d.title) cache[String(f.id)] = { ...(cache[String(f.id)]||{}), id:f.id, title:d.title, tags:d.tags||[], authors:d.authors||[], typeTags:d.typeTags||[] }; }
} catch(e) {}
}
State.saveAlbumCache(cache);
const lines2 = liked.map((f,i) => { const al=cache[String(f.id)]; const t=al?(al.title||'').substring(0,35):(f.title||'').substring(0,35)||'('+f.id+')'; const ts=f.likedAt?new Date(f.likedAt).toLocaleString('zh-CN').slice(5):''; return String(i+1).padStart(3)+'. ['+f.id+'] '+(ts?ts+' ':'')+t; });
likedListDiv.textContent = lines2.join('\n');
})();
}
});
}
// 日志按钮
const refreshLogBtn = document.getElementById('jms-btn-refresh-log');
if (refreshLogBtn) {
refreshLogBtn.addEventListener('click', () => {
const logView = document.getElementById('jms-log-view');
if (!logView) return;
const logs = LOG.getLogs();
if (logs.length === 0) { logView.textContent = '(暂无日志)'; return; }
const levelMap = { I: ' ', W: '⚠', E: '❌', D: '·', X: '⚡' };
logView.textContent = logs.map(e => {
const d = new Date(e.t);
const ts = d.toLocaleTimeString('zh-CN', { hour12: false });
return `${levelMap[e.l]||' '} ${ts} ${e.m}`;
}).join('\n');
logView.scrollTop = logView.scrollHeight;
});
}
const copyLogBtn = document.getElementById('jms-btn-copy-log');
if (copyLogBtn) {
copyLogBtn.addEventListener('click', () => {
const logView = document.getElementById('jms-log-view');
if (!logView || !logView.textContent) return;
navigator.clipboard.writeText(logView.textContent).then(() => showNotification('✅ 已复制')).catch(() => showNotification('❌ 复制失败'));
});
}
const cleanLogBtn = document.getElementById('jms-btn-clean-log');
if (cleanLogBtn) {
cleanLogBtn.addEventListener('click', () => {
LOG.clearLogs();
const profile = State.getProfile();
const recs = _cachedReranked && _cachedReranked.length > 0 ? _cachedReranked : State.getRecommendations();
const albumCache = State.getAlbumCache();
LOG.info('──────────────');
LOG.info('📊 摘要 (清理后)');
const favs2 = State.getFavorites();
LOG.info(`📌 收藏/喜欢${favs2.length}本: [${favs2.map(f=>f.id).join(',')}]`);
if (profile && profile.tags) {
const tt = ProfileManager.getTopTags(profile, 20);
LOG.info(`画像标签: ${tt.map(t => t+':'+(profile.tags[t]||0).toFixed(1)).join(', ')}`);
}
if (recs.length > 0) {
const topN = Math.min(25, recs.length);
LOG.info(`═══ 推荐 TOP ${topN} ═══`);
for (let i = 0; i < topN; i++) {
const r = recs[i];
const al = albumCache[r.id];
const wm = r._wm || 'def';
const flag = wm === 'tag+' ? '🏷️' : wm === 'author+' ? '✏️' : wm === 'surprise' ? '🎲' : ' ';
const tags = (al?.tags||[]).slice(0, 5).join(',');
const title = (al?.title||'').substring(0, 40);
LOG.info(`${flag} #${(i+1).toString().padStart(2)} [${r.id}] ${r.score.toFixed(3)} t:${r.breakdown?.tag||'?'} ${tags} ${title}`);
}
}
LOG.info(`📈 候选池: ${(State.getCandidates()||[]).length} | 推荐: ${recs.length} | 已看: ${State.getViewedAlbums().length}`);
LOG.info('──────────────');
const lv = document.getElementById('jms-log-view');
if (lv) {
const logs = LOG.getLogs();
lv.textContent = logs.map(e => {
const d = new Date(e.t);
return `${d.toLocaleTimeString('zh-CN',{hour12:false})} ${e.m}`;
}).join('\n');
lv.scrollTop = 0;
}
});
}
// 导出/导入
const exportBtn = document.getElementById('jms-btn-export');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
const data = {
version: CONFIG.DATA_VERSION,
config: { temperature: CONFIG.TEMPERATURE, hardBlacklist: CONFIG.TAG_HARD_BLACKLIST, softBlacklist: CONFIG.TAG_SOFT_BLACKLIST },
data: {
favorites: State.getFavorites().map(f => ({ id: String(f.id), title: f.title || '' })),
liked: State.getLikedAlbums().map(f => ({ id: String(f.id), likedAt: f.likedAt || 0 })),
viewed: State.getViewedAlbums().map(v => typeof v === 'string' ? { id: v, viewedAt: Date.now() } : { id: v.id, viewedAt: v.viewedAt || Date.now() }),
},
exportedAt: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `jmshelf-${State.getUsername()||'unknown'}-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
showNotification(`📤 导出: ${data.data.favorites.length}收藏 ${data.data.liked.length}爱心 ${data.data.viewed.length}浏览`);
});
}
const importBtn = document.getElementById('jms-btn-import');
const importFile = document.getElementById('jms-import-file');
if (importBtn && importFile) {
importBtn.addEventListener('click', () => importFile.click());
importFile.addEventListener('change', () => {
const file = importFile.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
if (!data.data && (!data.liked || !data.viewed)) { showNotification('❌ 无效的备份文件'); return; }
if (data.config && data.config.temperature != null) {
CONFIG.TEMPERATURE = data.config.temperature;
Storage.set('temperature', CONFIG.TEMPERATURE);
}
if (data.data && data.data.favorites) {
const favs = State.getFavorites();
const favIds = new Set(favs.map(f => String(f.id)));
for (const item of data.data.favorites) {
if (!favIds.has(String(item.id))) { favs.push({ id: String(item.id), title: item.title || '' }); favIds.add(String(item.id)); }
}
State.saveFavorites(favs);
}
const liked = State.getLikedAlbums();
const likedSrc = data.data ? data.data.liked : data.liked;
const likedIds = new Set(liked.map(f => String(f.id)));
let addedL = 0;
for (const item of (likedSrc||[])) {
const id = typeof item === 'object' ? String(item.id) : String(item);
const likedAt = typeof item === 'object' ? (item.likedAt || 0) : 0;
if (!likedIds.has(id)) { liked.push({ id, likedAt }); likedIds.add(id); addedL++; }
}
State.saveLikedAlbums(liked);
const viewed = State.getViewedAlbums();
const viewedSrc = data.data ? data.data.viewed : data.viewed;
const viewedSet = new Set(viewed.map(String));
let addedV = 0;
for (const id of (viewedSrc||[])) {
if (!viewedSet.has(String(id))) { viewed.push(String(id)); viewedSet.add(String(id)); addedV++; }
}
State.saveViewedAlbums(viewed);
showNotification(`📥 导入: +${addedL}爱心 +${addedV}浏览`);
} catch(e) { showNotification('❌ 文件解析失败'); }
importFile.value = '';
};
reader.readAsText(file);
});
}
// 黑名单设置
const hardInput = document.getElementById('jms-hard-input');
const softInput = document.getElementById('jms-soft-input');
if (hardInput) hardInput.value = CONFIG.TAG_HARD_BLACKLIST.join(',');
if (softInput) softInput.value = CONFIG.TAG_SOFT_BLACKLIST.join(',');
document.getElementById('jms-hard-save')?.addEventListener('click', () => {
const tags = (hardInput?.value||'').split(',').map(t=>normalizeTag(t.trim())).filter(Boolean);
CONFIG.TAG_HARD_BLACKLIST.length=0; CONFIG.TAG_HARD_BLACKLIST.push(...tags);
Storage.set('hard_bl',tags); showNotification('硬黑名单已更新: '+tags.join(',')+' | ⚠ 请全量扫描使设置生效');
});
document.getElementById('jms-soft-save')?.addEventListener('click', () => {
const tags = (softInput?.value||'').split(',').map(t=>normalizeTag(t.trim())).filter(Boolean);
CONFIG.TAG_SOFT_BLACKLIST.length=0; CONFIG.TAG_SOFT_BLACKLIST.push(...tags);
Storage.set('soft_bl',tags); showNotification('软黑名单已更新: '+tags.join(',')+' | ⚠ 请全量扫描使设置生效');
});
const savedHard = Storage.get('hard_bl');
if (savedHard) { CONFIG.TAG_HARD_BLACKLIST.length=0; CONFIG.TAG_HARD_BLACKLIST.push(...savedHard); if(hardInput)hardInput.value=savedHard.join(','); }
const savedSoft = Storage.get('soft_bl');
if (savedSoft) { CONFIG.TAG_SOFT_BLACKLIST.length=0; CONFIG.TAG_SOFT_BLACKLIST.push(...savedSoft); if(softInput)softInput.value=savedSoft.join(','); }
// 温度滑块
(() => {
const sl = panel.querySelector('#jms-temp-slider');
const vl = panel.querySelector('#jms-temp-val');
if (sl && vl) {
sl.addEventListener('input', () => {
CONFIG.TEMPERATURE = parseFloat(sl.value);
vl.textContent = CONFIG.TEMPERATURE.toFixed(2);
Storage.set('temperature', CONFIG.TEMPERATURE);
});
const savedTemp = Storage.get('temperature');
if (savedTemp != null) { CONFIG.TEMPERATURE = savedTemp; sl.value = savedTemp; vl.textContent = savedTemp.toFixed(2); }
}
})();
return panel;
}
// 手风琴
setTimeout(() => {
let _accLock = false;
document.querySelectorAll('#jms-history-details, #jms-settings-details, #jms-log-details').forEach(d => {
d.addEventListener('toggle', () => {
if (_accLock) return;
_accLock = true;
if (d.open) {
document.querySelectorAll('#jms-history-details, #jms-settings-details, #jms-log-details').forEach(o => {
if (o !== d) o.open = false;
});
}
setTimeout(() => { _accLock = false; }, 100);
});
});
}, 500);
function updateProgress(data) {
const container = document.getElementById('jms-progress-container');
const fill = document.getElementById('jms-progress-fill');
const text = document.getElementById('jms-progress-text');
const status = document.getElementById('jms-status');
if (data) {
container.style.display = 'block';
fill.style.width = Math.min(100, data.progress || 0) + '%';
text.textContent = data.message || '';
}
if (status && data?.message) { status.textContent = data.message; }
}
function updateStats(favorites, history, recommendations) {
const stats = document.getElementById('jms-stats');
if (!stats) return;
stats.innerHTML = `⭐ 收藏: ${favorites.length} 部 | 📖 历史: ${history.length} 部 | 🎯 推荐: ${recommendations.length} 条 `;
}
function showNotification(message, duration = 3000) {
const existing = document.getElementById('jms-notification');
if (existing) existing.remove();
const n = document.createElement('div');
n.id = 'jms-notification';
n.textContent = message;
document.body.appendChild(n);
setTimeout(() => n.remove(), duration);
}