liyifan202201outlook.com / Gen-CF-RMJ 记录

// ==UserScript==
// @name         Gen-CF-RMJ 记录
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  在洛谷提交记录页显示 Codeforces 提交记录,支持无限缓存、精准刷新、搜索筛选、正确链接与智能动画
// @author       liyifan202201
// @match        https://www.luogu.com.cn/record/list*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      codeforces.com
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';
    if (!window.location.href.startsWith('https://www.luogu.com.cn/record/list')) {
        return;
    }
  // --- 变量声明 ---
  const mainContainerSelector = 'main > div > div';
  let cfButton = null;
  let cfContent = null;
  let refreshTimer = null;
  let searchFilter = '';
  let cachedSubmissions = [];
  let displayedSubmissionIds = new Set(); // 跟踪已渲染的提交 ID
  let isProcessingPid = false;

  // --- 样式注入 ---
  const style = document.createElement('style');
  style.textContent = `
      .cf-card {
        display: flex;
        align-items: center;
        font-size: 16px;
        padding: 10px 0;
        border-bottom: 1px solid #eee;
      }
      .cf-avatar img {
        width: 36px;
        height: 36px;
        border-radius: 50%;
        margin-right: 12px;
      }
      .cf-nameblock {
        display: flex;
        flex-direction: column;
        width: 180px;
        margin-right: 12px;
      }
      .cf-name {
        font-weight: bold;
        color: #333;
        font-size: 16px;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .cf-time {
        font-size: 14px;
        color: #888;
        line-height: 1.2;
        margin-top: 2px;
        white-space: nowrap;
      }
      .cf-status {
        flex: 0 0 auto;
        margin-right: 12px;
        width: 250px;
        text-align: left;
      }
      .cf-status span {
        border-radius: 2px;
        padding: 4px 8px;
        font-size: 14px;
        font-weight: 600;
        white-space: nowrap;
        display: inline-block;
        max-width: 100%;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .cf-status-accepted { background: rgb(82, 196, 26); color: #fff; }
      .cf-status-wrong { background: rgb(231, 76, 60); color: #fff; }
      .cf-status-re { background: rgb(157, 61, 207); color: #fff; }
      .cf-status-other { background: rgb(243, 156, 17); color: #fff; }
      .cf-status-tle { background: rgb(5, 34, 66); color: #fff; }
      .cf-status-ce { background: rgb(250, 219, 20); color: #fff; }
      .cf-status-running { background: #888; color: #fff; }
      .cf-problem {
        flex: 1;
        color: rgb(52, 152, 219);
        font-size: 16px;
        text-align: left;
        min-width: 100px;
        margin-left: 60px;
      }
      .cf-error {
        padding: 20px;
        background: #fee;
        color: #900;
        border-radius: 6px;
        text-align: center;
      }
      .cf-loading {
        padding: 40px;
        text-align: center;
        color: #999;
      }
      .cf-login-warning {
        padding: 20px;
        background: #fff3cd;
        color: #856404;
        border: 1px solid #ffeaa7;
        border-radius: 6px;
        text-align: center;
        margin: 10px 0;
      }
      #luogu-cf-toggle-btn {
        padding: 8px 16px;
        border-radius: 4px;
        background: linear-gradient(45deg, #00bfff, #0095c7);
        color: white;
        font-size: 14px;
        border: none;
        cursor: pointer;
        transition: background 0.3s ease;
        flex-shrink: 0;
      }
      #luogu-cf-toggle-btn:hover {
        background: linear-gradient(45deg, #4DD0FF, #3CA2C8);
      }
      #cf-container {
        width: 100%;
      }
      .cf-search-info {
        padding: 10px;
        background: #f8f9fa;
        border-radius: 4px;
        margin: 10px 0;
        font-size: 14px;
        color: #666;
      }
      .cf-cache-indicator {
        display: inline-block;
        padding: 2px 6px;
        background: #e9ecef;
        border-radius: 3px;
        font-size: 12px;
        margin-left: 8px;
        color: #6c757d;
      }
    `;
  document.head.appendChild(style);

  // --- 缓存 ---
  const STORAGE_KEY = 'cf-submissions-cache';

  function saveToCache(submissions) {
    try {
      GM_setValue(STORAGE_KEY, JSON.stringify({
        timestamp: Date.now(),
        data: submissions
      }));
      console.log(`[CF] 缓存 ${submissions.length} 条记录`);
    }
    catch (e) {
      console.error('[CF] 缓存失败:', e);
    }
  }

  function loadFromCache() {
    try {
      const raw = GM_getValue(STORAGE_KEY, null);
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      // ✅ 从 data 字段读取
      const data = parsed.data || [];
      console.log(`[CF] 加载缓存 ${data.length} 条`);
      return data;
    }
    catch (e) {
      console.error('[CF] 加载缓存失败:', e);
      return [];
    }
  }

  // --- 工具函数 ---
  function getLuoguUserInfo() {
    const nameEl = document.querySelector('div.header:nth-child(1) span a span');
    const avatarEl = document.querySelector('#app .user-nav a img');
    return {
      name: nameEl ? nameEl.textContent.trim() : 'User',
      color: nameEl ? (nameEl.style.color || '#333') : '#333',
      avatar: avatarEl ? avatarEl.src : 'https://cdn.luogu.com.cn/upload/usericon/default.png'
    };
  }

  function isCodeforcesMode() {
    return new URLSearchParams(window.location.search).has('codeforces');
  }

  function convertCfTimeToUtc8(cfTimeStr) {
    if (!cfTimeStr) return 'Unknown Time';
    const parts = cfTimeStr.replace(/\//g, ' ').split(' ');
    if (parts.length < 4) return cfTimeStr;
    const [monthStr, dayStr, yearStr, timeStr] = parts;
    const months = {
      'Jan': 0,
      'Feb': 1,
      'Mar': 2,
      'Apr': 3,
      'May': 4,
      'Jun': 5,
      'Jul': 6,
      'Aug': 7,
      'Sep': 8,
      'Oct': 9,
      'Nov': 10,
      'Dec': 11
    };
    const month = months[monthStr];
    if (month === undefined) return cfTimeStr;
    const day = parseInt(dayStr, 10),
      year = parseInt(yearStr, 10);
    const [h, m] = timeStr.split(':').map(Number);
    if (isNaN(day) || isNaN(year) || isNaN(h) || isNaN(m)) return cfTimeStr;
    const utc = new Date(Date.UTC(year, month, day, h - 3, m));
    if (isNaN(utc.getTime())) return cfTimeStr;
    const utc8 = new Date(utc.getTime() + 8 * 60 * 60 * 1000);
    const y = utc8.getUTCFullYear();
    const mo = String(utc8.getUTCMonth() + 1).padStart(2, '0');
    const d = String(utc8.getUTCDate()).padStart(2, '0');
    const ho = String(utc8.getUTCHours()).padStart(2, '0');
    const mi = String(utc8.getUTCMinutes()).padStart(2, '0');
    return `${y}/${mo}/${d} ${ho}:${mi}`;
  }

  // --- DOM 操作 ---
  function removeCFIntegratedSpan() {
    const selectors = ['span', 'a.clear-filter', '.block > div > span', '.text.lfe-form-sz-small'];
    document.querySelectorAll('#app main section div section.block div').forEach(div => {
      selectors.forEach(sel => div.querySelectorAll(sel).forEach(el => el.remove()));
    });
  }

  function lockUserFilterInput() {
    const input = document.querySelector('#app main section div section:nth-child(1) div div:nth-child(2) input');
    if (input) {
      input.readOnly = true;
      input.style.backgroundColor = '#f5f5f5';
      input.style.cursor = 'not-allowed';
      input.title = '在CF模式下此筛选框已锁定';
    }
  }

  function createButton() {
    if (cfButton) return cfButton;
    cfButton = document.createElement('button');
    cfButton.id = 'luogu-cf-toggle-btn';
    cfButton.textContent = isCodeforcesMode() ? '返回洛谷记录' : '查看 Codeforces 记录';
    cfButton.style.marginLeft = '12px';
    cfButton.onclick = () => {
      const url = new URL(window.location);
      if (isCodeforcesMode()) {
        url.searchParams.delete('codeforces');
      }
      else {
        url.searchParams.set('codeforces', '1');
      }
      window.location.href = url.toString();
    };
    return cfButton;
  }

  function createCfRowFromData(sub, userInfo) {
    let statusClass = 'cf-status-other';
    const v = sub.verdict;
    if (/limit exceeded/i.test(v)) statusClass = 'cf-status-tle';
    else if (/Runtime error/i.test(v)) statusClass = 'cf-status-re';
    else if (/Compilation error/i.test(v)) statusClass = 'cf-status-ce';
    else if (/Running/i.test(v)) statusClass = 'cf-status-running';
    else if (/Accepted/i.test(v)) statusClass = 'cf-status-accepted';
    else if (/(Wrong|Error|Rejected|Failed)/i.test(v)) statusClass = 'cf-status-wrong';

    const card = document.createElement('div');
    card.className = 'cf-card';
    card.dataset.id = sub.id;

    const avatarDiv = document.createElement('div');
    avatarDiv.className = 'cf-avatar';
    const img = document.createElement('img');
    img.src = userInfo.avatar;
    img.alt = "Avatar";
    img.onerror = () => img.src = 'https://cdn.luogu.com.cn/upload/usericon/1.png';
    avatarDiv.appendChild(img);

    const nameBlock = document.createElement('div');
    nameBlock.className = 'cf-nameblock';
    const nameDiv = document.createElement('div');
    nameDiv.className = 'cf-name';
    nameDiv.style.color = userInfo.color;
    nameDiv.textContent = userInfo.name;
    const timeDiv = document.createElement('div');
    timeDiv.className = 'cf-time';
    timeDiv.textContent = sub.timeText;
    nameBlock.appendChild(nameDiv);
    nameBlock.appendChild(timeDiv);

    const statusDiv = document.createElement('div');
    statusDiv.className = 'cf-status';
    const link = document.createElement('a');
    link.href = sub.cfSubmissionLink;
    link.target = '_blank';
    link.rel = 'noopener noreferrer';
    const span = document.createElement('span');
    span.className = statusClass;
    span.textContent = v;
    link.appendChild(span);
    statusDiv.appendChild(link);

    const problemDiv = document.createElement('div');
    problemDiv.className = 'cf-problem';
    const probLink = document.createElement('a');
    probLink.href = sub.problemLink;
    probLink.textContent = sub.problemId;
    probLink.target = '_blank';
    probLink.rel = 'noopener noreferrer';
    problemDiv.appendChild(probLink);

    card.appendChild(avatarDiv);
    card.appendChild(nameBlock);
    card.appendChild(statusDiv);
    card.appendChild(problemDiv);
    return card;
  }

  // --- 渲染(带动画)---
  function renderCFFromCache(isRefresh = false) {
    if (!cfContent) return;

    const userInfo = getLuoguUserInfo();
    let filtered = cachedSubmissions;
    if (searchFilter) {
      const q = searchFilter.replace(/CF/gi, '').trim().toLowerCase();
      filtered = cachedSubmissions.filter(s => s.problemId.toLowerCase().includes(q));
    }

    // 首次加载:清空已显示记录,确保带动画
    if (!isRefresh) {
      displayedSubmissionIds.clear();
    }

    const frag = document.createDocumentFragment();

    const info = document.createElement('div');
    info.className = 'cf-search-info';
    if (searchFilter) {
      info.textContent = `搜索: "${searchFilter}",找到 ${filtered.length} 条记录`;
    }
    else {
      info.innerHTML = `显示 ${filtered.length} 条记录 <span class="cf-cache-indicator">缓存</span>`;
    }
    frag.appendChild(info);

    if (filtered.length === 0) {
      const err = document.createElement('div');
      err.className = 'cf-error';
      err.textContent = '没有找到匹配的提交记录';
      frag.appendChild(err);
    }
    else {
      let newCount = 0;
      filtered.forEach(sub => {
        const card = createCfRowFromData(sub, userInfo);

        if (!displayedSubmissionIds.has(sub.id)) {
          // 新提交:带动画
          card.style.opacity = '0';
          card.style.transform = 'translateY(-10px)';
          card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
          setTimeout(() => {
            card.style.opacity = '1';
            card.style.transform = 'translateY(0)';
          }, newCount * 50);
          newCount++;
          displayedSubmissionIds.add(sub.id);
        }
        else {
          // 已存在:直接显示(状态可能已更新)
          card.style.opacity = '1';
          card.style.transform = 'translateY(0)';
        }

        frag.appendChild(card);
      });
    }

    cfContent.innerHTML = '';
    cfContent.appendChild(frag);
  }

  // --- 搜索框绑定 ---
  function setupSearchFilter() {
    const input = document.querySelector('#app main section div section:nth-child(1) div div:nth-child(1) input');
    if (!input) return;

    input.placeholder = '筛选Codeforces题目...';
    const urlParams = new URLSearchParams(window.location.search);
    const pid = urlParams.get('pid');
    if (pid && isCodeforcesMode()) {
      input.value = pid;
      searchFilter = pid;
    }

    const original = input.oninput;
    input.oninput = (e) => {
      if (isProcessingPid) return;
      searchFilter = e.target.value.trim();
      const url = new URL(window.location);
      if (searchFilter) url.searchParams.set('pid', searchFilter);
      else url.searchParams.delete('pid');
      history.replaceState(null, '', url.toString());
      renderCFFromCache(true); // 刷新时保留已有动画状态
      if (original) original.call(input, e);
    };
  }

  // --- 网络请求 ---
  function fetchAndRenderCF(isInitial) {
    if (!cfContent) return;

    GM_xmlhttpRequest({
      method: 'GET',
      url: 'https://codeforces.com/problemset/status?my=on',
      onload(res) {
        if (res.finalUrl.includes('/enter') || res.responseText.includes('Enter')) {
          cfContent.innerHTML = `<div class="cf-login-warning"><strong>请登录 Codeforces</strong><br>检测到您未登录,请先登录</div>`;
          return;
        }
        if (res.status !== 200) {
          cfContent.innerHTML = `<div class="cf-error">加载失败: ${res.status}</div>`;
          return;
        }

        try {
          const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
          const rows = [...doc.querySelectorAll('.datatable tbody tr[data-submission-id]')]
            .filter(r => r.querySelector('.submissionVerdictWrapper')?.textContent?.trim());
          const fresh = rows.slice(0, 50).map(row => {
            const id = row.getAttribute('data-submission-id');
            const verdict = row.querySelector('.submissionVerdictWrapper')?.textContent?.trim() || 'Unknown';
            const rawTime = row.querySelector('td:nth-child(2)')?.textContent?.trim() || '';
            const problemId = row.querySelector('td:nth-child(4) a')?.textContent?.trim() || 'Unknown';
            let problemLink = '#';
            const m = problemId.match(/^(\d+)([A-Za-z][A-Za-z0-9]*)/);
            if (m) problemLink = `https://www.luogu.com.cn/problem/CF${m[1]}${m[2]}`;

            const cfSubmissionLink = id ? `https://codeforces.com/problemset/submission/${m[1]}/${id}` : '#';

            return {
              id,
              verdict,
              timeText: convertCfTimeToUtc8(rawTime),
              problemId,
              problemLink,
              cfSubmissionLink,
              rawTime
            };
          });

          const freshMap = new Map(fresh.map(s => [s.id, s]));
          const updated = [...fresh]; // 新的在前
          const seen = new Set(fresh.map(s => s.id));
          cachedSubmissions.forEach(s => {
            if (!seen.has(s.id)) {
              updated.push(s);
              seen.add(s.id);
            }
          });
          cachedSubmissions = updated;
          saveToCache(cachedSubmissions);
          renderCFFromCache(!isInitial);

        }
        catch (e) {
          console.error('[CF] 解析失败:', e);
          if (cfContent.children.length === 0) {
            cfContent.innerHTML = `<div class="cf-error">解析出错: ${e.message}</div>`;
          }
        }
      },
      onerror(err) {
        console.error('[CF] 网络错误:', err);
        if (cachedSubmissions.length > 0) renderCFFromCache(true);
        else if (cfContent) cfContent.innerHTML = `<div class="cf-error">网络错误</div>`;
      }
    });
  }

  // --- 模式切换 ---
  function switchToCodeforces() {
    const mainDiv = document.querySelector(mainContainerSelector);
    if (!mainDiv) return;

    cachedSubmissions = loadFromCache();
    cfContent = document.createElement('div');
    cfContent.id = 'cf-container';
    cfContent.innerHTML = '<div class="cf-loading">正在加载 Codeforces 提交记录...</div>';
    mainDiv.innerHTML = '';
    mainDiv.appendChild(cfContent);

    const btn = createButton();
    btn.textContent = '返回洛谷记录';
    const target = document.querySelector('main section > div > section > b:first-child');
    if (target && !target.parentNode.contains(btn)) {
      target.parentNode.insertBefore(btn, target.nextSibling || null);
    }

    removeCFIntegratedSpan();
    lockUserFilterInput();
    setupSearchFilter();

    if (cachedSubmissions.length > 0) {
      renderCFFromCache(false); // 首次加载带动画
    }

    fetchAndRenderCF(true);

    if (refreshTimer) clearInterval(refreshTimer);
    refreshTimer = setInterval(() => {
      if (isCodeforcesMode() && cfContent) {
        fetchAndRenderCF(false);
        removeCFIntegratedSpan();
      }
      else {
        clearInterval(refreshTimer);
      }
    }, 3000);
  }

  function switchToLuogu() {
    if (refreshTimer) clearInterval(refreshTimer);
    displayedSubmissionIds.clear();
    const url = new URL(window.location);
    url.searchParams.delete('codeforces');
    window.location.href = url.toString();
  }

  // --- URL 监听 ---
  function monitorUrlParams() {
    if (isProcessingPid) return;
    const pid = new URLSearchParams(window.location.search).get('pid');
    if (pid && isCodeforcesMode()) {
      isProcessingPid = true;
      const input = document.querySelector('#app main section div section:nth-child(1) div div:nth-child(1) input');
      if (input && input.value !== pid) {
        input.value = pid;
        searchFilter = pid;
        setTimeout(() => {
          if (cachedSubmissions.length > 0) renderCFFromCache(true);
          isProcessingPid = false;
        }, 100);
      }
      else {
        isProcessingPid = false;
      }
    }
  }

  function monitorUrlMode() {
    if (isCodeforcesMode()) {
      if (!cfContent) switchToCodeforces();
      removeCFIntegratedSpan();
      lockUserFilterInput();
      monitorUrlParams();
    }
    else {
      if (cfContent) switchToLuogu();
    }
  }

  // --- 初始化 ---
  function insertButton() {
    if (!cfButton) createButton();
    const target = document.querySelector('main section > div > section > b:first-child');
    if (target && cfButton && !target.parentNode.contains(cfButton)) {
      target.parentNode.insertBefore(cfButton, target.nextSibling || null);
    }
  }

  const observer = new MutationObserver(() => {
    insertButton();
    monitorUrlMode();
  });

  function start() {
    const main = document.querySelector('main');
    if (main) {
      observer.observe(main, {
        childList: true,
        subtree: true
      });
      setTimeout(() => {
        insertButton();
        monitorUrlMode();
      }, 100);
    }
    else {
      setTimeout(start, 500);
    }
  }

  window.addEventListener('popstate', () => {
    setTimeout(() => {
      monitorUrlMode();
      if (cfButton) cfButton.textContent = isCodeforcesMode() ? '返回洛谷记录' : '查看 Codeforces 记录';
    }, 0);
  });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  }
  else {
    start();
  }

})();