dataset_review.html 17.3 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>歌词三分类测试集查看</title>
<style>
:root {
  --bg: #f5f7fa; --card: #fff; --border: #e2e8f0;
  --primary: #3b82f6; --primary-hover: #2563eb;
  --success: #10b981; --warning: #f59e0b; --danger: #ef4444;
  --text: #1e293b; --text-secondary: #64748b;
  --radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 1400px; margin: 0 auto; padding: 16px; }
h1 { font-size: 1.4rem; margin-bottom: 12px; }
.stats-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; min-width: 120px; text-align: center; }
.stat-card .num { font-size: 1.5rem; font-weight: 700; color: var(--primary); }
.stat-card .label { font-size: 0.8rem; color: var(--text-secondary); }
.class-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.class-chip { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 6px 16px; font-size: 0.82rem; cursor: pointer; user-select: none; transition: all 0.15s; }
.class-chip:hover { border-color: var(--primary); }
.class-chip.active { background: var(--primary); color: #fff; border-color: var(--primary); }
.class-chip .count { font-weight: 700; margin-left: 4px; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; background: var(--card); padding: 12px; border-radius: var(--radius); border: 1px solid var(--border); }
.toolbar select, .toolbar input, .toolbar button { font-size: 0.85rem; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--border); }
.toolbar button { background: var(--primary); color: #fff; border: none; cursor: pointer; font-weight: 500; }
.toolbar button:hover { background: var(--primary-hover); }
.sample-list { display: flex; flex-direction: column; gap: 12px; }
.sample-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.sample-card.active { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
.sample-header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; cursor: pointer; user-select: none; }
.sample-header:hover { background: #f8fafc; }
.sample-id { font-weight: 700; font-size: 0.85rem; color: var(--primary); min-width: 70px; }
.sample-title { flex: 1; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sample-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #e2e8f0; color: var(--text-secondary); }
.sample-badge.class-duplicate { background: #fee2e2; color: #991b1b; }
.sample-badge.class-review { background: #fef3c7; color: #92400e; }
.sample-badge.class-new { background: #d1fae5; color: #065f46; }
.sample-body { display: none; padding: 16px; border-top: 1px solid var(--border); }
.sample-card.active .sample-body { display: block; }
.pair-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
@media (max-width: 900px) { .pair-grid { grid-template-columns: 1fr; } }
.pair-col { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; }
.pair-col h4 { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; }
.pair-col .meta { font-size: 0.85rem; margin-bottom: 4px; }
.pair-col .meta b { color: var(--text-secondary); font-weight: 500; }
.lyrics-box { background: #f8fafc; border: 1px solid var(--border); border-radius: 4px; padding: 10px; font-size: 0.8rem; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 8px; }
.info-section { background: #f8fafc; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin-bottom: 16px; font-size: 0.82rem; line-height: 1.7; }
.info-section .row { margin-bottom: 2px; }
.info-section b { color: var(--text); }
.form-actions { margin-top: 12px; display: flex; gap: 8px; }
.form-actions button { padding: 8px 20px; font-size: 0.85rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; }
.btn-nav { background: #e2e8f0; color: var(--text); }
.btn-nav:hover { background: #cbd5e1; }
.pagination { display: flex; align-items: center; gap: 8px; justify-content: center; margin-top: 16px; }
.pagination button { padding: 6px 14px; border: 1px solid var(--border); background: var(--card); border-radius: 4px; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.pagination span { font-size: 0.85rem; color: var(--text-secondary); }
.toast { position: fixed; bottom: 20px; right: 20px; background: #1e293b; color: #fff; padding: 10px 20px; border-radius: 6px; font-size: 0.85rem; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; }
.toast.show { opacity: 1; }
.guide-toggle { font-size: 0.8rem; color: var(--primary); cursor: pointer; text-decoration: underline; margin-left: 12px; }
.guide-panel { display: none; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; font-size: 0.82rem; line-height: 1.7; max-height: 500px; overflow-y: auto; }
.guide-panel.show { display: block; }
.guide-panel h1 { font-size: 1rem; margin-bottom: 8px; }
.guide-panel h2 { font-size: 0.9rem; margin: 10px 0 4px; }
.guide-panel h3 { font-size: 0.85rem; margin: 8px 0 4px; }
.guide-panel ul { padding-left: 18px; }
.guide-panel code { background: #f1f5f9; padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="container">
  <h1>歌词三分类测试集查看 <span class="guide-toggle" onclick="toggleGuide()">查看说明文档</span></h1>
  <div class="guide-panel" id="guidePanel"></div>
  <div class="stats-bar" id="statsBar"></div>
  <div class="class-stats" id="classStats"></div>
  <div class="toolbar">
    <select id="filterSubtype"><option value="all">全部子类</option></select>
    <input type="text" id="searchInput" placeholder="搜索歌名/ID/歌手..." style="width:220px;">
    <button onclick="applyFilter()">筛选</button>
  </div>
  <div class="sample-list" id="sampleList"></div>
  <div class="pagination" id="pagination"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const PAGE_SIZE = 20;
let allSamples = [];
let stats = {};
let filtered = [];
let currentPage = 1;
let activeId = null;
let activeClass = 'all';

const CLASS_ZH = { 'duplicate': '重复', 'review': '待复核', 'new': '新歌' };
const CLASS_STYLE = { 'duplicate': 'class-duplicate', 'review': 'class-review', 'new': 'class-new' };
const SUBTYPE_ZH = {
  'exact_hash': '精确哈希',
  'high_literal_overlap': '高字面重合',
  'high_primary_translation_ignored': '主翻译忽略',
  'suspected_wash': '疑似洗盗',
  'version_or_same_work': '版本/同作品',
  'fragment_reuse': '片段复用',
  'chorus_overlap': '副歌重合',
  'translation_overlap': '翻译重合',
  'general_borderline': '一般边界',
  'same_artist_different_title': '同歌手不同歌名',
  'same_lyricist_different_title': '同词作者不同歌名',
  'same_composer_different_title': '同曲作者不同歌名',
  'random_different_songs': '随机不同歌曲',
};
function zh(map, v) { return map[v] || v; }

async function init() {
  const [csvRes, statsRes, guideRes] = await Promise.all([
    fetch('/api/dataset-review/samples').then(r => r.json()),
    fetch('/api/dataset-review/stats').then(r => r.json()),
    fetch('/api/dataset-review/guide').then(r => r.json()),
  ]);
  allSamples = csvRes.samples || [];
  stats = statsRes;
  document.getElementById('guidePanel').innerHTML = renderGuide(guideRes.text || '');
  renderStats();
  renderClassChips();
  buildSubtypeFilter();
  applyFilter();
}

function renderGuide(text) {
  return text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
    .replace(/^# (.+)$/gm, '<h1>$1</h1>')
    .replace(/^### (.+)$/gm, '<h3>$1</h3>')
    .replace(/^## (.+)$/gm, '<h2>$1</h2>')
    .replace(/^```text$/gm, '<code>').replace(/^```$/gm, '</code>')
    .replace(/^- (.+)$/gm, '<li>$1</li>')
    .replace(/(<li>.*<\/li>)/gs, '<ul>$1</ul>')
    .replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
}
function toggleGuide() { document.getElementById('guidePanel').classList.toggle('show'); }

function renderStats() {
  const total = stats.total_pairs || allSamples.length;
  const subtypes = stats.subtype_counts || {};
  const subtypeCount = Object.keys(subtypes).length;
  const poolTotal = Object.values(stats.source_pool_counts || {}).reduce((a, b) => a + b, 0);
  let html = `<div class="stat-card"><div class="num">${total}</div><div class="label">样本对总数</div></div>`;
  html += `<div class="stat-card"><div class="num">${Object.keys(stats.class_counts || {}).length}</div><div class="label">分类数</div></div>`;
  html += `<div class="stat-card"><div class="num">${subtypeCount}</div><div class="label">子类数</div></div>`;
  html += `<div class="stat-card"><div class="num">${poolTotal || '-'}</div><div class="label">候选池总量</div></div>`;
  document.getElementById('statsBar').innerHTML = html;
}

function renderClassChips() {
  const counts = stats.class_counts || {};
  let html = `<div class="class-chip ${activeClass === 'all' ? 'active' : ''}" onclick="selectClass('all')">全部<span class="count">${allSamples.length}</span></div>`;
  Object.keys(counts).sort().forEach(c => {
    const style = activeClass === c ? 'active' : '';
    html += `<div class="class-chip ${style}" onclick="selectClass('${c}')">${zh(CLASS_ZH, c)}<span class="count">${counts[c]}</span></div>`;
  });
  document.getElementById('classStats').innerHTML = html;
}

function selectClass(c) {
  activeClass = c;
  renderClassChips();
  rebuildSubtypeFilter();
  applyFilter();
}

function buildSubtypeFilter() {
  rebuildSubtypeFilter();
}

function rebuildSubtypeFilter() {
  const sel = document.getElementById('filterSubtype');
  const prevValue = sel.value;
  sel.innerHTML = '<option value="all">全部子类</option>';
  // 按当前选中主类统计子类数量
  const counts = {};
  allSamples.forEach(s => {
    if (activeClass !== 'all' && s.sampling_class !== activeClass) return;
    const sub = s.sampling_subtype;
    if (!sub) return;
    counts[sub] = (counts[sub] || 0) + 1;
  });
  Object.keys(counts).sort().forEach(s => {
    const opt = document.createElement('option');
    opt.value = s;
    opt.textContent = `${zh(SUBTYPE_ZH, s)}${counts[s]})`;
    sel.appendChild(opt);
  });
  // 尽量保持之前选中的子类,若不存在则重置为 all
  sel.value = counts[prevValue] ? prevValue : 'all';
}

function applyFilter() {
  const subtype = document.getElementById('filterSubtype').value;
  const term = document.getElementById('searchInput').value.trim().toLowerCase();
  filtered = allSamples.filter(s => {
    if (activeClass !== 'all' && s.sampling_class !== activeClass) return false;
    if (subtype !== 'all' && s.sampling_subtype !== subtype) return false;
    if (term) {
      const hay = [s.sample_id, s.query_name, s.query_singer, s.candidate_name, s.candidate_singer, s.query_source_song_id, s.candidate_song_id].join(' ').toLowerCase();
      if (!hay.includes(term)) return false;
    }
    return true;
  });
  currentPage = 1;
  renderList();
}

function renderList() {
  const start = (currentPage - 1) * PAGE_SIZE;
  const pageItems = filtered.slice(start, start + PAGE_SIZE);
  const container = document.getElementById('sampleList');
  container.innerHTML = pageItems.map(s => {
    const cls = s.sampling_class || '';
    const sub = s.sampling_subtype || '';
    const styleCls = CLASS_STYLE[cls] || '';
    return `<div class="sample-card ${activeId === s.sample_id ? 'active' : ''}" id="card-${s.sample_id}">
      <div class="sample-header" onclick="toggleCard('${s.sample_id}')">
        <span class="sample-id">${s.sample_id}</span>
        <span class="sample-title">${esc(s.query_name)}${esc(s.candidate_name)}</span>
        ${cls ? `<span class="sample-badge ${styleCls}">${zh(CLASS_ZH, cls)}</span>` : ''}
        ${sub ? `<span class="sample-badge">${zh(SUBTYPE_ZH, sub)}</span>` : ''}
      </div>
      <div class="sample-body" id="body-${s.sample_id}"></div>
    </div>`;
  }).join('');
  renderPagination();
}

function renderPagination() {
  const totalPages = Math.ceil(filtered.length / PAGE_SIZE) || 1;
  document.getElementById('pagination').innerHTML = `
    <button onclick="goPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>上一页</button>
    <span>第 ${currentPage} / ${totalPages} 页(共 ${filtered.length} 条)</span>
    <button onclick="goPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
}
function goPage(p) { const totalPages = Math.ceil(filtered.length / PAGE_SIZE) || 1; if (p < 1 || p > totalPages) return; currentPage = p; activeId = null; renderList(); window.scrollTo(0, 0); }

function toggleCard(id) {
  if (activeId === id) { activeId = null; renderList(); return; }
  activeId = id;
  renderList();
  const body = document.getElementById('body-' + id);
  const s = allSamples.find(x => x.sample_id === id);
  if (!s || !body) return;
  body.innerHTML = renderDetail(s);
  loadLyrics(s, 'query'); loadLyrics(s, 'candidate');
}

function renderDetail(s) {
  return `
  <div class="pair-grid">
    <div class="pair-col">
      <h4>查询样本 (Query)</h4>
      <div class="meta"><b>ID:</b> ${esc(s.query_source_song_id)}</div>
      <div class="meta"><b>歌名:</b> ${esc(s.query_name)}</div>
      <div class="meta"><b>歌手:</b> ${esc(s.query_singer || '-')}</div>
      <div class="meta"><b>词:</b> ${esc(s.query_lyricist || '-')}</div>
      <div class="meta"><b>曲:</b> ${esc(s.query_composer || '-')}</div>
      <div class="meta"><b>歌词来源:</b> ${s.query_lyrics_source_type}</div>
      <div class="lyrics-box" id="lyrics-query-${s.sample_id}">加载中...</div>
    </div>
    <div class="pair-col">
      <h4>候选样本 (Candidate)</h4>
      <div class="meta"><b>ID:</b> ${esc(s.candidate_song_id)}</div>
      <div class="meta"><b>歌名:</b> ${esc(s.candidate_name)}</div>
      <div class="meta"><b>歌手:</b> ${esc(s.candidate_singer || '-')}</div>
      <div class="meta"><b>词:</b> ${esc(s.candidate_lyricist || '-')}</div>
      <div class="meta"><b>曲:</b> ${esc(s.candidate_composer || '-')}</div>
      <div class="meta"><b>歌词来源:</b> ${s.candidate_lyrics_source_type}</div>
      <div class="lyrics-box" id="lyrics-candidate-${s.sample_id}">加载中...</div>
    </div>
  </div>
  <div class="info-section">
    <div class="row"><b>抽样主类:</b> ${zh(CLASS_ZH, s.sampling_class)}${s.sampling_class || '-'})</div>
    <div class="row"><b>抽样子类:</b> ${zh(SUBTYPE_ZH, s.sampling_subtype)}${s.sampling_subtype || '-'})</div>
    <div class="row"><b>配对来源:</b> ${s.legacy_pair_source || '-'}</div>
    <div class="row"><b>旧系统决策:</b> ${s.legacy_dedup_decision || '-'}(置信度 ${s.legacy_dedup_confidence || '-'})</div>
    ${s.legacy_dedup_reason ? `<div class="row"><b>旧系统理由:</b> ${esc(s.legacy_dedup_reason)}</div>` : ''}
  </div>
  <div class="form-actions">
    <button class="btn-nav" onclick="navSample(-1)">← 上一条</button>
    <button class="btn-nav" onclick="navSample(1)">下一条 →</button>
  </div>`;
}

async function loadLyrics(s, role) {
  const el = document.getElementById(`lyrics-${role}-${s.sample_id}`);
  if (!el) return;
  const sourceType = s[`${role}_lyrics_source_type`];
  const value = s[`${role}_lyrics_url_or_text`] || '';
  if (sourceType === 'inline') {
    el.textContent = value || '(无歌词)';
  } else if (sourceType === 'url' && value) {
    try {
      const res = await fetch('/api/text?path=' + encodeURIComponent(value));
      const data = await res.json();
      el.textContent = data.text || '(空)';
    } catch { el.textContent = '(加载失败: ' + value + ')'; }
  } else {
    el.textContent = '(无歌词)';
  }
}

function navSample(dir) {
  const idx = filtered.findIndex(s => s.sample_id === activeId);
  if (idx < 0) return;
  const next = idx + dir;
  if (next < 0 || next >= filtered.length) { toast(dir > 0 ? '已是最后一条' : '已是第一条'); return; }
  const targetPage = Math.floor(next / PAGE_SIZE) + 1;
  if (targetPage !== currentPage) { currentPage = targetPage; }
  activeId = filtered[next].sample_id;
  renderList();
  const body = document.getElementById('body-' + activeId);
  const s = allSamples.find(x => x.sample_id === activeId);
  if (s && body) { body.innerHTML = renderDetail(s); loadLyrics(s, 'query'); loadLyrics(s, 'candidate'); }
  document.getElementById('card-' + activeId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}

function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); setTimeout(() => el.classList.remove('show'), 2000); }

document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') applyFilter(); });
document.getElementById('filterSubtype').addEventListener('change', applyFilter);
init();
</script>
</body>
</html>