JM Shelf - UI Panel

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

Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.sleazyfork.org/scripts/581110/1842609/JM%20Shelf%20-%20UI%20Panel.js

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

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