Sleazy Fork is available in English.

JM Shelf - UI Panel

设置面板 UI (进度/日志/导出/黑名单) — JM Shelf 推荐脚本的模块库,通过 @require 被主脚本引用。

Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @require https://update.sleazyfork.org/scripts/581110/1842609/JM%20Shelf%20-%20UI%20Panel.js

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==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);
  }