index.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <!DOCTYPE html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>调研结果</title>
  7. <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  8. <style>
  9. * { box-sizing: border-box; }
  10. body {
  11. font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
  12. margin: 0; background: #f5f5f7; color: #1d1d1f;
  13. }
  14. button { font-family: inherit; }
  15. header {
  16. padding: 14px 20px; background: rgba(255,255,255,0.92); backdrop-filter: blur(12px);
  17. border-bottom: 1px solid #e5e5e7; position: sticky; top: 0; z-index: 10;
  18. display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
  19. }
  20. header h1 { margin: 0; font-size: 17px; font-weight: 600; }
  21. .count { color: #86868b; font-size: 13px; }
  22. .stats { color: #515154; font-size: 13px; display: flex; gap: 10px; }
  23. .stats span { white-space: nowrap; }
  24. .search {
  25. flex: 1; min-width: 180px; max-width: 320px;
  26. padding: 7px 12px; border: 1px solid #d2d2d7; border-radius: 8px;
  27. font-size: 14px; background: #fbfbfd;
  28. }
  29. .search:focus { outline: none; border-color: #0071e3; background: white; }
  30. .btn {
  31. padding: 6px 12px; background: #f5f5f7; border: 1px solid #d2d2d7;
  32. border-radius: 8px; font-size: 13px; cursor: pointer;
  33. }
  34. .btn:hover { background: #ebebef; }
  35. .btn-primary { background: #0071e3; color: white; border-color: #0071e3; }
  36. .btn-primary:hover { background: #005ec1; }
  37. .file-label { display: inline-flex; align-items: center; }
  38. .file-label input { display: none; }
  39. .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #515154; cursor: pointer; }
  40. .toggle input { margin: 0; }
  41. .filter-bar {
  42. display: flex; gap: 6px; flex-wrap: wrap; padding: 0 20px 12px;
  43. background: rgba(255,255,255,0.92); border-bottom: 1px solid #e5e5e7;
  44. position: sticky; top: 56px; z-index: 9;
  45. }
  46. .chip {
  47. padding: 4px 12px; font-size: 12px; border: 1px solid #d2d2d7;
  48. border-radius: 999px; background: #fbfbfd; cursor: pointer;
  49. }
  50. .chip.active { background: #1d1d1f; color: white; border-color: #1d1d1f; }
  51. .grid {
  52. display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  53. gap: 16px; padding: 20px;
  54. }
  55. .card {
  56. background: white; border-radius: 12px; overflow: hidden;
  57. cursor: pointer; transition: transform .15s, box-shadow .15s;
  58. box-shadow: 0 1px 3px rgba(0,0,0,0.05);
  59. display: flex; flex-direction: column; position: relative;
  60. }
  61. .card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0,0,0,0.08); }
  62. .card.rejected { opacity: 0.45; }
  63. .card.checked { box-shadow: 0 0 0 2px #34c759, 0 1px 3px rgba(0,0,0,0.05); }
  64. .card-cover {
  65. width: 100%; aspect-ratio: 4/3; object-fit: contain;
  66. background: #1d1d1f;
  67. }
  68. .card-cover-placeholder {
  69. width: 100%; aspect-ratio: 4/3;
  70. background: linear-gradient(135deg,#e5e5e7,#f5f5f7);
  71. display: flex; align-items: center; justify-content: center;
  72. color: #86868b; font-size: 13px;
  73. }
  74. .card-body { padding: 12px 14px 14px; flex: 1; display: flex; flex-direction: column; }
  75. .card-title { font-size: 15px; font-weight: 600; margin: 0 0 6px; line-height: 1.35; }
  76. .card-desc {
  77. font-size: 13px; color: #515154; line-height: 1.5; flex: 1;
  78. display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
  79. }
  80. .card-meta { font-size: 11px; color: #86868b; margin-top: 10px; display: flex; gap: 8px; align-items: center; }
  81. .card-meta .badge {
  82. padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500;
  83. background: #f5f5f7; color: #515154;
  84. }
  85. .badge-xhs { background: #ffe5e5; color: #c00; }
  86. .badge-zhihu { background: #e5f0ff; color: #06c; }
  87. .badge-youtube { background: #ffe9e3; color: #c33; }
  88. .badge-bili { background: #ffd8e9; color: #c63; }
  89. .card-marks {
  90. position: absolute; top: 8px; right: 8px;
  91. display: flex; gap: 4px; z-index: 2;
  92. }
  93. .mark-btn {
  94. width: 28px; height: 28px; border-radius: 999px; border: none;
  95. background: rgba(255,255,255,0.92); backdrop-filter: blur(6px);
  96. cursor: pointer; font-size: 14px; display: flex;
  97. align-items: center; justify-content: center;
  98. box-shadow: 0 1px 4px rgba(0,0,0,0.15);
  99. transition: transform .1s, background .15s;
  100. }
  101. .mark-btn:hover { transform: scale(1.1); }
  102. .mark-btn.active-y { background: #34c759; color: white; }
  103. .mark-btn.active-n { background: #ff3b30; color: white; }
  104. .card-comment-flag {
  105. position: absolute; top: 8px; left: 8px;
  106. width: 22px; height: 22px; border-radius: 999px;
  107. background: #ffd60a; color: #1d1d1f;
  108. display: flex; align-items: center; justify-content: center;
  109. font-size: 11px; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
  110. }
  111. /* Modal */
  112. .modal {
  113. position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 100;
  114. display: none; padding: 20px; overflow-y: auto;
  115. }
  116. .modal.open { display: block; }
  117. .modal-content {
  118. max-width: 820px; margin: 0 auto; background: white;
  119. border-radius: 16px; padding: 28px 32px 36px;
  120. }
  121. .modal-close {
  122. position: fixed; top: 16px; right: 24px; background: white;
  123. border: none; padding: 8px 16px; border-radius: 999px;
  124. cursor: pointer; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  125. z-index: 101;
  126. }
  127. .modal-title { font-size: 22px; font-weight: 700; margin: 0 0 10px; line-height: 1.3; }
  128. .modal-meta {
  129. font-size: 13px; color: #515154; margin: 0 0 16px;
  130. padding-bottom: 16px; border-bottom: 1px solid #f0f0f3;
  131. display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
  132. }
  133. .modal-meta a { color: #0071e3; text-decoration: none; }
  134. .modal-meta a:hover { text-decoration: underline; }
  135. .feedback-box {
  136. background: #fafafd; border: 1px solid #ececf1; border-radius: 10px;
  137. padding: 14px 16px; margin: 0 0 20px;
  138. }
  139. .feedback-actions { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
  140. .feedback-btn {
  141. padding: 6px 14px; border-radius: 8px; border: 1px solid #d2d2d7;
  142. background: white; cursor: pointer; font-size: 13px; display: inline-flex;
  143. align-items: center; gap: 6px;
  144. }
  145. .feedback-btn.active-y { background: #34c759; border-color: #34c759; color: white; }
  146. .feedback-btn.active-n { background: #ff3b30; border-color: #ff3b30; color: white; }
  147. .feedback-comment {
  148. width: 100%; min-height: 70px; padding: 10px 12px;
  149. border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px;
  150. font-family: inherit; resize: vertical; background: white;
  151. }
  152. .feedback-comment:focus { outline: none; border-color: #0071e3; }
  153. .feedback-saved {
  154. font-size: 11px; color: #86868b; margin-top: 6px;
  155. }
  156. .modal-desc {
  157. background: #f5f5f7; padding: 14px 18px; border-radius: 10px;
  158. margin: 0 0 20px; font-size: 14px; color: #1d1d1f; line-height: 1.6;
  159. border-left: 3px solid #0071e3;
  160. }
  161. .modal-images {
  162. display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  163. gap: 8px; margin: 0 0 24px;
  164. }
  165. .modal-images img {
  166. width: 100%; aspect-ratio: 1/1; object-fit: contain;
  167. border-radius: 8px; cursor: zoom-in; background: #1d1d1f;
  168. }
  169. .modal-body {
  170. font-size: 14.5px; line-height: 1.75; color: #1d1d1f; white-space: pre-wrap;
  171. word-break: break-word;
  172. }
  173. .modal-body p { margin: 0 0 10px; }
  174. .modal-body img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
  175. .modal-body a { color: #0071e3; }
  176. .modal-note {
  177. margin-top: 24px; padding: 10px 14px; background: #f5f5f7;
  178. border-radius: 8px; font-size: 12px; color: #86868b; font-family: ui-monospace, monospace;
  179. }
  180. /* Export modal */
  181. .export-textarea {
  182. width: 100%; min-height: 50vh; padding: 14px;
  183. border: 1px solid #d2d2d7; border-radius: 8px;
  184. font-family: ui-monospace, "SF Mono", Menlo, monospace;
  185. font-size: 13px; line-height: 1.55; resize: vertical;
  186. background: #fbfbfd;
  187. }
  188. .export-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
  189. /* Lightbox */
  190. .lightbox {
  191. position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 200;
  192. display: none; align-items: center; justify-content: center;
  193. padding: 20px; cursor: zoom-out;
  194. }
  195. .lightbox.open { display: flex; }
  196. .lightbox img { max-width: 96%; max-height: 96vh; }
  197. .empty { padding: 60px 24px; text-align: center; color: #86868b; font-size: 14px; }
  198. .toast {
  199. position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
  200. background: #1d1d1f; color: white; padding: 10px 18px; border-radius: 999px;
  201. font-size: 13px; opacity: 0; transition: opacity .25s; z-index: 300; pointer-events: none;
  202. }
  203. .toast.show { opacity: 1; }
  204. @media (max-width: 600px) {
  205. header h1 { display: none; }
  206. .modal-content { padding: 20px 18px 28px; }
  207. }
  208. </style>
  209. </head>
  210. <body>
  211. <header>
  212. <h1>调研查看器</h1>
  213. <span id="count" class="count"></span>
  214. <span id="stats" class="stats"></span>
  215. <input id="search" class="search" placeholder="搜索…">
  216. <label class="toggle"><input id="showHidden" type="checkbox"> 显示已 ✗</label>
  217. <button id="exportBtn" class="btn btn-primary">导出标注</button>
  218. <label class="file-label btn">
  219. 加载 JSON
  220. <input id="file" type="file" accept=".json">
  221. </label>
  222. </header>
  223. <div id="filterBar" class="filter-bar"></div>
  224. <div id="grid" class="grid"></div>
  225. <div id="modal" class="modal" onclick="if(event.target===this) closeModal()">
  226. <button class="modal-close" onclick="closeModal()">关闭 ×</button>
  227. <div id="modalContent" class="modal-content"></div>
  228. </div>
  229. <div id="exportModal" class="modal" onclick="if(event.target===this) closeExport()">
  230. <button class="modal-close" onclick="closeExport()">关闭 ×</button>
  231. <div class="modal-content">
  232. <h2 class="modal-title">标注导出</h2>
  233. <p style="color:#86868b; font-size:13px; margin:0 0 12px;">
  234. ✓/✗ 和评论仅缓存在浏览器 localStorage(不会写回 result.json)。这里整理成文本,便于复制 / 下载 / 喂给 AI。
  235. </p>
  236. <textarea id="exportText" class="export-textarea"></textarea>
  237. <div class="export-actions">
  238. <button class="btn btn-primary" onclick="copyExport()">复制全部</button>
  239. <button class="btn" onclick="downloadExport()">下载 .md</button>
  240. <button class="btn" onclick="clearAllFeedback()" style="margin-left:auto; color:#ff3b30;">清空全部标注</button>
  241. </div>
  242. </div>
  243. </div>
  244. <div id="lightbox" class="lightbox" onclick="closeLightbox()">
  245. <img id="lightboxImg" alt="">
  246. </div>
  247. <div id="toast" class="toast"></div>
  248. <script>
  249. let items = [];
  250. let activeFilter = 'all';
  251. const STATE_KEY = 'viewer-annotations:' + location.pathname;
  252. const OLD_STATE_KEY = 'viewer-feedback:' + location.pathname; // legacy migration
  253. const SHOW_HIDDEN_KEY = 'viewer-show-hidden:' + location.pathname;
  254. if (!localStorage.getItem(STATE_KEY) && localStorage.getItem(OLD_STATE_KEY)) {
  255. localStorage.setItem(STATE_KEY, localStorage.getItem(OLD_STATE_KEY));
  256. }
  257. let state = JSON.parse(localStorage.getItem(STATE_KEY) || '{}');
  258. document.getElementById('showHidden').checked = localStorage.getItem(SHOW_HIDDEN_KEY) === '1';
  259. async function loadDefault() {
  260. try {
  261. const res = await fetch('./result.json');
  262. if (!res.ok) throw new Error('not found');
  263. const data = await res.json();
  264. items = Array.isArray(data) ? data : (data.items || []);
  265. renderAll();
  266. } catch (e) {
  267. document.getElementById('grid').innerHTML =
  268. '<div class="empty">未找到同目录下的 <code>result.json</code><br>' +
  269. '请用上方"加载 JSON"选择,或用 <code>./serve.sh</code> 启动后访问。</div>';
  270. }
  271. }
  272. document.getElementById('file').addEventListener('change', async (e) => {
  273. const f = e.target.files[0];
  274. if (!f) return;
  275. try {
  276. const data = JSON.parse(await f.text());
  277. items = Array.isArray(data) ? data : (data.items || []);
  278. activeFilter = 'all';
  279. renderAll();
  280. } catch (err) { alert('JSON 解析失败:' + err.message); }
  281. });
  282. document.getElementById('search').addEventListener('input', render);
  283. document.getElementById('showHidden').addEventListener('change', (e) => {
  284. localStorage.setItem(SHOW_HIDDEN_KEY, e.target.checked ? '1' : '0');
  285. render();
  286. });
  287. document.getElementById('exportBtn').addEventListener('click', openExport);
  288. function postId(item) {
  289. return item.url || (item.title || '') + '|' + (item.note || '');
  290. }
  291. function getFb(item) {
  292. return state[postId(item)] || {};
  293. }
  294. function setFb(item, patch) {
  295. const id = postId(item);
  296. state[id] = { ...(state[id] || {}), ...patch };
  297. // Clean empty
  298. const cur = state[id];
  299. if (!cur.mark && !(cur.comment || '').trim()) delete state[id];
  300. localStorage.setItem(STATE_KEY, JSON.stringify(state));
  301. updateStats();
  302. }
  303. function channelOf(item) {
  304. if (item.channel) return item.channel;
  305. const m = (item.note || '').match(/platform=(\w+)/); // legacy
  306. if (m) return m[1];
  307. const url = item.url || '';
  308. if (url.includes('xiaohongshu')) return 'xhs';
  309. if (url.includes('zhihu')) return 'zhihu';
  310. if (url.includes('youtube')) return 'youtube';
  311. if (url.includes('bilibili')) return 'bili';
  312. return 'other';
  313. }
  314. const METRIC_LABELS = {
  315. view_count: '👁',
  316. like_count: '❤',
  317. comment_count: '💬',
  318. collect_count: '⭐',
  319. share_count: '↗',
  320. };
  321. function formatNum(n) {
  322. if (n == null) return '';
  323. if (n < 1000) return String(n);
  324. if (n < 10000) return (n/1000).toFixed(1).replace(/\.0$/,'') + 'k';
  325. if (n < 100000000) return (n/10000).toFixed(1).replace(/\.0$/,'') + 'w';
  326. return (n/100000000).toFixed(1).replace(/\.0$/,'') + '亿';
  327. }
  328. function metricsHtml(feedback, separator) {
  329. if (!feedback) return '';
  330. const parts = [];
  331. for (const [k, label] of Object.entries(METRIC_LABELS)) {
  332. const v = feedback[k];
  333. if (v != null) parts.push(`${label} ${formatNum(v)}`);
  334. }
  335. return parts.join(separator || ' · ');
  336. }
  337. function renderAll() {
  338. const platforms = {};
  339. for (const it of items) {
  340. const p = channelOf(it);
  341. platforms[p] = (platforms[p] || 0) + 1;
  342. }
  343. const keys = Object.keys(platforms).sort();
  344. const bar = document.getElementById('filterBar');
  345. bar.innerHTML = `<span class="chip ${activeFilter==='all'?'active':''}" data-f="all">全部 (${items.length})</span>` +
  346. keys.map(k => `<span class="chip ${activeFilter===k?'active':''}" data-f="${k}">${k} (${platforms[k]})</span>`).join('');
  347. bar.querySelectorAll('.chip').forEach(el => {
  348. el.onclick = () => { activeFilter = el.dataset.f; renderAll(); };
  349. });
  350. render();
  351. }
  352. function updateStats() {
  353. let y = 0, n = 0, c = 0;
  354. for (const it of items) {
  355. const fb = getFb(it);
  356. if (fb.mark === 'y') y++;
  357. else if (fb.mark === 'n') n++;
  358. if ((fb.comment || '').trim()) c++;
  359. }
  360. document.getElementById('stats').innerHTML =
  361. `<span style="color:#34c759">✓ ${y}</span><span style="color:#ff3b30">✗ ${n}</span><span>💬 ${c}</span>`;
  362. }
  363. function render() {
  364. const q = document.getElementById('search').value.trim().toLowerCase();
  365. const showHidden = document.getElementById('showHidden').checked;
  366. const filtered = items.filter(it => {
  367. const fb = getFb(it);
  368. if (!showHidden && fb.mark === 'n') return false;
  369. if (activeFilter !== 'all' && channelOf(it) !== activeFilter) return false;
  370. if (!q) return true;
  371. return [it.title, it.description, it.author, it.body, it.note, fb.comment]
  372. .some(s => (s || '').toLowerCase().includes(q));
  373. });
  374. const hidden = items.filter(it => getFb(it).mark === 'n').length;
  375. document.getElementById('count').textContent =
  376. `${filtered.length} / ${items.length}` + (hidden && !showHidden ? ` (隐藏 ${hidden})` : '');
  377. updateStats();
  378. const grid = document.getElementById('grid');
  379. if (!filtered.length) {
  380. grid.innerHTML = '<div class="empty">没有匹配的结果</div>';
  381. return;
  382. }
  383. grid.innerHTML = filtered.map(it => {
  384. const realIdx = items.indexOf(it);
  385. const channel = channelOf(it);
  386. const fb = getFb(it);
  387. const cover = it.cover || '';
  388. const imgCount = (it.images || []).length;
  389. const cardClass = 'card' + (fb.mark === 'y' ? ' checked' : fb.mark === 'n' ? ' rejected' : '');
  390. const hasComment = (fb.comment || '').trim();
  391. const metrics = metricsHtml(it.feedback);
  392. return `
  393. <div class="${cardClass}" onclick="openModal(${realIdx})">
  394. <div class="card-marks" onclick="event.stopPropagation()">
  395. <button class="mark-btn ${fb.mark==='y'?'active-y':''}" title="保留" onclick="toggleMark(${realIdx},'y')">✓</button>
  396. <button class="mark-btn ${fb.mark==='n'?'active-n':''}" title="排除(隐藏)" onclick="toggleMark(${realIdx},'n')">✗</button>
  397. </div>
  398. ${hasComment ? `<div class="card-comment-flag" title="有评论">💬</div>` : ''}
  399. ${cover
  400. ? `<img class="card-cover" src="${escapeAttr(cover)}" loading="lazy" onerror="this.outerHTML='<div class=&quot;card-cover-placeholder&quot;>图片加载失败</div>'">`
  401. : `<div class="card-cover-placeholder">无封面</div>`}
  402. <div class="card-body">
  403. <h3 class="card-title">${escapeHtml(it.title || '无标题')}</h3>
  404. <p class="card-desc">${escapeHtml(it.description || '')}</p>
  405. <div class="card-meta">
  406. <span class="badge badge-${channel}">${channel}</span>
  407. <span>${escapeHtml(it.author || '')}</span>
  408. <span>· ${imgCount} 图</span>
  409. ${metrics ? `<span>· ${metrics}</span>` : ''}
  410. </div>
  411. </div>
  412. </div>`;
  413. }).join('');
  414. }
  415. function toggleMark(idx, mark) {
  416. const it = items[idx];
  417. const cur = getFb(it).mark;
  418. setFb(it, { mark: cur === mark ? null : mark });
  419. render();
  420. }
  421. function openModal(idx) {
  422. const it = items[idx];
  423. const channel = channelOf(it);
  424. const fb = getFb(it);
  425. const metrics = metricsHtml(it.feedback);
  426. const html = `
  427. <h2 class="modal-title">${escapeHtml(it.title || '无标题')}</h2>
  428. <div class="modal-meta">
  429. <span class="badge badge-${channel}">${channel}</span>
  430. <span>${escapeHtml(it.author || '')}</span>
  431. ${metrics ? `<span style="color:#86868b;">${metrics}</span>` : ''}
  432. ${it.url ? `<a href="${escapeAttr(it.url)}" target="_blank" rel="noopener">原文 ↗</a>` : ''}
  433. </div>
  434. <div class="feedback-box">
  435. <div class="feedback-actions">
  436. <button class="feedback-btn ${fb.mark==='y'?'active-y':''}" onclick="modalMark(${idx},'y')">✓ 保留</button>
  437. <button class="feedback-btn ${fb.mark==='n'?'active-n':''}" onclick="modalMark(${idx},'n')">✗ 排除</button>
  438. <span class="feedback-saved" id="savedNote">${fb.mark || (fb.comment||'').trim() ? '已保存到浏览器' : '未标记'}</span>
  439. </div>
  440. <textarea class="feedback-comment" id="commentInput" placeholder="对这条的评论(自动保存到浏览器 localStorage)…">${escapeHtml(fb.comment || '')}</textarea>
  441. </div>
  442. ${it.description ? `<div class="modal-desc">${escapeHtml(it.description)}</div>` : ''}
  443. ${(it.images || []).length ? `
  444. <div class="modal-images">
  445. ${it.images.map(src => `<img src="${escapeAttr(src)}" loading="lazy" onclick="event.stopPropagation(); openLightbox('${escapeAttr(src)}')" onerror="this.style.display='none'">`).join('')}
  446. </div>` : ''}
  447. <div class="modal-body">${renderMarkdown(it.body || '')}</div>
  448. ${it.note ? `<div class="modal-note">${escapeHtml(it.note)}</div>` : ''}
  449. `;
  450. document.getElementById('modalContent').innerHTML = html;
  451. document.getElementById('modal').classList.add('open');
  452. document.body.style.overflow = 'hidden';
  453. // bind comment auto-save
  454. const ta = document.getElementById('commentInput');
  455. let timer = null;
  456. ta.addEventListener('input', () => {
  457. clearTimeout(timer);
  458. timer = setTimeout(() => {
  459. setFb(it, { comment: ta.value });
  460. document.getElementById('savedNote').textContent = '已保存到浏览器';
  461. }, 250);
  462. });
  463. }
  464. function modalMark(idx, mark) {
  465. const it = items[idx];
  466. const cur = getFb(it).mark;
  467. setFb(it, { mark: cur === mark ? null : mark });
  468. openModal(idx); // re-render modal to update button states
  469. render(); // re-render grid
  470. }
  471. function closeModal() {
  472. document.getElementById('modal').classList.remove('open');
  473. document.body.style.overflow = '';
  474. }
  475. function openLightbox(src) {
  476. document.getElementById('lightboxImg').src = src;
  477. document.getElementById('lightbox').classList.add('open');
  478. }
  479. function closeLightbox() {
  480. document.getElementById('lightbox').classList.remove('open');
  481. }
  482. // ── Export ──────────────────────────────────────────────
  483. function compileExport() {
  484. const now = new Date();
  485. const ts = now.toLocaleString('zh-CN', { hour12: false });
  486. const yes = items.filter(it => getFb(it).mark === 'y');
  487. const no = items.filter(it => getFb(it).mark === 'n');
  488. const onlyComment = items.filter(it => {
  489. const fb = getFb(it);
  490. return !fb.mark && (fb.comment || '').trim();
  491. });
  492. const lines = [];
  493. lines.push('# 调研标注');
  494. lines.push(`导出时间: ${ts}`);
  495. lines.push(`数据源: ${location.pathname}result.json`);
  496. lines.push(`统计: 总 ${items.length} | ✓ ${yes.length} | ✗ ${no.length} | 仅评论 ${onlyComment.length}`);
  497. lines.push('');
  498. const dump = (arr, heading) => {
  499. if (!arr.length) return;
  500. lines.push(`## ${heading} (${arr.length})`);
  501. lines.push('');
  502. for (const it of arr) {
  503. const fb = getFb(it);
  504. lines.push(`### ${it.title || '无标题'}`);
  505. lines.push(`- 描述: ${(it.description || '').replace(/\n/g, ' ')}`);
  506. if ((fb.comment || '').trim()) {
  507. lines.push('- 评论:');
  508. for (const ln of fb.comment.trim().split('\n')) lines.push(` > ${ln}`);
  509. }
  510. lines.push('');
  511. }
  512. };
  513. dump(yes, '✓ 保留');
  514. dump(no, '✗ 排除');
  515. dump(onlyComment, '💬 仅评论(无 ✓/✗ 标记)');
  516. if (!yes.length && !no.length && !onlyComment.length) {
  517. lines.push('_(暂无标注,去标记或评论一些条目吧)_');
  518. }
  519. return lines.join('\n');
  520. }
  521. function openExport() {
  522. document.getElementById('exportText').value = compileExport();
  523. document.getElementById('exportModal').classList.add('open');
  524. document.body.style.overflow = 'hidden';
  525. }
  526. function closeExport() {
  527. document.getElementById('exportModal').classList.remove('open');
  528. document.body.style.overflow = '';
  529. }
  530. async function copyExport() {
  531. const text = document.getElementById('exportText').value;
  532. try {
  533. await navigator.clipboard.writeText(text);
  534. toast('已复制');
  535. } catch {
  536. document.getElementById('exportText').select();
  537. document.execCommand('copy');
  538. toast('已复制');
  539. }
  540. }
  541. function downloadExport() {
  542. const text = document.getElementById('exportText').value;
  543. const ts = new Date().toISOString().slice(0,19).replace(/[:T]/g, '-');
  544. const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
  545. const a = document.createElement('a');
  546. a.href = URL.createObjectURL(blob);
  547. a.download = `annotations-${ts}.md`;
  548. a.click();
  549. URL.revokeObjectURL(a.href);
  550. }
  551. function clearAllFeedback() {
  552. if (!confirm('确认清空所有标记和评论?此操作不可撤销。')) return;
  553. state = {};
  554. localStorage.removeItem(STATE_KEY);
  555. document.getElementById('exportText').value = compileExport();
  556. render();
  557. toast('已清空');
  558. }
  559. function toast(msg) {
  560. const t = document.getElementById('toast');
  561. t.textContent = msg;
  562. t.classList.add('show');
  563. setTimeout(() => t.classList.remove('show'), 1400);
  564. }
  565. function escapeHtml(s) {
  566. return String(s == null ? '' : s).replace(/[&<>"']/g,
  567. c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
  568. }
  569. function escapeAttr(s) {
  570. return String(s == null ? '' : s).replace(/"/g, '&quot;');
  571. }
  572. function renderMarkdown(s) {
  573. if (!s) return '';
  574. try { return marked.parse(s, { breaks: true }); }
  575. catch { return escapeHtml(s); }
  576. }
  577. document.addEventListener('keydown', e => {
  578. if (e.key === 'Escape') {
  579. if (document.getElementById('lightbox').classList.contains('open')) closeLightbox();
  580. else if (document.getElementById('exportModal').classList.contains('open')) closeExport();
  581. else if (document.getElementById('modal').classList.contains('open')) closeModal();
  582. }
  583. });
  584. loadDefault();
  585. </script>
  586. </body>
  587. </html>