| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- <!DOCTYPE html>
- <html lang="zh">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>调研结果</title>
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
- <style>
- * { box-sizing: border-box; }
- body {
- font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
- margin: 0; background: #f5f5f7; color: #1d1d1f;
- }
- button { font-family: inherit; }
- header {
- padding: 14px 20px; background: rgba(255,255,255,0.92); backdrop-filter: blur(12px);
- border-bottom: 1px solid #e5e5e7; position: sticky; top: 0; z-index: 10;
- display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
- }
- header h1 { margin: 0; font-size: 17px; font-weight: 600; }
- .count { color: #86868b; font-size: 13px; }
- .stats { color: #515154; font-size: 13px; display: flex; gap: 10px; }
- .stats span { white-space: nowrap; }
- .search {
- flex: 1; min-width: 180px; max-width: 320px;
- padding: 7px 12px; border: 1px solid #d2d2d7; border-radius: 8px;
- font-size: 14px; background: #fbfbfd;
- }
- .search:focus { outline: none; border-color: #0071e3; background: white; }
- .btn {
- padding: 6px 12px; background: #f5f5f7; border: 1px solid #d2d2d7;
- border-radius: 8px; font-size: 13px; cursor: pointer;
- }
- .btn:hover { background: #ebebef; }
- .btn-primary { background: #0071e3; color: white; border-color: #0071e3; }
- .btn-primary:hover { background: #005ec1; }
- .file-label { display: inline-flex; align-items: center; }
- .file-label input { display: none; }
- .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #515154; cursor: pointer; }
- .toggle input { margin: 0; }
- .filter-bar {
- display: flex; gap: 6px; flex-wrap: wrap; padding: 0 20px 12px;
- background: rgba(255,255,255,0.92); border-bottom: 1px solid #e5e5e7;
- position: sticky; top: 56px; z-index: 9;
- }
- .chip {
- padding: 4px 12px; font-size: 12px; border: 1px solid #d2d2d7;
- border-radius: 999px; background: #fbfbfd; cursor: pointer;
- }
- .chip.active { background: #1d1d1f; color: white; border-color: #1d1d1f; }
- .grid {
- display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: 16px; padding: 20px;
- }
- .card {
- background: white; border-radius: 12px; overflow: hidden;
- cursor: pointer; transition: transform .15s, box-shadow .15s;
- box-shadow: 0 1px 3px rgba(0,0,0,0.05);
- display: flex; flex-direction: column; position: relative;
- }
- .card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0,0,0,0.08); }
- .card.rejected { opacity: 0.45; }
- .card.checked { box-shadow: 0 0 0 2px #34c759, 0 1px 3px rgba(0,0,0,0.05); }
- .card-cover {
- width: 100%; aspect-ratio: 4/3; object-fit: contain;
- background: #1d1d1f;
- }
- .card-cover-placeholder {
- width: 100%; aspect-ratio: 4/3;
- background: linear-gradient(135deg,#e5e5e7,#f5f5f7);
- display: flex; align-items: center; justify-content: center;
- color: #86868b; font-size: 13px;
- }
- .card-body { padding: 12px 14px 14px; flex: 1; display: flex; flex-direction: column; }
- .card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; line-height: 1.35; }
- .card-desc {
- font-size: 13px; color: #515154; line-height: 1.5; flex: 1;
- display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
- }
- .card-meta { font-size: 11px; color: #86868b; margin-top: 10px; display: flex; gap: 8px; align-items: center; }
- .card-meta .badge {
- padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500;
- background: #f5f5f7; color: #515154;
- }
- .badge-xhs { background: #ffe5e5; color: #c00; }
- .badge-zhihu { background: #e5f0ff; color: #06c; }
- .badge-youtube { background: #ffe9e3; color: #c33; }
- .badge-bili { background: #ffd8e9; color: #c63; }
- .card-marks {
- position: absolute; top: 8px; right: 8px;
- display: flex; gap: 4px; z-index: 2;
- }
- .mark-btn {
- width: 28px; height: 28px; border-radius: 999px; border: none;
- background: rgba(255,255,255,0.92); backdrop-filter: blur(6px);
- cursor: pointer; font-size: 14px; display: flex;
- align-items: center; justify-content: center;
- box-shadow: 0 1px 4px rgba(0,0,0,0.15);
- transition: transform .1s, background .15s;
- }
- .mark-btn:hover { transform: scale(1.1); }
- .mark-btn.active-y { background: #34c759; color: white; }
- .mark-btn.active-n { background: #ff3b30; color: white; }
- .card-comment-flag {
- position: absolute; top: 8px; left: 8px;
- width: 22px; height: 22px; border-radius: 999px;
- background: #ffd60a; color: #1d1d1f;
- display: flex; align-items: center; justify-content: center;
- font-size: 11px; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
- }
- /* Modal */
- .modal {
- position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 100;
- display: none; padding: 20px; overflow-y: auto;
- }
- .modal.open { display: block; }
- .modal-content {
- max-width: 820px; margin: 0 auto; background: white;
- border-radius: 16px; padding: 28px 32px 36px;
- }
- .modal-close {
- position: fixed; top: 16px; right: 24px; background: white;
- border: none; padding: 8px 16px; border-radius: 999px;
- cursor: pointer; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);
- z-index: 101;
- }
- .modal-title { font-size: 22px; font-weight: 700; margin: 0 0 10px; line-height: 1.3; }
- .modal-meta {
- font-size: 13px; color: #515154; margin: 0 0 16px;
- padding-bottom: 16px; border-bottom: 1px solid #f0f0f3;
- display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
- }
- .modal-meta a { color: #0071e3; text-decoration: none; }
- .modal-meta a:hover { text-decoration: underline; }
- .feedback-box {
- background: #fafafd; border: 1px solid #ececf1; border-radius: 10px;
- padding: 14px 16px; margin: 0 0 20px;
- }
- .feedback-actions { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
- .feedback-btn {
- padding: 6px 14px; border-radius: 8px; border: 1px solid #d2d2d7;
- background: white; cursor: pointer; font-size: 13px; display: inline-flex;
- align-items: center; gap: 6px;
- }
- .feedback-btn.active-y { background: #34c759; border-color: #34c759; color: white; }
- .feedback-btn.active-n { background: #ff3b30; border-color: #ff3b30; color: white; }
- .feedback-comment {
- width: 100%; min-height: 70px; padding: 10px 12px;
- border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px;
- font-family: inherit; resize: vertical; background: white;
- }
- .feedback-comment:focus { outline: none; border-color: #0071e3; }
- .feedback-saved {
- font-size: 11px; color: #86868b; margin-top: 6px;
- }
- .modal-desc {
- background: #f5f5f7; padding: 14px 18px; border-radius: 10px;
- margin: 0 0 20px; font-size: 14px; color: #1d1d1f; line-height: 1.6;
- border-left: 3px solid #0071e3;
- }
- .modal-images {
- display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 8px; margin: 0 0 24px;
- }
- .modal-images img {
- width: 100%; aspect-ratio: 1/1; object-fit: contain;
- border-radius: 8px; cursor: zoom-in; background: #1d1d1f;
- }
- .modal-body {
- font-size: 14.5px; line-height: 1.75; color: #1d1d1f; white-space: pre-wrap;
- word-break: break-word;
- }
- .modal-body p { margin: 0 0 10px; }
- .modal-body img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
- .modal-body a { color: #0071e3; }
- .modal-note {
- margin-top: 24px; padding: 10px 14px; background: #f5f5f7;
- border-radius: 8px; font-size: 12px; color: #86868b; font-family: ui-monospace, monospace;
- }
- /* Export modal */
- .export-textarea {
- width: 100%; min-height: 50vh; padding: 14px;
- border: 1px solid #d2d2d7; border-radius: 8px;
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
- font-size: 13px; line-height: 1.55; resize: vertical;
- background: #fbfbfd;
- }
- .export-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
- /* Lightbox */
- .lightbox {
- position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 200;
- display: none; align-items: center; justify-content: center;
- padding: 20px; cursor: zoom-out;
- }
- .lightbox.open { display: flex; }
- .lightbox img { max-width: 96%; max-height: 96vh; }
- .empty { padding: 60px 24px; text-align: center; color: #86868b; font-size: 14px; }
- .toast {
- position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
- background: #1d1d1f; color: white; padding: 10px 18px; border-radius: 999px;
- font-size: 13px; opacity: 0; transition: opacity .25s; z-index: 300; pointer-events: none;
- }
- .toast.show { opacity: 1; }
- @media (max-width: 600px) {
- header h1 { display: none; }
- .modal-content { padding: 20px 18px 28px; }
- }
- </style>
- </head>
- <body>
- <header>
- <h1>调研查看器</h1>
- <span id="count" class="count"></span>
- <span id="stats" class="stats"></span>
- <input id="search" class="search" placeholder="搜索…">
- <label class="toggle"><input id="showHidden" type="checkbox"> 显示已 ✗</label>
- <button id="exportBtn" class="btn btn-primary">导出标注</button>
- <label class="file-label btn">
- 加载 JSON
- <input id="file" type="file" accept=".json">
- </label>
- </header>
- <div id="filterBar" class="filter-bar"></div>
- <div id="grid" class="grid"></div>
- <div id="modal" class="modal" onclick="if(event.target===this) closeModal()">
- <button class="modal-close" onclick="closeModal()">关闭 ×</button>
- <div id="modalContent" class="modal-content"></div>
- </div>
- <div id="exportModal" class="modal" onclick="if(event.target===this) closeExport()">
- <button class="modal-close" onclick="closeExport()">关闭 ×</button>
- <div class="modal-content">
- <h2 class="modal-title">标注导出</h2>
- <p style="color:#86868b; font-size:13px; margin:0 0 12px;">
- ✓/✗ 和评论仅缓存在浏览器 localStorage(不会写回 result.json)。这里整理成文本,便于复制 / 下载 / 喂给 AI。
- </p>
- <textarea id="exportText" class="export-textarea"></textarea>
- <div class="export-actions">
- <button class="btn btn-primary" onclick="copyExport()">复制全部</button>
- <button class="btn" onclick="downloadExport()">下载 .md</button>
- <button class="btn" onclick="clearAllFeedback()" style="margin-left:auto; color:#ff3b30;">清空全部标注</button>
- </div>
- </div>
- </div>
- <div id="lightbox" class="lightbox" onclick="closeLightbox()">
- <img id="lightboxImg" alt="">
- </div>
- <div id="toast" class="toast"></div>
- <script>
- let items = [];
- let activeFilter = 'all';
- const STATE_KEY = 'viewer-annotations:' + location.pathname;
- const OLD_STATE_KEY = 'viewer-feedback:' + location.pathname; // legacy migration
- const SHOW_HIDDEN_KEY = 'viewer-show-hidden:' + location.pathname;
- if (!localStorage.getItem(STATE_KEY) && localStorage.getItem(OLD_STATE_KEY)) {
- localStorage.setItem(STATE_KEY, localStorage.getItem(OLD_STATE_KEY));
- }
- let state = JSON.parse(localStorage.getItem(STATE_KEY) || '{}');
- document.getElementById('showHidden').checked = localStorage.getItem(SHOW_HIDDEN_KEY) === '1';
- async function loadDefault() {
- try {
- const res = await fetch('./result.json');
- if (!res.ok) throw new Error('not found');
- const data = await res.json();
- items = Array.isArray(data) ? data : (data.items || []);
- renderAll();
- } catch (e) {
- document.getElementById('grid').innerHTML =
- '<div class="empty">未找到同目录下的 <code>result.json</code><br>' +
- '请用上方"加载 JSON"选择,或用 <code>./serve.sh</code> 启动后访问。</div>';
- }
- }
- document.getElementById('file').addEventListener('change', async (e) => {
- const f = e.target.files[0];
- if (!f) return;
- try {
- const data = JSON.parse(await f.text());
- items = Array.isArray(data) ? data : (data.items || []);
- activeFilter = 'all';
- renderAll();
- } catch (err) { alert('JSON 解析失败:' + err.message); }
- });
- document.getElementById('search').addEventListener('input', render);
- document.getElementById('showHidden').addEventListener('change', (e) => {
- localStorage.setItem(SHOW_HIDDEN_KEY, e.target.checked ? '1' : '0');
- render();
- });
- document.getElementById('exportBtn').addEventListener('click', openExport);
- function postId(item) {
- return item.url || (item.title || '') + '|' + (item.note || '');
- }
- function getFb(item) {
- return state[postId(item)] || {};
- }
- function setFb(item, patch) {
- const id = postId(item);
- state[id] = { ...(state[id] || {}), ...patch };
- // Clean empty
- const cur = state[id];
- if (!cur.mark && !(cur.comment || '').trim()) delete state[id];
- localStorage.setItem(STATE_KEY, JSON.stringify(state));
- updateStats();
- }
- function channelOf(item) {
- if (item.channel) return item.channel;
- const m = (item.note || '').match(/platform=(\w+)/); // legacy
- if (m) return m[1];
- const url = item.url || '';
- if (url.includes('xiaohongshu')) return 'xhs';
- if (url.includes('zhihu')) return 'zhihu';
- if (url.includes('youtube')) return 'youtube';
- if (url.includes('bilibili')) return 'bili';
- return 'other';
- }
- const METRIC_LABELS = {
- view_count: '👁',
- like_count: '❤',
- comment_count: '💬',
- collect_count: '⭐',
- share_count: '↗',
- };
- function formatNum(n) {
- if (n == null) return '';
- if (n < 1000) return String(n);
- if (n < 10000) return (n/1000).toFixed(1).replace(/\.0$/,'') + 'k';
- if (n < 100000000) return (n/10000).toFixed(1).replace(/\.0$/,'') + 'w';
- return (n/100000000).toFixed(1).replace(/\.0$/,'') + '亿';
- }
- function metricsHtml(feedback, separator) {
- if (!feedback) return '';
- const parts = [];
- for (const [k, label] of Object.entries(METRIC_LABELS)) {
- const v = feedback[k];
- if (v != null) parts.push(`${label} ${formatNum(v)}`);
- }
- return parts.join(separator || ' · ');
- }
- function renderAll() {
- const platforms = {};
- for (const it of items) {
- const p = channelOf(it);
- platforms[p] = (platforms[p] || 0) + 1;
- }
- const keys = Object.keys(platforms).sort();
- const bar = document.getElementById('filterBar');
- bar.innerHTML = `<span class="chip ${activeFilter==='all'?'active':''}" data-f="all">全部 (${items.length})</span>` +
- keys.map(k => `<span class="chip ${activeFilter===k?'active':''}" data-f="${k}">${k} (${platforms[k]})</span>`).join('');
- bar.querySelectorAll('.chip').forEach(el => {
- el.onclick = () => { activeFilter = el.dataset.f; renderAll(); };
- });
- render();
- }
- function updateStats() {
- let y = 0, n = 0, c = 0;
- for (const it of items) {
- const fb = getFb(it);
- if (fb.mark === 'y') y++;
- else if (fb.mark === 'n') n++;
- if ((fb.comment || '').trim()) c++;
- }
- document.getElementById('stats').innerHTML =
- `<span style="color:#34c759">✓ ${y}</span><span style="color:#ff3b30">✗ ${n}</span><span>💬 ${c}</span>`;
- }
- function render() {
- const q = document.getElementById('search').value.trim().toLowerCase();
- const showHidden = document.getElementById('showHidden').checked;
- const filtered = items.filter(it => {
- const fb = getFb(it);
- if (!showHidden && fb.mark === 'n') return false;
- if (activeFilter !== 'all' && channelOf(it) !== activeFilter) return false;
- if (!q) return true;
- return [it.title, it.description, it.author, it.body, it.note, fb.comment]
- .some(s => (s || '').toLowerCase().includes(q));
- });
- const hidden = items.filter(it => getFb(it).mark === 'n').length;
- document.getElementById('count').textContent =
- `${filtered.length} / ${items.length}` + (hidden && !showHidden ? ` (隐藏 ${hidden})` : '');
- updateStats();
- const grid = document.getElementById('grid');
- if (!filtered.length) {
- grid.innerHTML = '<div class="empty">没有匹配的结果</div>';
- return;
- }
- grid.innerHTML = filtered.map(it => {
- const realIdx = items.indexOf(it);
- const channel = channelOf(it);
- const fb = getFb(it);
- const cover = it.cover || '';
- const imgCount = (it.images || []).length;
- const cardClass = 'card' + (fb.mark === 'y' ? ' checked' : fb.mark === 'n' ? ' rejected' : '');
- const hasComment = (fb.comment || '').trim();
- const metrics = metricsHtml(it.feedback);
- return `
- <div class="${cardClass}" onclick="openModal(${realIdx})">
- <div class="card-marks" onclick="event.stopPropagation()">
- <button class="mark-btn ${fb.mark==='y'?'active-y':''}" title="保留" onclick="toggleMark(${realIdx},'y')">✓</button>
- <button class="mark-btn ${fb.mark==='n'?'active-n':''}" title="排除(隐藏)" onclick="toggleMark(${realIdx},'n')">✗</button>
- </div>
- ${hasComment ? `<div class="card-comment-flag" title="有评论">💬</div>` : ''}
- ${cover
- ? `<img class="card-cover" src="${escapeAttr(cover)}" loading="lazy" onerror="this.outerHTML='<div class="card-cover-placeholder">图片加载失败</div>'">`
- : `<div class="card-cover-placeholder">无封面</div>`}
- <div class="card-body">
- <h3 class="card-title">${escapeHtml(it.title || '无标题')}</h3>
- <p class="card-desc">${escapeHtml(it.description || '')}</p>
- <div class="card-meta">
- <span class="badge badge-${channel}">${channel}</span>
- <span>${escapeHtml(it.author || '')}</span>
- <span>· ${imgCount} 图</span>
- ${metrics ? `<span>· ${metrics}</span>` : ''}
- </div>
- </div>
- </div>`;
- }).join('');
- }
- function toggleMark(idx, mark) {
- const it = items[idx];
- const cur = getFb(it).mark;
- setFb(it, { mark: cur === mark ? null : mark });
- render();
- }
- function openModal(idx) {
- const it = items[idx];
- const channel = channelOf(it);
- const fb = getFb(it);
- const metrics = metricsHtml(it.feedback);
- const html = `
- <h2 class="modal-title">${escapeHtml(it.title || '无标题')}</h2>
- <div class="modal-meta">
- <span class="badge badge-${channel}">${channel}</span>
- <span>${escapeHtml(it.author || '')}</span>
- ${metrics ? `<span style="color:#86868b;">${metrics}</span>` : ''}
- ${it.url ? `<a href="${escapeAttr(it.url)}" target="_blank" rel="noopener">原文 ↗</a>` : ''}
- </div>
- <div class="feedback-box">
- <div class="feedback-actions">
- <button class="feedback-btn ${fb.mark==='y'?'active-y':''}" onclick="modalMark(${idx},'y')">✓ 保留</button>
- <button class="feedback-btn ${fb.mark==='n'?'active-n':''}" onclick="modalMark(${idx},'n')">✗ 排除</button>
- <span class="feedback-saved" id="savedNote">${fb.mark || (fb.comment||'').trim() ? '已保存到浏览器' : '未标记'}</span>
- </div>
- <textarea class="feedback-comment" id="commentInput" placeholder="对这条的评论(自动保存到浏览器 localStorage)…">${escapeHtml(fb.comment || '')}</textarea>
- </div>
- ${it.description ? `<div class="modal-desc">${escapeHtml(it.description)}</div>` : ''}
- ${(it.images || []).length ? `
- <div class="modal-images">
- ${it.images.map(src => `<img src="${escapeAttr(src)}" loading="lazy" onclick="event.stopPropagation(); openLightbox('${escapeAttr(src)}')" onerror="this.style.display='none'">`).join('')}
- </div>` : ''}
- <div class="modal-body">${renderMarkdown(it.body || '')}</div>
- ${it.note ? `<div class="modal-note">${escapeHtml(it.note)}</div>` : ''}
- `;
- document.getElementById('modalContent').innerHTML = html;
- document.getElementById('modal').classList.add('open');
- document.body.style.overflow = 'hidden';
- // bind comment auto-save
- const ta = document.getElementById('commentInput');
- let timer = null;
- ta.addEventListener('input', () => {
- clearTimeout(timer);
- timer = setTimeout(() => {
- setFb(it, { comment: ta.value });
- document.getElementById('savedNote').textContent = '已保存到浏览器';
- }, 250);
- });
- }
- function modalMark(idx, mark) {
- const it = items[idx];
- const cur = getFb(it).mark;
- setFb(it, { mark: cur === mark ? null : mark });
- openModal(idx); // re-render modal to update button states
- render(); // re-render grid
- }
- function closeModal() {
- document.getElementById('modal').classList.remove('open');
- document.body.style.overflow = '';
- }
- function openLightbox(src) {
- document.getElementById('lightboxImg').src = src;
- document.getElementById('lightbox').classList.add('open');
- }
- function closeLightbox() {
- document.getElementById('lightbox').classList.remove('open');
- }
- // ── Export ──────────────────────────────────────────────
- function compileExport() {
- const now = new Date();
- const ts = now.toLocaleString('zh-CN', { hour12: false });
- const yes = items.filter(it => getFb(it).mark === 'y');
- const no = items.filter(it => getFb(it).mark === 'n');
- const onlyComment = items.filter(it => {
- const fb = getFb(it);
- return !fb.mark && (fb.comment || '').trim();
- });
- const lines = [];
- lines.push('# 调研标注');
- lines.push(`导出时间: ${ts}`);
- lines.push(`数据源: ${location.pathname}result.json`);
- lines.push(`统计: 总 ${items.length} | ✓ ${yes.length} | ✗ ${no.length} | 仅评论 ${onlyComment.length}`);
- lines.push('');
- const dump = (arr, heading) => {
- if (!arr.length) return;
- lines.push(`## ${heading} (${arr.length})`);
- lines.push('');
- for (const it of arr) {
- const fb = getFb(it);
- lines.push(`### ${it.title || '无标题'}`);
- lines.push(`- 描述: ${(it.description || '').replace(/\n/g, ' ')}`);
- if ((fb.comment || '').trim()) {
- lines.push('- 评论:');
- for (const ln of fb.comment.trim().split('\n')) lines.push(` > ${ln}`);
- }
- lines.push('');
- }
- };
- dump(yes, '✓ 保留');
- dump(no, '✗ 排除');
- dump(onlyComment, '💬 仅评论(无 ✓/✗ 标记)');
- if (!yes.length && !no.length && !onlyComment.length) {
- lines.push('_(暂无标注,去标记或评论一些条目吧)_');
- }
- return lines.join('\n');
- }
- function openExport() {
- document.getElementById('exportText').value = compileExport();
- document.getElementById('exportModal').classList.add('open');
- document.body.style.overflow = 'hidden';
- }
- function closeExport() {
- document.getElementById('exportModal').classList.remove('open');
- document.body.style.overflow = '';
- }
- async function copyExport() {
- const text = document.getElementById('exportText').value;
- try {
- await navigator.clipboard.writeText(text);
- toast('已复制');
- } catch {
- document.getElementById('exportText').select();
- document.execCommand('copy');
- toast('已复制');
- }
- }
- function downloadExport() {
- const text = document.getElementById('exportText').value;
- const ts = new Date().toISOString().slice(0,19).replace(/[:T]/g, '-');
- const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `annotations-${ts}.md`;
- a.click();
- URL.revokeObjectURL(a.href);
- }
- function clearAllFeedback() {
- if (!confirm('确认清空所有标记和评论?此操作不可撤销。')) return;
- state = {};
- localStorage.removeItem(STATE_KEY);
- document.getElementById('exportText').value = compileExport();
- render();
- toast('已清空');
- }
- function toast(msg) {
- const t = document.getElementById('toast');
- t.textContent = msg;
- t.classList.add('show');
- setTimeout(() => t.classList.remove('show'), 1400);
- }
- function escapeHtml(s) {
- return String(s == null ? '' : s).replace(/[&<>"']/g,
- c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
- }
- function escapeAttr(s) {
- return String(s == null ? '' : s).replace(/"/g, '"');
- }
- function renderMarkdown(s) {
- if (!s) return '';
- try { return marked.parse(s, { breaks: true }); }
- catch { return escapeHtml(s); }
- }
- document.addEventListener('keydown', e => {
- if (e.key === 'Escape') {
- if (document.getElementById('lightbox').classList.contains('open')) closeLightbox();
- else if (document.getElementById('exportModal').classList.contains('open')) closeExport();
- else if (document.getElementById('modal').classList.contains('open')) closeModal();
- }
- });
- loadDefault();
- </script>
- </body>
- </html>
|