跳到正文

【AI创作】Phigros歌曲数据记录工具

小编

这个工具可以用树状图记录歌曲的各项数据(分数、评级、Acc、定数),用起来觉得不错(要手动录入曲目)

  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Phigros 歌曲记录器</title>
  7. <style>
  8.   * { box-sizing: border-box; }
  9.   /* ===== 主题变量 ===== */
  10.   :root, [data-theme="light"] {
  11.     --bg: #ffffff;
  12.     --bg-card: #ffffff;
  13.     --bg-sub: #f7f7fb;
  14.     --bg-hover: #f0f0f6;
  15.     --bg-input: #ffffff;
  16.     --bg-input-dis: #f4f4f8;
  17.     --bg-modal: #ffffff;
  18.     --bg-overlay: rgba(30,30,40,0.35);
  19.     --text: #222233;
  20.     --text-strong: #000000;
  21.     --text-dim: #777;
  22.     --text-faint: #bbb;
  23.     --border: #e4e4ec;
  24.     --border-strong: #ddd;
  25.     --accent: #b8860b;
  26.     --accent-bg: #fdf8ec;
  27.     --danger: #d03a3a;
  28.     --danger-bg: #fde8e8;
  29.     --shadow: 0 12px 40px rgba(0,0,0,0.15);
  30.     --rank-phi-bg: #f0c040; --rank-phi-fg: #222;
  31.     --rank-fc-bg: #2f6fd0; --rank-fc-fg: #fff;
  32.     --rank-wv-bg: #fff; --rank-wv-fg: #222; --rank-wv-border: #ccc;
  33.     --rank-s-bg: #f0a030; --rank-s-fg: #222;
  34.     --rank-a-bg: #4caf50; --rank-a-fg: #fff;
  35.     --rank-b-bg: #4a90d9; --rank-b-fg: #fff;
  36.     --rank-c-bg: #c08060; --rank-c-fg: #fff;
  37.     --rank-f-bg: #ccc; --rank-f-fg: #777;
  38.     --rank-none-bg: #f0f0f5; --rank-none-fg: #bbb;
  39.     --lv-bg: #f0f0f5; --lv-fg: #444; --lv-border: #ddd; --lv-empty: #bbb;
  40.     --diff-ez: #2e9e4f;
  41.     --diff-hd: #2f6fd0;
  42.     --diff-in: #d03a3a;
  43.     --diff-at: #7a7a85;
  44.   }
  45.   [data-theme="dark"] {
  46.     --bg: #0f0f14;
  47.     --bg-card: #1a1a26;
  48.     --bg-sub: #16161f;
  49.     --bg-hover: #22223a;
  50.     --bg-input: #0f0f14;
  51.     --bg-input-dis: #1e1e2a;
  52.     --bg-modal: #1a1a26;
  53.     --bg-overlay: rgba(0,0,0,0.65);
  54.     --text: #e8e8f0;
  55.     --text-strong: #ffffff;
  56.     --text-dim: #8888aa;
  57.     --text-faint: #555;
  58.     --border: #333344;
  59.     --border-strong: #44445a;
  60.     --accent: #ffd966;
  61.     --accent-bg: #22222e;
  62.     --danger: #ff6b6b;
  63.     --danger-bg: #3a1a1a;
  64.     --shadow: 0 12px 40px rgba(0,0,0,0.6);
  65.     --rank-phi-bg: #ffd966; --rank-phi-fg: #000;
  66.     --rank-fc-bg: #4a6fd4; --rank-fc-fg: #fff;
  67.     --rank-wv-bg: #e8e8f0; --rank-wv-fg: #222; --rank-wv-border: #e8e8f0;
  68.     --rank-s-bg: #ffaa44; --rank-s-fg: #000;
  69.     --rank-a-bg: #66cc88; --rank-a-fg: #000;
  70.     --rank-b-bg: #6699cc; --rank-b-fg: #000;
  71.     --rank-c-bg: #cc8866; --rank-c-fg: #000;
  72.     --rank-f-bg: #444; --rank-f-fg: #aaa;
  73.     --rank-none-bg: #222; --rank-none-fg: #555;
  74.     --lv-bg: #2a2a40; --lv-fg: #ffd966; --lv-border: #44445a; --lv-empty: #444;
  75.     --diff-ez: #2e9e4f;
  76.     --diff-hd: #2f6fd0;
  77.     --diff-in: #d03a3a;
  78.     --diff-at: #7a7a85;
  79.   }
  80.   body {
  81.     font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  82.     background: var(--bg); color: var(--text);
  83.     margin: 0 auto; padding: 20px; max-width: 1080px;
  84.     transition: background 0.2s, color 0.2s;
  85.   }
  86.   h1 { font-size: 1.3rem; margin: 0 0 4px; color: var(--accent); }
  87.   .sub { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 18px; }
  88.   .toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; align-items: center; }
  89.   .toolbar button {
  90.     background: var(--bg-sub); color: var(--text); border: 1px solid var(--border-strong);
  91.     border-radius: 8px; padding: 8px 14px; font-size: 0.85rem; cursor: pointer;
  92.     transition: background 0.15s;
  93.   }
  94.   .toolbar button:hover { background: var(--bg-hover); }
  95.   .theme-toggle {
  96.     margin-left: auto; display: flex; align-items: center; gap: 6px;
  97.     background: var(--bg-sub); color: var(--text); border: 1px solid var(--border-strong);
  98.     border-radius: 999px; padding: 6px 14px; font-size: 0.82rem; cursor: pointer;
  99.     transition: background 0.15s;
  100.   }
  101.   .theme-toggle:hover { background: var(--bg-hover); }
  102.   .theme-toggle .icon { font-size: 1rem; }
  103.   /* 树状 */
  104.   .tree { user-select: none; }
  105.   .group { margin-bottom: 6px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg-card); }
  106.   .group-header {
  107.     display: flex; align-items: center; gap: 8px; padding: 10px 14px;
  108.     background: var(--bg-sub); cursor: pointer; font-weight: 700; font-size: 0.9rem; color: var(--text);
  109.   }
  110.   .group-header:hover { background: var(--bg-hover); }
  111.   .group-header .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; color: var(--text-faint); }
  112.   .group-header.open .arrow { transform: rotate(90deg); }
  113.   .group-body { display: none; padding: 4px 0 8px 0; }
  114.   .group-body.open { display: block; }
  115.   .chapter { margin: 2px 0; }
  116.   .chapter-header {
  117.     display: flex; align-items: center; gap: 6px; padding: 7px 14px 7px 30px;
  118.     font-size: 0.82rem; color: var(--text-dim); cursor: pointer;
  119.   }
  120.   .chapter-header:hover { color: var(--text-strong); background: var(--bg-hover); }
  121.   .chapter-header .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.6rem; color: var(--text-faint); }
  122.   .chapter-header.open .arrow { transform: rotate(90deg); }
  123.   .chapter-body { display: none; }
  124.   .chapter-body.open { display: block; }
  125.   .song-item { margin: 1px 0; }
  126.   .song-row {
  127.     display: flex; align-items: center; gap: 10px; padding: 7px 8px 7px 48px;
  128.     font-size: 0.82rem; cursor: pointer; border-left: 3px solid transparent;
  129.   }
  130.   .song-row:hover { background: var(--bg-hover); }
  131.   .song-row.active { border-left-color: var(--accent); background: var(--accent-bg); }
  132.   .song-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-strong); font-weight: 500; }
  133.   .diff-btns { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
  134.   .diff-btn-wrap { display: flex; align-items: center; gap: 3px; }
  135.   .diff-btn {
  136.     min-width: 36px; height: 24px; border: none; border-radius: 4px;
  137.     font-size: 0.7rem; font-weight: 800; cursor: pointer;
  138.     transition: opacity 0.15s, transform 0.1s; padding: 0 5px; color: #fff;
  139.   }
  140.   .diff-btn:hover { transform: scale(1.06); }
  141.   .diff-btn.selected { outline: 2px solid var(--text-strong); outline-offset: 1px; }
  142.   .diff-btn.EZ { background: var(--diff-ez); }
  143.   .diff-btn.HD { background: var(--diff-hd); }
  144.   .diff-btn.IN { background: var(--diff-in); }
  145.   .diff-btn.AT { background: var(--diff-at); }
  146.   .lv-tag {
  147.     display: inline-flex; align-items: center; justify-content: center;
  148.     height: 22px; min-width: 44px; padding: 0 7px;
  149.     border-radius: 6px; font-size: 0.72rem; font-weight: 800;
  150.     background: var(--lv-bg); color: var(--lv-fg); border: 1px solid var(--lv-border);
  151.     letter-spacing: 0.3px; white-space: nowrap;
  152.   }
  153.   .lv-tag.empty { background: transparent; color: var(--lv-empty); border: 1px dashed var(--lv-border); font-weight: 500; }
  154.   .song-del-btn {
  155.     flex-shrink: 0; width: 28px; height: 28px; border: none; border-radius: 6px;
  156.     background: transparent; color: var(--text-faint); font-size: 0.95rem; cursor: pointer;
  157.     display: flex; align-items: center; justify-content: center;
  158.     transition: background 0.15s, color 0.15s;
  159.   }
  160.   .song-del-btn:hover { background: var(--danger-bg); color: var(--danger); }
  161.   /* 遮罩 + 弹窗 */
  162.   .overlay {
  163.     position: fixed; inset: 0; background: var(--bg-overlay);
  164.     display: none; align-items: center; justify-content: center;
  165.     z-index: 100; padding: 20px;
  166.   }
  167.   .overlay.open { display: flex; }
  168.   .modal {
  169.     background: var(--bg-modal); border: 1px solid var(--border); border-radius: 14px;
  170.     padding: 22px 26px; width: 100%; max-width: 500px;
  171.     box-shadow: var(--shadow); max-height: 90vh; overflow-y: auto;
  172.     color: var(--text);
  173.   }
  174.   .modal-title { font-size: 1rem; color: var(--accent); font-weight: 700; margin-bottom: 16px; }
  175.   .diff-switch { display: flex; gap: 8px; margin-bottom: 18px; }
  176.   .diff-switch button {
  177.     flex: 1; min-height: 48px; border: 2px solid var(--border); border-radius: 8px;
  178.     background: var(--bg-sub); color: var(--text-dim); cursor: pointer; transition: all 0.15s;
  179.     display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  180.     padding: 4px 2px;
  181.   }
  182.   .diff-switch button:hover { border-color: var(--text-faint); }
  183.   .diff-switch button.active { background: var(--bg-card); }
  184.   .diff-switch button.active.EZ { border-color: var(--diff-ez); color: var(--diff-ez); }
  185.   .diff-switch button.active.HD { border-color: var(--diff-hd); color: var(--diff-hd); }
  186.   .diff-switch button.active.IN { border-color: var(--diff-in); color: var(--diff-in); }
  187.   .diff-switch button.active.AT { border-color: var(--diff-at); color: var(--diff-at); }
  188.   .diff-switch .sw-name { font-weight: 800; font-size: 0.85rem; }
  189.   .diff-switch .sw-lv { font-size: 0.72rem; font-weight: 700; color: var(--text-faint); }
  190.   .diff-switch button.active.EZ .sw-lv { color: var(--diff-ez); }
  191.   .diff-switch button.active.HD .sw-lv { color: var(--diff-hd); }
  192.   .diff-switch button.active.IN .sw-lv { color: var(--diff-in); }
  193.   .diff-switch button.active.AT .sw-lv { color: var(--diff-at); }
  194.   .editor-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
  195.   .editor-row label { font-size: 0.82rem; color: var(--text-dim); min-width: 44px; }
  196.   .lv-input-wrap {
  197.     display: flex; align-items: center; gap: 0;
  198.     background: var(--bg-input); border: 1px solid var(--border-strong); border-radius: 8px;
  199.     overflow: hidden; height: 40px;
  200.   }
  201.   .lv-input-wrap:focus-within { border-color: var(--accent); }
  202.   .lv-input-prefix {
  203.     padding: 0 10px; height: 100%; display: flex; align-items: center;
  204.     background: var(--bg-sub); color: var(--accent); font-weight: 800; font-size: 0.9rem;
  205.     border-right: 1px solid var(--border);
  206.   }
  207.   .lv-input-wrap input {
  208.     width: 100px; height: 100%; background: transparent; border: none;
  209.     color: var(--text-strong); font-size: 1rem; font-weight: 700; padding: 0 12px; outline: none;
  210.   }
  211.   .editor-row input[type="number"] {
  212.     width: 130px; height: 40px; background: var(--bg-input); border: 1px solid var(--border-strong);
  213.     border-radius: 8px; padding: 0 12px; color: var(--text-strong); font-size: 0.95rem;
  214.   }
  215.   .editor-row input[type="number"]:focus { outline: none; border-color: var(--accent); }
  216.   .editor-row input[type="number"]:disabled { opacity: 0.45; cursor: not-allowed; background: var(--bg-input-dis); }
  217.   .check-row { display: flex; gap: 20px; align-items: center; margin-bottom: 18px; }
  218.   .check-row label { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer; color: var(--text); }
  219.   .check-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); }
  220.   .rank-display {
  221.     display: inline-block; min-width: 48px; text-align: center;
  222.     padding: 5px 12px; border-radius: 6px; font-weight: 800; font-size: 1.15rem;
  223.   }
  224.   .rank-phi { background: var(--rank-phi-bg); color: var(--rank-phi-fg); }
  225.   .rank-nu-fc { background: var(--rank-fc-bg); color: var(--rank-fc-fg); }
  226.   .rank-nu-white { background: var(--rank-wv-bg); color: var(--rank-wv-fg); border: 1.5px solid var(--rank-wv-border); }
  227.   .rank-s { background: var(--rank-s-bg); color: var(--rank-s-fg); }
  228.   .rank-a { background: var(--rank-a-bg); color: var(--rank-a-fg); }
  229.   .rank-b { background: var(--rank-b-bg); color: var(--rank-b-fg); }
  230.   .rank-c { background: var(--rank-c-bg); color: var(--rank-c-fg); }
  231.   .rank-false { background: var(--rank-f-bg); color: var(--rank-f-fg); }
  232.   .rank-none { background: var(--rank-none-bg); color: var(--rank-none-fg); }
  233.   .modal-btns { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; }
  234.   .modal-btns button {
  235.     background: var(--bg-sub); color: var(--text); border: 1px solid var(--border-strong);
  236.     border-radius: 8px; padding: 9px 18px; cursor: pointer; font-size: 0.85rem;
  237.   }
  238.   .modal-btns button:hover { background: var(--bg-hover); }
  239.   .modal-btns button.danger { border-color: var(--danger); color: var(--danger); }
  240.   .modal-btns button.danger:hover { background: var(--danger-bg); }
  241.   .empty-song { padding: 8px 14px 8px 48px; font-size: 0.78rem; color: var(--text-faint); font-style: italic; }
  242.   .pick-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; max-height: 50vh; overflow-y: auto; }
  243.   .pick-item {
  244.     padding: 9px 12px; border-radius: 8px; background: var(--bg-sub); border: 1px solid var(--border);
  245.     cursor: pointer; font-size: 0.85rem; color: var(--text);
  246.   }
  247.   .pick-item:hover { background: var(--bg-hover); color: var(--text-strong); }
  248.   .pick-item small { color: var(--text-faint); margin-left: 6px; }
  249.   .name-input {
  250.     width: 100%; background: var(--bg-input); border: 1px solid var(--border-strong); border-radius: 8px;
  251.     padding: 11px 14px; color: var(--text-strong); font-size: 0.95rem; margin-bottom: 16px;
  252.   }
  253.   .name-input:focus { outline: none; border-color: var(--accent); }
  254. </style>
  255. </head>
  256. <body>
  257. <h1>&#127925; Phigros 歌曲记录器</h1>
  258. <div class="sub">每首歌曲默认有EZ/HD/IN/AT,如果没有AT等难度,不要管它</div>
  259. <div class="toolbar">
  260.   <button id="addSongBtn">+ 添加曲目</button>
  261.   <button id="exportBtn">导出数据</button>
  262.   <button id="importBtn">导入数据</button>
  263.   <input type="file" id="importFile" accept=".json" style="display:none;">
  264.   <button id="clearBtn" style="border-color:var(--danger);color:var(--danger);">清空全部</button>
  265.   <button class="theme-toggle" id="themeToggleBtn">
  266.     <span class="icon" id="themeIcon">&#127769;</span>
  267.     <span id="themeLabel">深色</span>
  268.   </button>
  269. </div>
  270. <div class="tree" id="tree"></div>
  271. <!-- 遮罩:曲名输入 -->
  272. <div class="overlay" id="nameOverlay">
  273.   <div class="modal">
  274.     <div class="modal-title">输入曲名</div>
  275.     <input type="text" class="name-input" id="nameInput" placeholder="输入曲名…" autocomplete="off">
  276.     <div class="modal-btns">
  277.       <button id="nameOkBtn">下一步:选择章节</button>
  278.       <button id="nameCancelBtn">取消</button>
  279.     </div>
  280.   </div>
  281. </div>
  282. <!-- 遮罩:章节选择 -->
  283. <div class="overlay" id="pickOverlay">
  284.   <div class="modal">
  285.     <div class="modal-title">选择所属章节</div>
  286.     <div class="pick-list" id="pickList"></div>
  287.     <div class="modal-btns">
  288.       <button id="pickCancelBtn">取消</button>
  289.     </div>
  290.   </div>
  291. </div>
  292. <!-- 遮罩:删除曲目确认 -->
  293. <div class="overlay" id="delSongOverlay">
  294.   <div class="modal" style="max-width:400px;">
  295.     <div class="modal-title" style="color:var(--danger);">删除曲目</div>
  296.     <div style="font-size:0.88rem; color:var(--text); line-height:1.7; margin-bottom:18px;">
  297.       确定删除 <strong id="delSongName" style="color:var(--text-strong);"></strong> 吗?<br>
  298.       <span style="color:var(--text-dim); font-size:0.8rem;">该曲目所有难度的记录都会一并删除,不可恢复。</span>
  299.     </div>
  300.     <div class="modal-btns">
  301.       <button id="delSongCancelBtn">取消</button>
  302.       <button id="delSongConfirmBtn" class="danger">确认删除</button>
  303.     </div>
  304.   </div>
  305. </div>
  306. <!-- 遮罩:数据编辑 -->
  307. <div class="overlay" id="editorOverlay">
  308.   <div class="modal">
  309.     <div class="modal-title" id="editorTitle">编辑数据</div>
  310.     <div class="diff-switch" id="diffSwitch"></div>
  311.     <div class="editor-row">
  312.       <label>等级</label>
  313.       <div class="lv-input-wrap">
  314.         <span class="lv-input-prefix">Lv.</span>
  315.         <input type="text" id="levelInput" placeholder="15" autocomplete="off">
  316.       </div>
  317.     </div>
  318.     <div class="editor-row">
  319.       <label>分数</label>
  320.       <input type="number" id="scoreInput" min="0" max="1000000" step="1" placeholder="0~1000000">
  321.       <label style="margin-left:4px;">Acc</label>
  322.       <input type="number" id="accInput" min="0" max="100" step="0.01" placeholder="0~100">
  323.     </div>
  324.     <div class="editor-row">
  325.       <span style="font-size:0.82rem; color:var(--text-dim);">评级</span>
  326.       <span class="rank-display rank-none" id="rankDisplay">—</span>
  327.     </div>
  328.     <div class="check-row">
  329.       <label><input type="checkbox" id="apCheck"> All Perfect</label>
  330.       <label><input type="checkbox" id="fcCheck"> Full Combo</label>
  331.     </div>
  332.     <div class="modal-btns">
  333.       <button id="saveBtn">保存</button>
  334.       <button id="deleteRecordBtn" class="danger">清除此难度记录</button>
  335.     </div>
  336.   </div>
  337. </div>
  338. <script>
  339. (function(){
  340.   const STORAGE_KEY = 'phigros_recorder_data_v1';
  341.   const DATA_VERSION = '1.0';
  342.   const THEME_KEY = 'phigros_recorder_theme';
  343.   // ---------- 主题切换 ----------
  344.   const themeToggleBtn = document.getElementById('themeToggleBtn');
  345.   const themeIcon = document.getElementById('themeIcon');
  346.   const themeLabel = document.getElementById('themeLabel');
  347.   function applyTheme(theme) {
  348.     document.documentElement.setAttribute('data-theme', theme);
  349.     if (theme === 'dark') {
  350.       themeIcon.textContent = '&#9728;&#65039;';
  351.       themeLabel.textContent = '浅色';
  352.     } else {
  353.       themeIcon.textContent = '&#127769;';
  354.       themeLabel.textContent = '深色';
  355.     }
  356.     try { localStorage.setItem(THEME_KEY, theme); } catch(e) {}
  357.   }
  358.   function initTheme() {
  359.     let saved = null;
  360.     try { saved = localStorage.getItem(THEME_KEY); } catch(e) {}
  361.     if (saved === 'dark' || saved === 'light') {
  362.       applyTheme(saved);
  363.     } else {
  364.       // 跟随系统
  365.       const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
  366.       applyTheme(prefersDark ? 'dark' : 'light');
  367.     }
  368.   }
  369.   themeToggleBtn.addEventListener('click', function() {
  370.     const cur = document.documentElement.getAttribute('data-theme');
  371.     applyTheme(cur === 'dark' ? 'light' : 'dark');
  372.   });
  373.   initTheme();
  374.   // ---------- 章节骨架 ----------
  375.   const CHAPTERS = [
  376.     { group: '主线章节', chapters: [
  377.       { id: 'legacy', name: 'Legacy(原 Chapter 1–4)', songs: [] },
  378.       { id: 'main5', name: 'Chapter 5', songs: [] },
  379.       { id: 'main6', name: 'Chapter 6', songs: [] },
  380.       { id: 'main7', name: 'Chapter 7', songs: [] },
  381.       { id: 'main8', name: 'Chapter 8', songs: [] },
  382.       { id: 'main9', name: 'Chapter 9', songs: [] },
  383.     ]},
  384.     { group: '支线章节', chapters: [
  385.       { id: 'side1', name: 'Side Story 1 · 忘忧宫', songs: [] },
  386.       { id: 'side2', name: 'Side Story 2 · 弭刻日', songs: [] },
  387.       { id: 'side3', name: 'Side Story 3 · 盗乐行', songs: [] },
  388.       { id: 'side4', name: 'Side Story 4 · 无相乡', songs: [] },
  389.     ]},
  390.     { group: '额外章节', chapters: [
  391.       { id: 'single', name: '单曲精选集', songs: [] },
  392.       { id: 'more', name: '其他歌曲', songs: [] },
  393.     ]},
  394.   ];
  395.   let records = {};
  396.   let songIdCounter = 1;
  397.   let currentSongId = null;
  398.   let currentDiff = null;
  399.   let pendingSongName = null;
  400.   let pendingDeleteSongId = null;
  401.   function ensureIds() {
  402.     CHAPTERS.forEach(g => g.chapters.forEach(ch => {
  403.       ch.songs.forEach(s => { if (!s.id) s.id = 's' + (songIdCounter++); });
  404.     }));
  405.   }
  406.   function loadData() {
  407.     try {
  408.       const raw = localStorage.getItem(STORAGE_KEY);
  409.       if (raw) {
  410.         const parsed = JSON.parse(raw);
  411.         if (!Array.isArray(parsed)) {
  412.           if (parsed.records) records = parsed.records;
  413.           if (parsed.chapters) {
  414.             parsed.chapters.forEach(g => {
  415.               const tg = CHAPTERS.find(x => x.group === g.group);
  416.               if (!tg) return;
  417.               g.chapters.forEach(sc => {
  418.                 const tc = tg.chapters.find(x => x.id === sc.id);
  419.                 if (tc && sc.songs) tc.songs = sc.songs;
  420.               });
  421.             });
  422.           }
  423.           if (parsed.songIdCounter) songIdCounter = parsed.songIdCounter;
  424.         }
  425.       }
  426.     } catch(e) { console.warn(e); }
  427.     ensureIds();
  428.   }
  429.   function saveData() {
  430.     try {
  431.       localStorage.setItem(STORAGE_KEY, JSON.stringify({
  432.         version: DATA_VERSION, records, chapters: CHAPTERS, songIdCounter
  433.       }));
  434.     } catch(e) { alert('保存失败,存储空间可能已满'); }
  435.   }
  436.   function getRank(score, fc, ap) {
  437.     if (ap) return { label: 'Phi', cls: 'rank-phi' };
  438.     const hasScore = !(score === null || score === undefined || score === '' || isNaN(score));
  439.     if (fc) {
  440.       if (hasScore && Number(score) >= 1000000) return { label: 'Phi', cls: 'rank-phi' };
  441.       return { label: 'V', cls: 'rank-nu-fc' };
  442.     }
  443.     if (!hasScore) return { label: '—', cls: 'rank-none' };
  444.     const s = Number(score);
  445.     if (s >= 1000000) return { label: 'Phi', cls: 'rank-phi' };
  446.     if (s >= 960000) return { label: 'V', cls: 'rank-nu-white' };
  447.     if (s >= 920000) return { label: 'S', cls: 'rank-s' };
  448.     if (s >= 880000) return { label: 'A', cls: 'rank-a' };
  449.     if (s >= 820000) return { label: 'B', cls: 'rank-b' };
  450.     if (s >= 700000) return { label: 'C', cls: 'rank-c' };
  451.     return { label: 'False', cls: 'rank-false' };
  452.   }
  453.   // ---------- 渲染树 ----------
  454.   const treeEl = document.getElementById('tree');
  455.   function renderTree() {
  456.     let html = '';
  457.     CHAPTERS.forEach((group, gi) => {
  458.       html += `<div class="group">
  459.         <div class="group-header open" data-gi="${gi}"><span class="arrow">&#9654;</span>${group.group}</div>
  460.         <div class="group-body open" data-gi="${gi}">`;
  461.       group.chapters.forEach((ch, ci) => {
  462.         html += `<div class="chapter">
  463.           <div class="chapter-header" data-gi="${gi}" data-ci="${ci}">
  464.             <span class="arrow">&#9654;</span>${ch.name}
  465.             <span style="color:var(--text-faint);font-size:0.7rem;margin-left:6px;">(${ch.songs.length})</span>
  466.           </div>
  467.           <div class="chapter-body">`;
  468.         if (ch.songs.length === 0) {
  469.           html += `<div class="empty-song">暂无曲目</div>`;
  470.         } else {
  471.           ch.songs.forEach(song => { html += renderSongRow(song); });
  472.         }
  473.         html += `</div></div>`;
  474.       });
  475.       html += `</div></div>`;
  476.     });
  477.     treeEl.innerHTML = html;
  478.     attachTreeEvents();
  479.   }
  480.   function renderSongRow(song) {
  481.     const diffs = ['EZ','HD','IN','AT'];
  482.     let btnHtml = diffs.map(d => {
  483.       const rec = records[song.id + '|' + d];
  484.       const sel = (currentSongId === song.id && currentDiff === d) ? 'selected' : '';
  485.       const hasLv = rec && rec.level && rec.level.trim();
  486.       const lvHtml = hasLv
  487.         ? `<span class="lv-tag" title="${d} 等级">Lv.${escapeHtml(rec.level.trim())}</span>`
  488.         : `<span class="lv-tag empty" title="未设置等级">—</span>`;
  489.       return `<span class="diff-btn-wrap">
  490.         <button class="diff-btn ${d} ${sel}" data-songid="${song.id}" data-diff="${d}" title="${d}${rec ? ' (已记录)' : ''}">${d}</button>
  491.         ${lvHtml}
  492.       </span>`;
  493.     }).join('');
  494.     return `<div class="song-item">
  495.       <div class="song-row ${currentSongId === song.id ? 'active' : ''}" data-songid="${song.id}">
  496.         <span class="song-name">${escapeHtml(song.name)}</span>
  497.         <div class="diff-btns">${btnHtml}</div>
  498.         <button class="song-del-btn" data-songid="${song.id}" title="删除此曲目">&#128465;</button>
  499.       </div>
  500.     </div>`;
  501.   }
  502.   function escapeHtml(s) {
  503.     return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
  504.   }
  505.   function attachTreeEvents() {
  506.     treeEl.querySelectorAll('.group-header').forEach(el => {
  507.       el.addEventListener('click', function() {
  508.         this.classList.toggle('open');
  509.         this.nextElementSibling.classList.toggle('open');
  510.       });
  511.     });
  512.     treeEl.querySelectorAll('.chapter-header').forEach(el => {
  513.       el.addEventListener('click', function(e) {
  514.         e.stopPropagation();
  515.         this.classList.toggle('open');
  516.         this.nextElementSibling.classList.toggle('open');
  517.       });
  518.     });
  519.     treeEl.querySelectorAll('.diff-btn').forEach(el => {
  520.       el.addEventListener('click', function(e) {
  521.         e.stopPropagation();
  522.         openEditor(this.dataset.songid, this.dataset.diff);
  523.       });
  524.     });
  525.     treeEl.querySelectorAll('.song-del-btn').forEach(el => {
  526.       el.addEventListener('click', function(e) {
  527.         e.stopPropagation();
  528.         const songId = this.dataset.songid;
  529.         const found = findSongById(songId);
  530.         if (!found) return;
  531.         pendingDeleteSongId = songId;
  532.         document.getElementById('delSongName').textContent = found.song.name;
  533.         document.getElementById('delSongOverlay').classList.add('open');
  534.       });
  535.     });
  536.     treeEl.querySelectorAll('.song-row').forEach(el => {
  537.       el.addEventListener('click', function(e) {
  538.         if (e.target.closest('.diff-btn-wrap') || e.target.closest('.song-del-btn')) return;
  539.         openEditor(this.dataset.songid, 'IN');
  540.       });
  541.     });
  542.   }
  543.   function findSongById(id) {
  544.     for (const g of CHAPTERS) for (const ch of g.chapters) {
  545.       const s = ch.songs.find(x => x.id === id);
  546.       if (s) return { song: s, chapter: ch, group: g };
  547.     }
  548.     return null;
  549.   }
  550.   // ---------- 删除曲目确认 ----------
  551.   const delSongOverlay = document.getElementById('delSongOverlay');
  552.   delSongOverlay.addEventListener('click', function(e) {
  553.     if (e.target === delSongOverlay) { pendingDeleteSongId = null; delSongOverlay.classList.remove('open'); }
  554.   });
  555.   document.getElementById('delSongCancelBtn').addEventListener('click', function() {
  556.     pendingDeleteSongId = null;
  557.     delSongOverlay.classList.remove('open');
  558.   });
  559.   document.getElementById('delSongConfirmBtn').addEventListener('click', function() {
  560.     if (!pendingDeleteSongId) return;
  561.     CHAPTERS.forEach(g => g.chapters.forEach(ch => {
  562.       ch.songs = ch.songs.filter(s => s.id !== pendingDeleteSongId);
  563.     }));
  564.     Object.keys(records).forEach(k => {
  565.       if (k.startsWith(pendingDeleteSongId + '|')) delete records[k];
  566.     });
  567.     if (currentSongId === pendingDeleteSongId) { currentSongId = null; currentDiff = null; }
  568.     pendingDeleteSongId = null;
  569.     delSongOverlay.classList.remove('open');
  570.     saveData();
  571.     renderTree();
  572.   });
  573.   // ---------- 编辑弹窗 ----------
  574.   const editorOverlay = document.getElementById('editorOverlay');
  575.   const editorTitle = document.getElementById('editorTitle');
  576.   const diffSwitch = document.getElementById('diffSwitch');
  577.   const levelInput = document.getElementById('levelInput');
  578.   const scoreInput = document.getElementById('scoreInput');
  579.   const accInput = document.getElementById('accInput');
  580.   const apCheck = document.getElementById('apCheck');
  581.   const fcCheck = document.getElementById('fcCheck');
  582.   const rankDisplay = document.getElementById('rankDisplay');
  583.   const DIFFS = ['EZ','HD','IN','AT'];
  584.   function openEditor(songId, diff) {
  585.     const found = findSongById(songId);
  586.     if (!found) return;
  587.     currentSongId = songId;
  588.     currentDiff = diff;
  589.     renderTree();
  590.     editorOverlay.classList.add('open');
  591.     editorTitle.textContent = found.song.name;
  592.     renderDiffSwitch();
  593.     loadDiffData();
  594.   }
  595.   function renderDiffSwitch() {
  596.     diffSwitch.innerHTML = DIFFS.map(d => {
  597.       const rec = records[currentSongId + '|' + d];
  598.       const active = d === currentDiff ? `active ${d}` : '';
  599.       const lv = rec && rec.level && rec.level.trim() ? `Lv.${escapeHtml(rec.level.trim())}` : '未设等级';
  600.       return `<button class="${active}" data-diff="${d}">
  601.         <span class="sw-name">${d}</span>
  602.         <span class="sw-lv">${lv}</span>
  603.       </button>`;
  604.     }).join('');
  605.     diffSwitch.querySelectorAll('button').forEach(el => {
  606.       el.addEventListener('click', function() {
  607.         currentDiff = this.dataset.diff;
  608.         renderDiffSwitch();
  609.         loadDiffData();
  610.       });
  611.     });
  612.   }
  613.   function loadDiffData() {
  614.     const key = currentSongId + '|' + currentDiff;
  615.     const rec = records[key] || { score: null, acc: null, ap: false, fc: false, level: '' };
  616.     levelInput.value = rec.level || '';
  617.     scoreInput.value = rec.score ?? '';
  618.     accInput.value = rec.acc ?? '';
  619.     apCheck.checked = !!rec.ap;
  620.     fcCheck.checked = !!rec.fc;
  621.     applyLockAndRank();
  622.   }
  623.   function applyLockAndRank() {
  624.     if (apCheck.checked) {
  625.       scoreInput.value = 1000000;
  626.       scoreInput.disabled = true;
  627.     } else {
  628.       scoreInput.disabled = false;
  629.     }
  630.     const ap = apCheck.checked;
  631.     const fc = fcCheck.checked;
  632.     const scoreVal = scoreInput.value === '' ? null : Number(scoreInput.value);
  633.     const rank = getRank(scoreVal, fc, ap);
  634.     rankDisplay.textContent = rank.label;
  635.     rankDisplay.className = 'rank-display ' + rank.cls;
  636.   }
  637.   scoreInput.addEventListener('input', applyLockAndRank);
  638.   apCheck.addEventListener('change', function() {
  639.     if (this.checked) fcCheck.checked = false;
  640.     applyLockAndRank();
  641.   });
  642.   fcCheck.addEventListener('change', function() {
  643.     if (this.checked) apCheck.checked = false;
  644.     applyLockAndRank();
  645.   });
  646.   editorOverlay.addEventListener('click', function(e) {
  647.     if (e.target === editorOverlay) editorOverlay.classList.remove('open');
  648.   });
  649.   document.getElementById('saveBtn').addEventListener('click', function() {
  650.     if (!currentSongId || !currentDiff) return;
  651.     const key = currentSongId + '|' + currentDiff;
  652.     const ap = apCheck.checked;
  653.     const fc = fcCheck.checked;
  654.     let score = scoreInput.value === '' ? null : Number(scoreInput.value);
  655.     if (ap) score = 1000000;
  656.     records[key] = {
  657.       score,
  658.       acc: accInput.value === '' ? null : Number(accInput.value),
  659.       ap, fc,
  660.       level: levelInput.value.trim()
  661.     };
  662.     saveData();
  663.     renderTree();
  664.     renderDiffSwitch();
  665.     applyLockAndRank();
  666.     editorOverlay.classList.remove('open');
  667.   });
  668.   document.getElementById('deleteRecordBtn').addEventListener('click', function() {
  669.     if (!currentSongId || !currentDiff) return;
  670.     const key = currentSongId + '|' + currentDiff;
  671.     if (records[key]) {
  672.       if (confirm('清除该曲目此难度的记录?')) {
  673.         delete records[key];
  674.         saveData();
  675.         renderTree();
  676.         loadDiffData();
  677.         renderDiffSwitch();
  678.       }
  679.     }
  680.   });
  681.   // ---------- 添加曲目 ----------
  682.   const nameOverlay = document.getElementById('nameOverlay');
  683.   const nameInput = document.getElementById('nameInput');
  684.   const pickOverlay = document.getElementById('pickOverlay');
  685.   const pickList = document.getElementById('pickList');
  686.   document.getElementById('addSongBtn').addEventListener('click', function() {
  687.     pendingSongName = null;
  688.     nameInput.value = '';
  689.     nameOverlay.classList.add('open');
  690.     setTimeout(() => nameInput.focus(), 50);
  691.   });
  692.   nameOverlay.addEventListener('click', function(e) {
  693.     if (e.target === nameOverlay) nameOverlay.classList.remove('open');
  694.   });
  695.   document.getElementById('nameCancelBtn').addEventListener('click', function() {
  696.     nameOverlay.classList.remove('open');
  697.   });
  698.   document.getElementById('nameOkBtn').addEventListener('click', function() {
  699.     const v = nameInput.value.trim();
  700.     if (!v) { alert('曲名不能为空'); return; }
  701.     pendingSongName = v;
  702.     nameOverlay.classList.remove('open');
  703.     openChapterPick();
  704.   });
  705.   nameInput.addEventListener('keydown', function(e) {
  706.     if (e.key === 'Enter') document.getElementById('nameOkBtn').click();
  707.   });
  708.   function openChapterPick() {
  709.     let html = '';
  710.     CHAPTERS.forEach(g => g.chapters.forEach(ch => {
  711.       html += `<div class="pick-item" data-cid="${ch.id}">${g.group} / ${ch.name} <small>(${ch.songs.length})</small></div>`;
  712.     }));
  713.     pickList.innerHTML = html;
  714.     pickList.querySelectorAll('.pick-item').forEach(el => {
  715.       el.addEventListener('click', function() {
  716.         const cid = this.dataset.cid;
  717.         let targetCh = null;
  718.         CHAPTERS.forEach(g => g.chapters.forEach(ch => { if (ch.id === cid) targetCh = ch; }));
  719.         if (!targetCh) return;
  720.         targetCh.songs.push({ id: 's' + (songIdCounter++), name: pendingSongName });
  721.         pendingSongName = null;
  722.         saveData();
  723.         renderTree();
  724.         pickOverlay.classList.remove('open');
  725.       });
  726.     });
  727.     pickOverlay.classList.add('open');
  728.   }
  729.   pickOverlay.addEventListener('click', function(e) {
  730.     if (e.target === pickOverlay) pickOverlay.classList.remove('open');
  731.   });
  732.   document.getElementById('pickCancelBtn').addEventListener('click', function() {
  733.     pickOverlay.classList.remove('open');
  734.   });
  735.   // ---------- 导出 / 导入 ----------
  736.   document.getElementById('exportBtn').addEventListener('click', function() {
  737.     const blob = new Blob([JSON.stringify({
  738.       version: DATA_VERSION, records, chapters: CHAPTERS, songIdCounter
  739.     }, null, 2)], { type: 'application/json' });
  740.     const url = URL.createObjectURL(blob);
  741.     const a = document.createElement('a');
  742.     a.href = url; a.download = `phigros_recorder_${new Date().toISOString().slice(0,10)}.json`;
  743.     a.click(); URL.revokeObjectURL(url);
  744.   });
  745.   document.getElementById('importBtn').addEventListener('click', function() {
  746.     document.getElementById('importFile').click();
  747.   });
  748.   document.getElementById('importFile').addEventListener('change', function(e) {
  749.     const file = e.target.files[0];
  750.     if (!file) return;
  751.     const reader = new FileReader();
  752.     reader.onload = function(ev) {
  753.       try {
  754.         const data = JSON.parse(ev.target.result);
  755.         if (data.records) records = data.records;
  756.         if (data.chapters) {
  757.           data.chapters.forEach(g => {
  758.             const tg = CHAPTERS.find(x => x.group === g.group);
  759.             if (!tg) return;
  760.             g.chapters.forEach(sc => {
  761.               const tc = tg.chapters.find(x => x.id === sc.id);
  762.               if (tc && sc.songs) tc.songs = sc.songs;
  763.             });
  764.           });
  765.         }
  766.         if (data.songIdCounter) songIdCounter = data.songIdCounter;
  767.         ensureIds();
  768.         saveData();
  769.         renderTree();
  770.         alert('导入成功' + (data.version ? `(数据版本 ${data.version})` : '(旧格式)'));
  771.       } catch(err) { alert('导入失败:' + err.message); }
  772.     };
  773.     reader.readAsText(file);
  774.     e.target.value = '';
  775.   });
  776.   document.getElementById('clearBtn').addEventListener('click', function() {
  777.     if (confirm('清空所有记录和曲目?不可恢复。')) {
  778.       localStorage.removeItem(STORAGE_KEY);
  779.       location.reload();
  780.     }
  781.   });
  782.   // ---------- 启动 ----------
  783.   loadData();
  784.   renderTree();
  785.   document.querySelectorAll('.chapter-header').forEach(el => {
  786.     el.classList.add('open');
  787.     el.nextElementSibling.classList.add('open');
  788.   });
  789. })();
  790. </script>
  791. </body>
  792. </html>
复制代码

使用截图
【AI创作】Phigros歌曲数据记录工具
【AI创作】Phigros歌曲数据记录工具
【AI创作】Phigros歌曲数据记录工具
【AI创作】Phigros歌曲数据记录工具

下载地址

https://yxzyw51.xyz/wp-content/uploads/2026/11/phigros-song-recorder.zip