这个工具可以用树状图记录歌曲的各项数据(分数、评级、Acc、定数),用起来觉得不错(要手动录入曲目)
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Phigros 歌曲记录器</title>
- <style>
- * { box-sizing: border-box; }
-
- /* ===== 主题变量 ===== */
- :root, [data-theme="light"] {
- --bg: #ffffff;
- --bg-card: #ffffff;
- --bg-sub: #f7f7fb;
- --bg-hover: #f0f0f6;
- --bg-input: #ffffff;
- --bg-input-dis: #f4f4f8;
- --bg-modal: #ffffff;
- --bg-overlay: rgba(30,30,40,0.35);
- --text: #222233;
- --text-strong: #000000;
- --text-dim: #777;
- --text-faint: #bbb;
- --border: #e4e4ec;
- --border-strong: #ddd;
- --accent: #b8860b;
- --accent-bg: #fdf8ec;
- --danger: #d03a3a;
- --danger-bg: #fde8e8;
- --shadow: 0 12px 40px rgba(0,0,0,0.15);
- --rank-phi-bg: #f0c040; --rank-phi-fg: #222;
- --rank-fc-bg: #2f6fd0; --rank-fc-fg: #fff;
- --rank-wv-bg: #fff; --rank-wv-fg: #222; --rank-wv-border: #ccc;
- --rank-s-bg: #f0a030; --rank-s-fg: #222;
- --rank-a-bg: #4caf50; --rank-a-fg: #fff;
- --rank-b-bg: #4a90d9; --rank-b-fg: #fff;
- --rank-c-bg: #c08060; --rank-c-fg: #fff;
- --rank-f-bg: #ccc; --rank-f-fg: #777;
- --rank-none-bg: #f0f0f5; --rank-none-fg: #bbb;
- --lv-bg: #f0f0f5; --lv-fg: #444; --lv-border: #ddd; --lv-empty: #bbb;
- --diff-ez: #2e9e4f;
- --diff-hd: #2f6fd0;
- --diff-in: #d03a3a;
- --diff-at: #7a7a85;
- }
-
- [data-theme="dark"] {
- --bg: #0f0f14;
- --bg-card: #1a1a26;
- --bg-sub: #16161f;
- --bg-hover: #22223a;
- --bg-input: #0f0f14;
- --bg-input-dis: #1e1e2a;
- --bg-modal: #1a1a26;
- --bg-overlay: rgba(0,0,0,0.65);
- --text: #e8e8f0;
- --text-strong: #ffffff;
- --text-dim: #8888aa;
- --text-faint: #555;
- --border: #333344;
- --border-strong: #44445a;
- --accent: #ffd966;
- --accent-bg: #22222e;
- --danger: #ff6b6b;
- --danger-bg: #3a1a1a;
- --shadow: 0 12px 40px rgba(0,0,0,0.6);
- --rank-phi-bg: #ffd966; --rank-phi-fg: #000;
- --rank-fc-bg: #4a6fd4; --rank-fc-fg: #fff;
- --rank-wv-bg: #e8e8f0; --rank-wv-fg: #222; --rank-wv-border: #e8e8f0;
- --rank-s-bg: #ffaa44; --rank-s-fg: #000;
- --rank-a-bg: #66cc88; --rank-a-fg: #000;
- --rank-b-bg: #6699cc; --rank-b-fg: #000;
- --rank-c-bg: #cc8866; --rank-c-fg: #000;
- --rank-f-bg: #444; --rank-f-fg: #aaa;
- --rank-none-bg: #222; --rank-none-fg: #555;
- --lv-bg: #2a2a40; --lv-fg: #ffd966; --lv-border: #44445a; --lv-empty: #444;
- --diff-ez: #2e9e4f;
- --diff-hd: #2f6fd0;
- --diff-in: #d03a3a;
- --diff-at: #7a7a85;
- }
-
- body {
- font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
- background: var(--bg); color: var(--text);
- margin: 0 auto; padding: 20px; max-width: 1080px;
- transition: background 0.2s, color 0.2s;
- }
- h1 { font-size: 1.3rem; margin: 0 0 4px; color: var(--accent); }
- .sub { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 18px; }
- .toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; align-items: center; }
- .toolbar button {
- background: var(--bg-sub); color: var(--text); border: 1px solid var(--border-strong);
- border-radius: 8px; padding: 8px 14px; font-size: 0.85rem; cursor: pointer;
- transition: background 0.15s;
- }
- .toolbar button:hover { background: var(--bg-hover); }
- .theme-toggle {
- margin-left: auto; display: flex; align-items: center; gap: 6px;
- background: var(--bg-sub); color: var(--text); border: 1px solid var(--border-strong);
- border-radius: 999px; padding: 6px 14px; font-size: 0.82rem; cursor: pointer;
- transition: background 0.15s;
- }
- .theme-toggle:hover { background: var(--bg-hover); }
- .theme-toggle .icon { font-size: 1rem; }
-
- /* 树状 */
- .tree { user-select: none; }
- .group { margin-bottom: 6px; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg-card); }
- .group-header {
- display: flex; align-items: center; gap: 8px; padding: 10px 14px;
- background: var(--bg-sub); cursor: pointer; font-weight: 700; font-size: 0.9rem; color: var(--text);
- }
- .group-header:hover { background: var(--bg-hover); }
- .group-header .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; color: var(--text-faint); }
- .group-header.open .arrow { transform: rotate(90deg); }
- .group-body { display: none; padding: 4px 0 8px 0; }
- .group-body.open { display: block; }
-
- .chapter { margin: 2px 0; }
- .chapter-header {
- display: flex; align-items: center; gap: 6px; padding: 7px 14px 7px 30px;
- font-size: 0.82rem; color: var(--text-dim); cursor: pointer;
- }
- .chapter-header:hover { color: var(--text-strong); background: var(--bg-hover); }
- .chapter-header .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.6rem; color: var(--text-faint); }
- .chapter-header.open .arrow { transform: rotate(90deg); }
- .chapter-body { display: none; }
- .chapter-body.open { display: block; }
-
- .song-item { margin: 1px 0; }
- .song-row {
- display: flex; align-items: center; gap: 10px; padding: 7px 8px 7px 48px;
- font-size: 0.82rem; cursor: pointer; border-left: 3px solid transparent;
- }
- .song-row:hover { background: var(--bg-hover); }
- .song-row.active { border-left-color: var(--accent); background: var(--accent-bg); }
- .song-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-strong); font-weight: 500; }
- .diff-btns { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
- .diff-btn-wrap { display: flex; align-items: center; gap: 3px; }
-
- .diff-btn {
- min-width: 36px; height: 24px; border: none; border-radius: 4px;
- font-size: 0.7rem; font-weight: 800; cursor: pointer;
- transition: opacity 0.15s, transform 0.1s; padding: 0 5px; color: #fff;
- }
- .diff-btn:hover { transform: scale(1.06); }
- .diff-btn.selected { outline: 2px solid var(--text-strong); outline-offset: 1px; }
- .diff-btn.EZ { background: var(--diff-ez); }
- .diff-btn.HD { background: var(--diff-hd); }
- .diff-btn.IN { background: var(--diff-in); }
- .diff-btn.AT { background: var(--diff-at); }
-
- .lv-tag {
- display: inline-flex; align-items: center; justify-content: center;
- height: 22px; min-width: 44px; padding: 0 7px;
- border-radius: 6px; font-size: 0.72rem; font-weight: 800;
- background: var(--lv-bg); color: var(--lv-fg); border: 1px solid var(--lv-border);
- letter-spacing: 0.3px; white-space: nowrap;
- }
- .lv-tag.empty { background: transparent; color: var(--lv-empty); border: 1px dashed var(--lv-border); font-weight: 500; }
-
- .song-del-btn {
- flex-shrink: 0; width: 28px; height: 28px; border: none; border-radius: 6px;
- background: transparent; color: var(--text-faint); font-size: 0.95rem; cursor: pointer;
- display: flex; align-items: center; justify-content: center;
- transition: background 0.15s, color 0.15s;
- }
- .song-del-btn:hover { background: var(--danger-bg); color: var(--danger); }
-
- /* 遮罩 + 弹窗 */
- .overlay {
- position: fixed; inset: 0; background: var(--bg-overlay);
- display: none; align-items: center; justify-content: center;
- z-index: 100; padding: 20px;
- }
- .overlay.open { display: flex; }
- .modal {
- background: var(--bg-modal); border: 1px solid var(--border); border-radius: 14px;
- padding: 22px 26px; width: 100%; max-width: 500px;
- box-shadow: var(--shadow); max-height: 90vh; overflow-y: auto;
- color: var(--text);
- }
- .modal-title { font-size: 1rem; color: var(--accent); font-weight: 700; margin-bottom: 16px; }
-
- .diff-switch { display: flex; gap: 8px; margin-bottom: 18px; }
- .diff-switch button {
- flex: 1; min-height: 48px; border: 2px solid var(--border); border-radius: 8px;
- background: var(--bg-sub); color: var(--text-dim); cursor: pointer; transition: all 0.15s;
- display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
- padding: 4px 2px;
- }
- .diff-switch button:hover { border-color: var(--text-faint); }
- .diff-switch button.active { background: var(--bg-card); }
- .diff-switch button.active.EZ { border-color: var(--diff-ez); color: var(--diff-ez); }
- .diff-switch button.active.HD { border-color: var(--diff-hd); color: var(--diff-hd); }
- .diff-switch button.active.IN { border-color: var(--diff-in); color: var(--diff-in); }
- .diff-switch button.active.AT { border-color: var(--diff-at); color: var(--diff-at); }
- .diff-switch .sw-name { font-weight: 800; font-size: 0.85rem; }
- .diff-switch .sw-lv { font-size: 0.72rem; font-weight: 700; color: var(--text-faint); }
- .diff-switch button.active.EZ .sw-lv { color: var(--diff-ez); }
- .diff-switch button.active.HD .sw-lv { color: var(--diff-hd); }
- .diff-switch button.active.IN .sw-lv { color: var(--diff-in); }
- .diff-switch button.active.AT .sw-lv { color: var(--diff-at); }
-
- .editor-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
- .editor-row label { font-size: 0.82rem; color: var(--text-dim); min-width: 44px; }
-
- .lv-input-wrap {
- display: flex; align-items: center; gap: 0;
- background: var(--bg-input); border: 1px solid var(--border-strong); border-radius: 8px;
- overflow: hidden; height: 40px;
- }
- .lv-input-wrap:focus-within { border-color: var(--accent); }
- .lv-input-prefix {
- padding: 0 10px; height: 100%; display: flex; align-items: center;
- background: var(--bg-sub); color: var(--accent); font-weight: 800; font-size: 0.9rem;
- border-right: 1px solid var(--border);
- }
- .lv-input-wrap input {
- width: 100px; height: 100%; background: transparent; border: none;
- color: var(--text-strong); font-size: 1rem; font-weight: 700; padding: 0 12px; outline: none;
- }
-
- .editor-row input[type="number"] {
- width: 130px; height: 40px; background: var(--bg-input); border: 1px solid var(--border-strong);
- border-radius: 8px; padding: 0 12px; color: var(--text-strong); font-size: 0.95rem;
- }
- .editor-row input[type="number"]:focus { outline: none; border-color: var(--accent); }
- .editor-row input[type="number"]:disabled { opacity: 0.45; cursor: not-allowed; background: var(--bg-input-dis); }
-
- .check-row { display: flex; gap: 20px; align-items: center; margin-bottom: 18px; }
- .check-row label { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer; color: var(--text); }
- .check-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); }
-
- .rank-display {
- display: inline-block; min-width: 48px; text-align: center;
- padding: 5px 12px; border-radius: 6px; font-weight: 800; font-size: 1.15rem;
- }
- .rank-phi { background: var(--rank-phi-bg); color: var(--rank-phi-fg); }
- .rank-nu-fc { background: var(--rank-fc-bg); color: var(--rank-fc-fg); }
- .rank-nu-white { background: var(--rank-wv-bg); color: var(--rank-wv-fg); border: 1.5px solid var(--rank-wv-border); }
- .rank-s { background: var(--rank-s-bg); color: var(--rank-s-fg); }
- .rank-a { background: var(--rank-a-bg); color: var(--rank-a-fg); }
- .rank-b { background: var(--rank-b-bg); color: var(--rank-b-fg); }
- .rank-c { background: var(--rank-c-bg); color: var(--rank-c-fg); }
- .rank-false { background: var(--rank-f-bg); color: var(--rank-f-fg); }
- .rank-none { background: var(--rank-none-bg); color: var(--rank-none-fg); }
-
- .modal-btns { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; }
- .modal-btns button {
- background: var(--bg-sub); color: var(--text); border: 1px solid var(--border-strong);
- border-radius: 8px; padding: 9px 18px; cursor: pointer; font-size: 0.85rem;
- }
- .modal-btns button:hover { background: var(--bg-hover); }
- .modal-btns button.danger { border-color: var(--danger); color: var(--danger); }
- .modal-btns button.danger:hover { background: var(--danger-bg); }
-
- .empty-song { padding: 8px 14px 8px 48px; font-size: 0.78rem; color: var(--text-faint); font-style: italic; }
-
- .pick-list { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; max-height: 50vh; overflow-y: auto; }
- .pick-item {
- padding: 9px 12px; border-radius: 8px; background: var(--bg-sub); border: 1px solid var(--border);
- cursor: pointer; font-size: 0.85rem; color: var(--text);
- }
- .pick-item:hover { background: var(--bg-hover); color: var(--text-strong); }
- .pick-item small { color: var(--text-faint); margin-left: 6px; }
- .name-input {
- width: 100%; background: var(--bg-input); border: 1px solid var(--border-strong); border-radius: 8px;
- padding: 11px 14px; color: var(--text-strong); font-size: 0.95rem; margin-bottom: 16px;
- }
- .name-input:focus { outline: none; border-color: var(--accent); }
- </style>
- </head>
- <body>
-
- <h1>🎵 Phigros 歌曲记录器</h1>
- <div class="sub">每首歌曲默认有EZ/HD/IN/AT,如果没有AT等难度,不要管它</div>
-
- <div class="toolbar">
- <button id="addSongBtn">+ 添加曲目</button>
- <button id="exportBtn">导出数据</button>
- <button id="importBtn">导入数据</button>
- <input type="file" id="importFile" accept=".json" style="display:none;">
- <button id="clearBtn" style="border-color:var(--danger);color:var(--danger);">清空全部</button>
- <button class="theme-toggle" id="themeToggleBtn">
- <span class="icon" id="themeIcon">🌙</span>
- <span id="themeLabel">深色</span>
- </button>
- </div>
-
- <div class="tree" id="tree"></div>
-
- <!-- 遮罩:曲名输入 -->
- <div class="overlay" id="nameOverlay">
- <div class="modal">
- <div class="modal-title">输入曲名</div>
- <input type="text" class="name-input" id="nameInput" placeholder="输入曲名…" autocomplete="off">
- <div class="modal-btns">
- <button id="nameOkBtn">下一步:选择章节</button>
- <button id="nameCancelBtn">取消</button>
- </div>
- </div>
- </div>
-
- <!-- 遮罩:章节选择 -->
- <div class="overlay" id="pickOverlay">
- <div class="modal">
- <div class="modal-title">选择所属章节</div>
- <div class="pick-list" id="pickList"></div>
- <div class="modal-btns">
- <button id="pickCancelBtn">取消</button>
- </div>
- </div>
- </div>
-
- <!-- 遮罩:删除曲目确认 -->
- <div class="overlay" id="delSongOverlay">
- <div class="modal" style="max-width:400px;">
- <div class="modal-title" style="color:var(--danger);">删除曲目</div>
- <div style="font-size:0.88rem; color:var(--text); line-height:1.7; margin-bottom:18px;">
- 确定删除 <strong id="delSongName" style="color:var(--text-strong);"></strong> 吗?<br>
- <span style="color:var(--text-dim); font-size:0.8rem;">该曲目所有难度的记录都会一并删除,不可恢复。</span>
- </div>
- <div class="modal-btns">
- <button id="delSongCancelBtn">取消</button>
- <button id="delSongConfirmBtn" class="danger">确认删除</button>
- </div>
- </div>
- </div>
-
- <!-- 遮罩:数据编辑 -->
- <div class="overlay" id="editorOverlay">
- <div class="modal">
- <div class="modal-title" id="editorTitle">编辑数据</div>
- <div class="diff-switch" id="diffSwitch"></div>
-
- <div class="editor-row">
- <label>等级</label>
- <div class="lv-input-wrap">
- <span class="lv-input-prefix">Lv.</span>
- <input type="text" id="levelInput" placeholder="15" autocomplete="off">
- </div>
- </div>
-
- <div class="editor-row">
- <label>分数</label>
- <input type="number" id="scoreInput" min="0" max="1000000" step="1" placeholder="0~1000000">
- <label style="margin-left:4px;">Acc</label>
- <input type="number" id="accInput" min="0" max="100" step="0.01" placeholder="0~100">
- </div>
-
- <div class="editor-row">
- <span style="font-size:0.82rem; color:var(--text-dim);">评级</span>
- <span class="rank-display rank-none" id="rankDisplay">—</span>
- </div>
-
- <div class="check-row">
- <label><input type="checkbox" id="apCheck"> All Perfect</label>
- <label><input type="checkbox" id="fcCheck"> Full Combo</label>
- </div>
- <div class="modal-btns">
- <button id="saveBtn">保存</button>
- <button id="deleteRecordBtn" class="danger">清除此难度记录</button>
- </div>
- </div>
- </div>
-
- <script>
- (function(){
- const STORAGE_KEY = 'phigros_recorder_data_v1';
- const DATA_VERSION = '1.0';
- const THEME_KEY = 'phigros_recorder_theme';
-
- // ---------- 主题切换 ----------
- const themeToggleBtn = document.getElementById('themeToggleBtn');
- const themeIcon = document.getElementById('themeIcon');
- const themeLabel = document.getElementById('themeLabel');
-
- function applyTheme(theme) {
- document.documentElement.setAttribute('data-theme', theme);
- if (theme === 'dark') {
- themeIcon.textContent = '☀️';
- themeLabel.textContent = '浅色';
- } else {
- themeIcon.textContent = '🌙';
- themeLabel.textContent = '深色';
- }
- try { localStorage.setItem(THEME_KEY, theme); } catch(e) {}
- }
-
- function initTheme() {
- let saved = null;
- try { saved = localStorage.getItem(THEME_KEY); } catch(e) {}
- if (saved === 'dark' || saved === 'light') {
- applyTheme(saved);
- } else {
- // 跟随系统
- const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
- applyTheme(prefersDark ? 'dark' : 'light');
- }
- }
-
- themeToggleBtn.addEventListener('click', function() {
- const cur = document.documentElement.getAttribute('data-theme');
- applyTheme(cur === 'dark' ? 'light' : 'dark');
- });
-
- initTheme();
-
- // ---------- 章节骨架 ----------
- const CHAPTERS = [
- { group: '主线章节', chapters: [
- { id: 'legacy', name: 'Legacy(原 Chapter 1–4)', songs: [] },
- { id: 'main5', name: 'Chapter 5', songs: [] },
- { id: 'main6', name: 'Chapter 6', songs: [] },
- { id: 'main7', name: 'Chapter 7', songs: [] },
- { id: 'main8', name: 'Chapter 8', songs: [] },
- { id: 'main9', name: 'Chapter 9', songs: [] },
- ]},
- { group: '支线章节', chapters: [
- { id: 'side1', name: 'Side Story 1 · 忘忧宫', songs: [] },
- { id: 'side2', name: 'Side Story 2 · 弭刻日', songs: [] },
- { id: 'side3', name: 'Side Story 3 · 盗乐行', songs: [] },
- { id: 'side4', name: 'Side Story 4 · 无相乡', songs: [] },
- ]},
- { group: '额外章节', chapters: [
- { id: 'single', name: '单曲精选集', songs: [] },
- { id: 'more', name: '其他歌曲', songs: [] },
- ]},
- ];
-
- let records = {};
- let songIdCounter = 1;
- let currentSongId = null;
- let currentDiff = null;
- let pendingSongName = null;
- let pendingDeleteSongId = null;
-
- function ensureIds() {
- CHAPTERS.forEach(g => g.chapters.forEach(ch => {
- ch.songs.forEach(s => { if (!s.id) s.id = 's' + (songIdCounter++); });
- }));
- }
-
- function loadData() {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (raw) {
- const parsed = JSON.parse(raw);
- if (!Array.isArray(parsed)) {
- if (parsed.records) records = parsed.records;
- if (parsed.chapters) {
- parsed.chapters.forEach(g => {
- const tg = CHAPTERS.find(x => x.group === g.group);
- if (!tg) return;
- g.chapters.forEach(sc => {
- const tc = tg.chapters.find(x => x.id === sc.id);
- if (tc && sc.songs) tc.songs = sc.songs;
- });
- });
- }
- if (parsed.songIdCounter) songIdCounter = parsed.songIdCounter;
- }
- }
- } catch(e) { console.warn(e); }
- ensureIds();
- }
-
- function saveData() {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({
- version: DATA_VERSION, records, chapters: CHAPTERS, songIdCounter
- }));
- } catch(e) { alert('保存失败,存储空间可能已满'); }
- }
-
- function getRank(score, fc, ap) {
- if (ap) return { label: 'Phi', cls: 'rank-phi' };
- const hasScore = !(score === null || score === undefined || score === '' || isNaN(score));
- if (fc) {
- if (hasScore && Number(score) >= 1000000) return { label: 'Phi', cls: 'rank-phi' };
- return { label: 'V', cls: 'rank-nu-fc' };
- }
- if (!hasScore) return { label: '—', cls: 'rank-none' };
- const s = Number(score);
- if (s >= 1000000) return { label: 'Phi', cls: 'rank-phi' };
- if (s >= 960000) return { label: 'V', cls: 'rank-nu-white' };
- if (s >= 920000) return { label: 'S', cls: 'rank-s' };
- if (s >= 880000) return { label: 'A', cls: 'rank-a' };
- if (s >= 820000) return { label: 'B', cls: 'rank-b' };
- if (s >= 700000) return { label: 'C', cls: 'rank-c' };
- return { label: 'False', cls: 'rank-false' };
- }
-
- // ---------- 渲染树 ----------
- const treeEl = document.getElementById('tree');
-
- function renderTree() {
- let html = '';
- CHAPTERS.forEach((group, gi) => {
- html += `<div class="group">
- <div class="group-header open" data-gi="${gi}"><span class="arrow">▶</span>${group.group}</div>
- <div class="group-body open" data-gi="${gi}">`;
- group.chapters.forEach((ch, ci) => {
- html += `<div class="chapter">
- <div class="chapter-header" data-gi="${gi}" data-ci="${ci}">
- <span class="arrow">▶</span>${ch.name}
- <span style="color:var(--text-faint);font-size:0.7rem;margin-left:6px;">(${ch.songs.length})</span>
- </div>
- <div class="chapter-body">`;
- if (ch.songs.length === 0) {
- html += `<div class="empty-song">暂无曲目</div>`;
- } else {
- ch.songs.forEach(song => { html += renderSongRow(song); });
- }
- html += `</div></div>`;
- });
- html += `</div></div>`;
- });
- treeEl.innerHTML = html;
- attachTreeEvents();
- }
-
- function renderSongRow(song) {
- const diffs = ['EZ','HD','IN','AT'];
- let btnHtml = diffs.map(d => {
- const rec = records[song.id + '|' + d];
- const sel = (currentSongId === song.id && currentDiff === d) ? 'selected' : '';
- const hasLv = rec && rec.level && rec.level.trim();
- const lvHtml = hasLv
- ? `<span class="lv-tag" title="${d} 等级">Lv.${escapeHtml(rec.level.trim())}</span>`
- : `<span class="lv-tag empty" title="未设置等级">—</span>`;
- return `<span class="diff-btn-wrap">
- <button class="diff-btn ${d} ${sel}" data-songid="${song.id}" data-diff="${d}" title="${d}${rec ? ' (已记录)' : ''}">${d}</button>
- ${lvHtml}
- </span>`;
- }).join('');
- return `<div class="song-item">
- <div class="song-row ${currentSongId === song.id ? 'active' : ''}" data-songid="${song.id}">
- <span class="song-name">${escapeHtml(song.name)}</span>
- <div class="diff-btns">${btnHtml}</div>
- <button class="song-del-btn" data-songid="${song.id}" title="删除此曲目">🗑</button>
- </div>
- </div>`;
- }
-
- function escapeHtml(s) {
- return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
- }
-
- function attachTreeEvents() {
- treeEl.querySelectorAll('.group-header').forEach(el => {
- el.addEventListener('click', function() {
- this.classList.toggle('open');
- this.nextElementSibling.classList.toggle('open');
- });
- });
- treeEl.querySelectorAll('.chapter-header').forEach(el => {
- el.addEventListener('click', function(e) {
- e.stopPropagation();
- this.classList.toggle('open');
- this.nextElementSibling.classList.toggle('open');
- });
- });
- treeEl.querySelectorAll('.diff-btn').forEach(el => {
- el.addEventListener('click', function(e) {
- e.stopPropagation();
- openEditor(this.dataset.songid, this.dataset.diff);
- });
- });
- treeEl.querySelectorAll('.song-del-btn').forEach(el => {
- el.addEventListener('click', function(e) {
- e.stopPropagation();
- const songId = this.dataset.songid;
- const found = findSongById(songId);
- if (!found) return;
- pendingDeleteSongId = songId;
- document.getElementById('delSongName').textContent = found.song.name;
- document.getElementById('delSongOverlay').classList.add('open');
- });
- });
- treeEl.querySelectorAll('.song-row').forEach(el => {
- el.addEventListener('click', function(e) {
- if (e.target.closest('.diff-btn-wrap') || e.target.closest('.song-del-btn')) return;
- openEditor(this.dataset.songid, 'IN');
- });
- });
- }
-
- function findSongById(id) {
- for (const g of CHAPTERS) for (const ch of g.chapters) {
- const s = ch.songs.find(x => x.id === id);
- if (s) return { song: s, chapter: ch, group: g };
- }
- return null;
- }
-
- // ---------- 删除曲目确认 ----------
- const delSongOverlay = document.getElementById('delSongOverlay');
- delSongOverlay.addEventListener('click', function(e) {
- if (e.target === delSongOverlay) { pendingDeleteSongId = null; delSongOverlay.classList.remove('open'); }
- });
- document.getElementById('delSongCancelBtn').addEventListener('click', function() {
- pendingDeleteSongId = null;
- delSongOverlay.classList.remove('open');
- });
- document.getElementById('delSongConfirmBtn').addEventListener('click', function() {
- if (!pendingDeleteSongId) return;
- CHAPTERS.forEach(g => g.chapters.forEach(ch => {
- ch.songs = ch.songs.filter(s => s.id !== pendingDeleteSongId);
- }));
- Object.keys(records).forEach(k => {
- if (k.startsWith(pendingDeleteSongId + '|')) delete records[k];
- });
- if (currentSongId === pendingDeleteSongId) { currentSongId = null; currentDiff = null; }
- pendingDeleteSongId = null;
- delSongOverlay.classList.remove('open');
- saveData();
- renderTree();
- });
-
- // ---------- 编辑弹窗 ----------
- const editorOverlay = document.getElementById('editorOverlay');
- const editorTitle = document.getElementById('editorTitle');
- const diffSwitch = document.getElementById('diffSwitch');
- const levelInput = document.getElementById('levelInput');
- const scoreInput = document.getElementById('scoreInput');
- const accInput = document.getElementById('accInput');
- const apCheck = document.getElementById('apCheck');
- const fcCheck = document.getElementById('fcCheck');
- const rankDisplay = document.getElementById('rankDisplay');
- const DIFFS = ['EZ','HD','IN','AT'];
-
- function openEditor(songId, diff) {
- const found = findSongById(songId);
- if (!found) return;
- currentSongId = songId;
- currentDiff = diff;
- renderTree();
- editorOverlay.classList.add('open');
- editorTitle.textContent = found.song.name;
- renderDiffSwitch();
- loadDiffData();
- }
-
- function renderDiffSwitch() {
- diffSwitch.innerHTML = DIFFS.map(d => {
- const rec = records[currentSongId + '|' + d];
- const active = d === currentDiff ? `active ${d}` : '';
- const lv = rec && rec.level && rec.level.trim() ? `Lv.${escapeHtml(rec.level.trim())}` : '未设等级';
- return `<button class="${active}" data-diff="${d}">
- <span class="sw-name">${d}</span>
- <span class="sw-lv">${lv}</span>
- </button>`;
- }).join('');
- diffSwitch.querySelectorAll('button').forEach(el => {
- el.addEventListener('click', function() {
- currentDiff = this.dataset.diff;
- renderDiffSwitch();
- loadDiffData();
- });
- });
- }
-
- function loadDiffData() {
- const key = currentSongId + '|' + currentDiff;
- const rec = records[key] || { score: null, acc: null, ap: false, fc: false, level: '' };
- levelInput.value = rec.level || '';
- scoreInput.value = rec.score ?? '';
- accInput.value = rec.acc ?? '';
- apCheck.checked = !!rec.ap;
- fcCheck.checked = !!rec.fc;
- applyLockAndRank();
- }
-
- function applyLockAndRank() {
- if (apCheck.checked) {
- scoreInput.value = 1000000;
- scoreInput.disabled = true;
- } else {
- scoreInput.disabled = false;
- }
- const ap = apCheck.checked;
- const fc = fcCheck.checked;
- const scoreVal = scoreInput.value === '' ? null : Number(scoreInput.value);
- const rank = getRank(scoreVal, fc, ap);
- rankDisplay.textContent = rank.label;
- rankDisplay.className = 'rank-display ' + rank.cls;
- }
-
- scoreInput.addEventListener('input', applyLockAndRank);
- apCheck.addEventListener('change', function() {
- if (this.checked) fcCheck.checked = false;
- applyLockAndRank();
- });
- fcCheck.addEventListener('change', function() {
- if (this.checked) apCheck.checked = false;
- applyLockAndRank();
- });
-
- editorOverlay.addEventListener('click', function(e) {
- if (e.target === editorOverlay) editorOverlay.classList.remove('open');
- });
-
- document.getElementById('saveBtn').addEventListener('click', function() {
- if (!currentSongId || !currentDiff) return;
- const key = currentSongId + '|' + currentDiff;
- const ap = apCheck.checked;
- const fc = fcCheck.checked;
- let score = scoreInput.value === '' ? null : Number(scoreInput.value);
- if (ap) score = 1000000;
- records[key] = {
- score,
- acc: accInput.value === '' ? null : Number(accInput.value),
- ap, fc,
- level: levelInput.value.trim()
- };
- saveData();
- renderTree();
- renderDiffSwitch();
- applyLockAndRank();
- editorOverlay.classList.remove('open');
- });
-
- document.getElementById('deleteRecordBtn').addEventListener('click', function() {
- if (!currentSongId || !currentDiff) return;
- const key = currentSongId + '|' + currentDiff;
- if (records[key]) {
- if (confirm('清除该曲目此难度的记录?')) {
- delete records[key];
- saveData();
- renderTree();
- loadDiffData();
- renderDiffSwitch();
- }
- }
- });
-
- // ---------- 添加曲目 ----------
- const nameOverlay = document.getElementById('nameOverlay');
- const nameInput = document.getElementById('nameInput');
- const pickOverlay = document.getElementById('pickOverlay');
- const pickList = document.getElementById('pickList');
-
- document.getElementById('addSongBtn').addEventListener('click', function() {
- pendingSongName = null;
- nameInput.value = '';
- nameOverlay.classList.add('open');
- setTimeout(() => nameInput.focus(), 50);
- });
-
- nameOverlay.addEventListener('click', function(e) {
- if (e.target === nameOverlay) nameOverlay.classList.remove('open');
- });
- document.getElementById('nameCancelBtn').addEventListener('click', function() {
- nameOverlay.classList.remove('open');
- });
- document.getElementById('nameOkBtn').addEventListener('click', function() {
- const v = nameInput.value.trim();
- if (!v) { alert('曲名不能为空'); return; }
- pendingSongName = v;
- nameOverlay.classList.remove('open');
- openChapterPick();
- });
- nameInput.addEventListener('keydown', function(e) {
- if (e.key === 'Enter') document.getElementById('nameOkBtn').click();
- });
-
- function openChapterPick() {
- let html = '';
- CHAPTERS.forEach(g => g.chapters.forEach(ch => {
- html += `<div class="pick-item" data-cid="${ch.id}">${g.group} / ${ch.name} <small>(${ch.songs.length})</small></div>`;
- }));
- pickList.innerHTML = html;
- pickList.querySelectorAll('.pick-item').forEach(el => {
- el.addEventListener('click', function() {
- const cid = this.dataset.cid;
- let targetCh = null;
- CHAPTERS.forEach(g => g.chapters.forEach(ch => { if (ch.id === cid) targetCh = ch; }));
- if (!targetCh) return;
- targetCh.songs.push({ id: 's' + (songIdCounter++), name: pendingSongName });
- pendingSongName = null;
- saveData();
- renderTree();
- pickOverlay.classList.remove('open');
- });
- });
- pickOverlay.classList.add('open');
- }
-
- pickOverlay.addEventListener('click', function(e) {
- if (e.target === pickOverlay) pickOverlay.classList.remove('open');
- });
- document.getElementById('pickCancelBtn').addEventListener('click', function() {
- pickOverlay.classList.remove('open');
- });
-
- // ---------- 导出 / 导入 ----------
- document.getElementById('exportBtn').addEventListener('click', function() {
- const blob = new Blob([JSON.stringify({
- version: DATA_VERSION, records, chapters: CHAPTERS, songIdCounter
- }, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url; a.download = `phigros_recorder_${new Date().toISOString().slice(0,10)}.json`;
- a.click(); URL.revokeObjectURL(url);
- });
-
- document.getElementById('importBtn').addEventListener('click', function() {
- document.getElementById('importFile').click();
- });
- document.getElementById('importFile').addEventListener('change', function(e) {
- const file = e.target.files[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = function(ev) {
- try {
- const data = JSON.parse(ev.target.result);
- if (data.records) records = data.records;
- if (data.chapters) {
- data.chapters.forEach(g => {
- const tg = CHAPTERS.find(x => x.group === g.group);
- if (!tg) return;
- g.chapters.forEach(sc => {
- const tc = tg.chapters.find(x => x.id === sc.id);
- if (tc && sc.songs) tc.songs = sc.songs;
- });
- });
- }
- if (data.songIdCounter) songIdCounter = data.songIdCounter;
- ensureIds();
- saveData();
- renderTree();
- alert('导入成功' + (data.version ? `(数据版本 ${data.version})` : '(旧格式)'));
- } catch(err) { alert('导入失败:' + err.message); }
- };
- reader.readAsText(file);
- e.target.value = '';
- });
-
- document.getElementById('clearBtn').addEventListener('click', function() {
- if (confirm('清空所有记录和曲目?不可恢复。')) {
- localStorage.removeItem(STORAGE_KEY);
- location.reload();
- }
- });
-
- // ---------- 启动 ----------
- loadData();
- renderTree();
- document.querySelectorAll('.chapter-header').forEach(el => {
- el.classList.add('open');
- el.nextElementSibling.classList.add('open');
- });
- })();
- </script>
- </body>
- </html>
复制代码
使用截图




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