仰望星辰工作室

clearlove2.1

clearlove2.1/ web/static/app.js 42.5 KB · 1,144 行 原始文件
1/* ===================================================================
2 ClearLove 表白墙 2.0 前端脚本(原生 JS,无任何第三方依赖)
3 功能:浏览器指纹、瀑布流无限滚动、点赞/评论/举报、发帖上传(进度条)、
4 验证码、公告每日一次展示、安装向导、后台交互。
5 =================================================================== */
6(function () {
7 'use strict';
8
9 var CSRF = (document.querySelector('meta[name=csrf]') || {}).content || '';
10
11 /* ---------------- 基础工具 ---------------- */
12
13 function el(tag, cls, text) {
14 var e = document.createElement(tag);
15 if (cls) e.className = cls;
16 if (text !== undefined && text !== null) e.textContent = text;
17 return e;
18 }
19
20 function svgIcon(path, size) {
21 var s = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="' + (size || 16) +
22 '" height="' + (size || 16) + '" fill="none" stroke="currentColor" stroke-width="2">' + path + '</svg>';
23 var span = document.createElement('span');
24 span.innerHTML = s;
25 return span.firstChild;
26 }
27
28 var ICONS = {
29 heart: '<path d="M12 21s-8-5.2-8-10.4C4 7 6.2 5 8.8 5c1.6 0 2.7.8 3.2 1.7C12.5 5.8 13.6 5 15.2 5 17.8 5 20 7 20 10.6 20 15.8 12 21 12 21z"/>',
30 comment: '<path d="M21 12a8 8 0 1 1-3.2-6.4L21 4l-1 4.4c.6 1.1 1 2.3 1 3.6z"/>'
31 };
32
33 // 浏览器指纹(UA + 语言 + 屏幕 + canvas 渲染哈希),用于点赞去重与封禁
34 var _fp = null;
35 function fingerprint() {
36 if (_fp) return _fp;
37 try {
38 var parts = [
39 navigator.userAgent, navigator.language, screen.width + 'x' + screen.height,
40 screen.colorDepth, new Date().getTimezoneOffset()
41 ];
42 var c = document.createElement('canvas');
43 c.width = 200; c.height = 40;
44 var ctx = c.getContext('2d');
45 ctx.textBaseline = 'top';
46 ctx.font = '14px Arial';
47 ctx.fillStyle = '#f60';
48 ctx.fillRect(0, 0, 100, 20);
49 ctx.fillStyle = '#069';
50 ctx.fillText('ClearLove', 2, 15);
51 parts.push(c.toDataURL());
52 var str = parts.join('|'), h = 5381;
53 for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0;
54 _fp = 'fp' + (h >>> 0).toString(16);
55 } catch (e) {
56 _fp = 'fp' + Math.random().toString(16).slice(2);
57 }
58 return _fp;
59 }
60
61 function api(url, opts) {
62 opts = opts || {};
63 opts.headers = Object.assign({
64 'X-CSRF-Token': CSRF,
65 'X-Fingerprint': fingerprint(),
66 'X-Requested-With': 'XMLHttpRequest'
67 }, opts.headers || {});
68 return fetch(url, opts).then(function (r) {
69 return r.json().catch(function () { return { ok: r.ok }; });
70 });
71 }
72
73 function toast(msg) { alert(msg); }
74
75 /* ---------------- 公告:每天展示一次 ---------------- */
76
77 window.dismissNotice = function () {
78 var bar = document.getElementById('notice-bar');
79 if (!bar) return;
80 try {
81 localStorage.setItem('clv_notice_' + bar.dataset.nid + '_' + new Date().toDateString(), '1');
82 } catch (e) { /* 隐私模式下忽略 */ }
83 bar.remove();
84 };
85
86 function initNotice() {
87 var bar = document.getElementById('notice-bar');
88 if (!bar) return;
89 var key = 'clv_notice_' + bar.dataset.nid + '_' + new Date().toDateString();
90 var seen = false;
91 try { seen = localStorage.getItem(key) === '1'; } catch (e) { }
92 if (seen) bar.remove();
93 }
94
95 /* ---------------- 瀑布流:无限滚动 + 懒加载 ---------------- */
96
97 function mediaEl(src, isVideo) {
98 if (isVideo) {
99 var v = el('video', 'video');
100 v.src = src; v.controls = true; v.preload = 'metadata';
101 return v;
102 }
103 var img = el('img', 'media');
104 img.loading = 'lazy'; // 懒加载
105 img.decoding = 'async';
106 img.alt = '帖子图片';
107 img.src = src;
108 return img;
109 }
110
111 function timeText(s) {
112 if (!s) return '';
113 var d = new Date(s);
114 if (isNaN(d.getTime())) return s;
115 function p(n) { return n < 10 ? '0' + n : '' + n; }
116 return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
117 }
118
119 function cardEl(p) {
120 var art = el('article', 'card post-card');
121
122 var head = el('div', 'card-head');
123 head.appendChild(el('span', 'avatar', (p.nickname || '匿')[0]));
124 var meta = el('div', 'meta');
125 // 匿名帖 user_id=0 无个人主页,渲染为纯文本避免点击 404
126 var nick;
127 if (p.user_id) {
128 nick = el('a', 'nickname', p.nickname || '匿名');
129 nick.href = '/u/' + p.user_id;
130 } else {
131 nick = el('span', 'nickname', p.nickname || '匿名');
132 }
133 var nickRow = el('div', 'nick-row');
134 nickRow.appendChild(nick);
135 // 帖子标识(内置「管理员」+ 插件 badge 钩子写入)
136 var badges = p.badges || [];
137 for (var bi = 0; bi < badges.length; bi++) {
138 nickRow.appendChild(el('span', 'post-badge', badges[bi]));
139 }
140 meta.appendChild(nickRow);
141 var t = el('time', '', timeText(p.created_at));
142 meta.appendChild(t);
143 head.appendChild(meta);
144 art.appendChild(head);
145
146 var link = el('a', 'card-link');
147 link.href = '/post/' + p.id;
148 if (p.topic) {
149 var chip = el('span', 'chip', '# ' + p.topic);
150 chip.style.marginBottom = '6px';
151 link.appendChild(chip);
152 }
153 link.appendChild(el('p', 'content', p.content || ''));
154 if (p.images && p.images.length) {
155 var grid = el('div', 'media-grid media-' + Math.min(p.images.length, 4));
156 for (var i = 0; i < p.images.length; i++) grid.appendChild(mediaEl(p.images[i], false));
157 link.appendChild(grid);
158 }
159 if (p.videos && p.videos.length) {
160 for (var j = 0; j < p.videos.length; j++) link.appendChild(mediaEl(p.videos[j], true));
161 }
162 art.appendChild(link);
163
164 // 最新4条评论(点击卡片区域进入详情)
165 var cmts = p.comments || [];
166 if (cmts.length) {
167 var box = el('div', 'preview-comments');
168 for (var k = 0; k < cmts.length; k++) {
169 var row = el('p');
170 row.appendChild(el('span', 'pc-nick', (cmts[k].nickname || '匿名') + ':'));
171 row.appendChild(document.createTextNode(cmts[k].content || ''));
172 box.appendChild(row);
173 }
174 if (p.comment_count > cmts.length) {
175 var all = el('a', 'view-all', '查看全部 ' + p.comment_count + ' 条评论');
176 all.href = '/post/' + p.id;
177 box.appendChild(all);
178 }
179 art.appendChild(box);
180 }
181
182 var acts = el('div', 'card-actions');
183 var like = el('button', 'act like-btn');
184 like.type = 'button';
185 like.dataset.id = p.id;
186 like.appendChild(svgIcon(ICONS.heart, 17));
187 like.appendChild(el('span', 'like-count', p.likes || 0));
188 acts.appendChild(like);
189 var cm = el('span', 'act');
190 cm.appendChild(svgIcon(ICONS.comment, 17));
191 cm.appendChild(document.createTextNode(p.comment_count || 0));
192 acts.appendChild(cm);
193 art.appendChild(acts);
194
195 // 点击卡片主体进入详情
196 art.addEventListener('click', function (ev) {
197 if (ev.target.closest('button, a, video')) return;
198 window.location.href = '/post/' + p.id;
199 });
200 applyCardHooks(art, p);
201 return art;
202 }
203
204 /* 插件卡片扩展点:CLV.hooks.cardHtml / cardEl / afterRender
205 插件通过注入脚本注册(见 docs/PLUGIN-APP.md 第 9.7 节) */
206 function applyCardHooks(art, p) {
207 var hooks = window.CLV && window.CLV.hooks;
208 if (!hooks) return;
209 (hooks.cardHtml || []).forEach(function (fn) {
210 try {
211 var extra = fn('', p);
212 if (extra) {
213 var box = el('div', 'clv-plugin-card-extra');
214 box.innerHTML = extra;
215 art.appendChild(box);
216 }
217 } catch (e) { }
218 });
219 (hooks.cardEl || []).forEach(function (fn) { try { fn(art, p); } catch (e) { } });
220 (hooks.afterRender || []).forEach(function (fn) { try { fn(art, p); } catch (e) { } });
221 }
222
223 function initMasonry() {
224 var box = document.getElementById('masonry');
225 if (!box) return;
226 var state = document.getElementById('load-state');
227 var sentinel = document.getElementById('sentinel');
228 var topic = box.dataset.topic || '';
229 var last = 0, loading = false, done = false;
230 var io = null;
231 var PAGE = 10;
232
233 // 页面底部是否已进入预加载范围
234 function nearBottom() {
235 return window.innerHeight + window.scrollY > document.body.offsetHeight - 500;
236 }
237
238 // 重新观察哨兵。observe() 会立即按"当前"相交状态回调一次,而这正是关键:
239 // IntersectionObserver 只在相交状态**变化**时回调,若一批数据插入后哨兵仍在
240 // 预加载范围内,状态没变就不会再回调,页面就会永远卡在"加载中..."。
241 // 内容不足以填满屏幕时(话题栏收起后页面变短尤其明显),还要靠它续加载。
242 function kick() {
243 if (io && sentinel && sentinel.parentNode) {
244 io.unobserve(sentinel);
245 io.observe(sentinel);
246 } else if (!io && nearBottom()) {
247 load();
248 }
249 }
250
251 function finish(msg) {
252 done = true;
253 state.textContent = msg;
254 if (sentinel) sentinel.remove();
255 if (io) { io.disconnect(); io = null; }
256 }
257
258 function load() {
259 if (loading || done) return;
260 loading = true;
261 state.textContent = '加载中...';
262 api('/api/v1/posts?before=' + last + '&limit=' + PAGE + '&topic=' + encodeURIComponent(topic))
263 .then(function (r) {
264 loading = false;
265 if (!r || !r.ok) {
266 state.textContent = '加载失败,请刷新页面重试';
267 return;
268 }
269 var posts = r.posts || [];
270 posts.forEach(function (p) { box.appendChild(cardEl(p)); });
271 if (posts.length) last = posts[posts.length - 1].id;
272 if (posts.length < PAGE) {
273 finish(last ? '已经到底啦' : '还没有帖子,点击右下角发布第一条');
274 return;
275 }
276 // 还有更多:清掉加载提示,并让哨兵重新判断是否需要立即续加载
277 state.textContent = '';
278 kick();
279 })
280 .catch(function () {
281 loading = false;
282 state.textContent = '网络异常,请稍后重试';
283 });
284 }
285
286 if (sentinel && 'IntersectionObserver' in window) {
287 io = new IntersectionObserver(function (entries) {
288 if (entries[0].isIntersecting) load();
289 }, { rootMargin: '400px' });
290 io.observe(sentinel);
291 } else {
292 // 兜底:滚动监听(首屏内容不足时同样需要主动续加载)
293 window.addEventListener('scroll', function () { if (nearBottom()) load(); });
294 }
295 load();
296 }
297
298 /* ---------------- 点赞 / 举报(事件委托) ---------------- */
299
300 document.addEventListener('click', function (ev) {
301 var like = ev.target.closest('.like-btn');
302 if (like) {
303 ev.preventDefault();
304 var id = like.dataset.id;
305 api('/api/v1/posts/' + id + '/like', { method: 'POST' })
306 .then(function (r) {
307 if (!r || !r.ok) { toast((r && r.msg) || '操作失败'); return; }
308 var span = like.querySelector('.like-count');
309 if (span) span.textContent = r.likes;
310 like.classList.toggle('liked', !!r.liked);
311 });
312 return;
313 }
314 var rep = ev.target.closest('.report-btn');
315 if (rep) {
316 ev.preventDefault();
317 var reason = prompt('请填写举报理由(例如:广告、辱骂、违规内容)');
318 if (!reason) return;
319 var fd = new FormData();
320 fd.append('post_id', rep.dataset.id);
321 fd.append('reason', reason);
322 api('/api/v1/reports', { method: 'POST', body: fd }).then(function (r) {
323 toast((r && r.msg) || (r.ok ? '举报已提交' : '举报失败'));
324 });
325 }
326 });
327
328 /* ---------------- 评论表单 ---------------- */
329
330 function initCommentForm() {
331 var form = document.getElementById('comment-form');
332 if (!form) return;
333 var list = document.getElementById('comment-list');
334 form.addEventListener('submit', function (ev) {
335 ev.preventDefault();
336 var fd = new FormData(form);
337 fd.append('post_id', form.dataset.post);
338 var btn = form.querySelector('button[type=submit]');
339 btn.disabled = true;
340 api('/api/v1/comments', { method: 'POST', body: fd }).then(function (r) {
341 btn.disabled = false;
342 if (!r || !r.ok) { toast((r && r.msg) || '评论失败'); return; }
343 var empty = list.querySelector('.empty');
344 if (empty) empty.remove();
345 var item = el('div', 'comment-item');
346 item.appendChild(el('span', 'avatar small', (fd.get('nickname') || '匿')[0]));
347 var body = el('div', 'comment-body');
348 body.appendChild(el('span', 'nickname', fd.get('nickname') || '匿名'));
349 body.appendChild(el('p', '', fd.get('content')));
350 item.appendChild(body);
351 list.appendChild(item);
352 form.reset();
353 });
354 });
355 }
356
357 /* ---------------- 发帖页:验证码 + 上传进度 ---------------- */
358
359 function loadCaptcha() {
360 var box = document.getElementById('captcha-box');
361 if (!box) return;
362 api('/api/captcha').then(function (r) {
363 if (!r || !r.id) return;
364 box.innerHTML = r.svg;
365 box.dataset.id = r.id;
366 });
367 }
368
369 /* ---------------- 发帖 AI 预审动画 ---------------- */
370
371 // 上传完成后进入 AI 审核阶段时展示,分阶段提示文案
372 function initAiScan() {
373 var overlay = document.getElementById('ai-scan');
374 if (!overlay) return null;
375 var sub = document.getElementById('ai-scan-sub');
376 var steps = ['正在提取内容特征', '正在比对社区守则', 'AI 正在给出审核结论', '即将完成'];
377 var idx = 0, timer = null;
378 return {
379 show: function () {
380 idx = 0;
381 if (sub) sub.textContent = steps[0];
382 overlay.hidden = false;
383 if (timer) clearInterval(timer);
384 timer = setInterval(function () {
385 idx = Math.min(idx + 1, steps.length - 1);
386 if (sub) sub.textContent = steps[idx];
387 }, 2200);
388 },
389 hide: function () {
390 if (timer) { clearInterval(timer); timer = null; }
391 overlay.hidden = true;
392 }
393 };
394 }
395
396 function initCompose() {
397 var form = document.getElementById('compose-form');
398 if (!form) return;
399 var aiScan = initAiScan(); // AI 预审开启时才有该元素
400 var errBox = document.getElementById('compose-err');
401 function showErr(msg) {
402 if (errBox) { errBox.textContent = msg; errBox.hidden = false; }
403 else toast(msg);
404 }
405
406 /* ---- 管理员专享昵称:命中时需验证后台账号 ---- */
407 var adminNicks = (form.dataset.adminNicks || '').split(',').map(function (s) {
408 return s.trim().toLowerCase();
409 }).filter(function (s) { return s; });
410 var adminAuth = { user: '', pass: '' }; // 本次发布携带的后台凭证
411
412 function isReservedNick(name) {
413 return adminNicks.indexOf(String(name || '').trim().toLowerCase()) >= 0;
414 }
415
416 /* ---- 媒体上传:拖拽(PC)+ 点击选择(移动端/PC) ---- */
417 var dropZone = document.getElementById('drop-zone');
418 var input = document.getElementById('media-input');
419 var previews = document.getElementById('media-previews');
420 var maxImages = Number(dropZone && dropZone.dataset.maxImages) || 15;
421 var maxVideos = Number(dropZone && dropZone.dataset.maxVideos) || 3;
422 var MAX_IMG = 10 * 1024 * 1024, MAX_VID = 70 * 1024 * 1024;
423 var selected = []; // { file, url, type }
424
425 function humanSize(n) {
426 if (n >= 1048576) return (n / 1048576).toFixed(1) + 'MB';
427 return Math.max(1, Math.round(n / 1024)) + 'KB';
428 }
429 function countType(t) {
430 var n = 0;
431 for (var i = 0; i < selected.length; i++) if (selected[i].type === t) n++;
432 return n;
433 }
434 function renderPreviews() {
435 if (!previews) return;
436 previews.innerHTML = '';
437 selected.forEach(function (it, idx) {
438 var item = el('div', 'media-thumb');
439 if (it.type === 'image') {
440 var im = el('img');
441 im.src = it.url;
442 im.alt = it.file.name;
443 item.appendChild(im);
444 } else {
445 item.appendChild(el('div', 'media-file-icon', '视频'));
446 }
447 var rm = el('button', 'media-remove', '×');
448 rm.type = 'button';
449 rm.title = '移除';
450 rm.addEventListener('click', function () { removeAt(idx); });
451 item.appendChild(rm);
452 item.appendChild(el('span', 'media-name', it.file.name));
453 item.appendChild(el('span', 'media-size', humanSize(it.file.size)));
454 previews.appendChild(item);
455 });
456 }
457 function removeAt(idx) {
458 var it = selected[idx];
459 if (it && it.url) URL.revokeObjectURL(it.url);
460 selected.splice(idx, 1);
461 renderPreviews();
462 }
463 function addFiles(files) {
464 var errs = [];
465 Array.prototype.slice.call(files).forEach(function (f) {
466 var isImg = /^image\//.test(f.type);
467 var isVid = /^video\//.test(f.type);
468 if (!isImg && !isVid) { errs.push(f.name + ':不支持的格式'); return; }
469 if (isImg) {
470 if (f.size > MAX_IMG) { errs.push(f.name + ':图片不能超过 10MB'); return; }
471 if (countType('image') >= maxImages) { errs.push('图片最多 ' + maxImages + ' 张'); return; }
472 } else {
473 if (f.size > MAX_VID) { errs.push(f.name + ':视频不能超过 70MB'); return; }
474 if (countType('video') >= maxVideos) { errs.push('视频最多 ' + maxVideos + ' 个'); return; }
475 }
476 selected.push({ file: f, url: isImg ? URL.createObjectURL(f) : '', type: isImg ? 'image' : 'video' });
477 });
478 renderPreviews();
479 if (errs.length) showErr(errs.join(';'));
480 }
481
482 if (dropZone && input) {
483 dropZone.addEventListener('click', function () { input.click(); });
484 dropZone.addEventListener('keydown', function (e) {
485 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); }
486 });
487 input.addEventListener('change', function () { addFiles(input.files); input.value = ''; });
488 ['dragenter', 'dragover'].forEach(function (t) {
489 dropZone.addEventListener(t, function (e) {
490 e.preventDefault(); e.stopPropagation(); dropZone.classList.add('dragover');
491 });
492 });
493 ['dragleave', 'drop'].forEach(function (t) {
494 dropZone.addEventListener(t, function (e) {
495 e.preventDefault(); e.stopPropagation(); dropZone.classList.remove('dragover');
496 });
497 });
498 dropZone.addEventListener('drop', function (e) {
499 if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
500 });
501 // 拖到页面其他位置时阻止浏览器直接打开文件
502 ['dragover', 'drop'].forEach(function (t) {
503 document.addEventListener(t, function (e) { if (!dropZone.contains(e.target)) e.preventDefault(); });
504 });
505 }
506
507 /* ---- 验证码弹窗 ---- */
508 var modal = document.getElementById('captcha-modal');
509 var codeInput = document.getElementById('captcha-code');
510 var captchaBox = document.getElementById('captcha-box');
511 var captchaErr = document.getElementById('captcha-err');
512 var confirmBtn = document.getElementById('captcha-confirm');
513 var cancelBtn = document.getElementById('captcha-cancel');
514 var submitBtn = document.getElementById('compose-submit');
515
516 function openCaptcha() {
517 if (!modal) return;
518 if (captchaErr) captchaErr.hidden = true;
519 if (codeInput) codeInput.value = '';
520 loadCaptcha();
521 modal.hidden = false;
522 if (codeInput) setTimeout(function () { codeInput.focus(); }, 50);
523 }
524 function closeCaptcha() { if (modal) modal.hidden = true; }
525 function resetBtns() {
526 if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = '确认发布'; }
527 if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '发布'; }
528 var bar = document.getElementById('upload-progress');
529 if (bar) {
530 bar.hidden = true;
531 var span = bar.firstElementChild;
532 if (span) span.style.width = '0%';
533 }
534 }
535
536 if (captchaBox) captchaBox.addEventListener('click', loadCaptcha);
537 if (cancelBtn) cancelBtn.addEventListener('click', closeCaptcha);
538 if (modal) {
539 var mask = modal.querySelector('.modal-mask');
540 if (mask) mask.addEventListener('click', closeCaptcha);
541 }
542 if (codeInput) {
543 codeInput.addEventListener('keydown', function (e) {
544 if (e.key === 'Enter') { e.preventDefault(); if (confirmBtn) confirmBtn.click(); }
545 });
546 }
547 document.addEventListener('keydown', function (e) {
548 if (e.key === 'Escape' && modal && !modal.hidden) closeCaptcha();
549 });
550
551 /* ---- 管理员身份验证弹窗 ---- */
552 var avModal = document.getElementById('admin-verify-modal');
553 var avNick = document.getElementById('admin-verify-nick');
554 var avUser = document.getElementById('admin-user');
555 var avPass = document.getElementById('admin-pass');
556 var avErr = document.getElementById('admin-verify-err');
557 var avOk = document.getElementById('admin-verify-ok');
558 var avCancel = document.getElementById('admin-verify-cancel');
559
560 function closeAdminVerify() { if (avModal) avModal.hidden = true; }
561
562 function openAdminVerify(nick) {
563 if (!avModal) return;
564 if (avNick) avNick.textContent = nick;
565 if (avErr) avErr.hidden = true;
566 if (avUser) avUser.value = '';
567 if (avPass) avPass.value = '';
568 avModal.hidden = false;
569 if (avUser) setTimeout(function () { avUser.focus(); }, 50);
570 }
571
572 if (avCancel) avCancel.addEventListener('click', closeAdminVerify);
573 if (avModal) {
574 var avMask = avModal.querySelector('.modal-mask');
575 if (avMask) avMask.addEventListener('click', closeAdminVerify);
576 }
577 if (avPass) {
578 avPass.addEventListener('keydown', function (e) {
579 if (e.key === 'Enter') { e.preventDefault(); if (avOk) avOk.click(); }
580 });
581 }
582 document.addEventListener('keydown', function (e) {
583 if (e.key === 'Escape' && avModal && !avModal.hidden) closeAdminVerify();
584 });
585 if (avOk) {
586 avOk.addEventListener('click', function () {
587 var u = (avUser ? avUser.value : '').trim();
588 var p = avPass ? avPass.value : '';
589 if (!u || !p) {
590 if (avErr) { avErr.textContent = '请输入后台账号与密码'; avErr.hidden = false; }
591 return;
592 }
593 adminAuth = { user: u, pass: p };
594 closeAdminVerify();
595 openCaptcha(); // 验证通过后继续走验证码流程
596 });
597 }
598
599 // 验证码通过后真正提交(XHR 带上传进度 + AI 预审动画)
600 function doSubmit(code) {
601 if (captchaErr) captchaErr.hidden = true;
602 if (errBox) errBox.hidden = true;
603 if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = '发布中...'; }
604 if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '发布中...'; }
605
606 var fd = new FormData(form);
607 // 按类型分配到服务端约定的 images / videos 字段
608 selected.forEach(function (it) {
609 fd.append(it.type === 'video' ? 'videos' : 'images', it.file, it.file.name);
610 });
611 fd.set('captcha_id', captchaBox ? (captchaBox.dataset.id || '') : '');
612 fd.set('captcha_code', code);
613 fd.set('fingerprint', fingerprint());
614 // 管理员专享昵称:后台账号凭证(服务端强制校验)
615 fd.set('admin_user', adminAuth.user || '');
616 fd.set('admin_pass', adminAuth.pass || '');
617
618 var xhr = new XMLHttpRequest();
619 xhr.open('POST', '/compose');
620 xhr.setRequestHeader('X-CSRF-Token', CSRF);
621 xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
622 xhr.upload.onprogress = function (e) {
623 if (!e.lengthComputable) return;
624 var pct = Math.round(e.loaded / e.total * 100);
625 var bar = document.getElementById('upload-progress');
626 if (bar) {
627 bar.hidden = false;
628 var span = bar.firstElementChild;
629 if (span) span.style.width = pct + '%';
630 }
631 };
632 // 媒体上传完成、等待服务端(含 AI 预审)响应期间展示审核动画
633 xhr.upload.onload = function () { closeCaptcha(); if (aiScan) aiScan.show(); };
634 xhr.onload = function () {
635 if (aiScan) aiScan.hide();
636 resetBtns();
637 var r = {};
638 try { r = JSON.parse(xhr.responseText); } catch (e) { }
639 if (xhr.status === 200 && r.ok) {
640 window.location.href = r.redirect || '/';
641 return;
642 }
643 closeCaptcha();
644 showErr(r.msg || '发布失败,请稍后重试');
645 };
646 xhr.onerror = function () {
647 if (aiScan) aiScan.hide();
648 resetBtns();
649 closeCaptcha();
650 showErr('网络异常,请稍后重试');
651 };
652 xhr.send(fd);
653 }
654
655 // 点击「发布」:基础校验 → (管理员专享昵称时)验证后台账号 → 验证码
656 form.addEventListener('submit', function (ev) {
657 ev.preventDefault();
658 if (errBox) errBox.hidden = true;
659 var content = form.querySelector('[name=content]');
660 if (content && !content.value.trim()) { showErr('请填写内容'); return; }
661 adminAuth = { user: '', pass: '' };
662 var nickInput = form.querySelector('[name=nickname]');
663 var nick = nickInput ? nickInput.value : '';
664 if (isReservedNick(nick)) {
665 openAdminVerify(String(nick).trim() || '匿名');
666 return;
667 }
668 openCaptcha();
669 });
670
671 if (confirmBtn) {
672 confirmBtn.addEventListener('click', function () {
673 var code = (codeInput ? codeInput.value : '').trim();
674 if (!/^\d{4}$/.test(code)) {
675 if (captchaErr) { captchaErr.textContent = '请输入 4 位数字验证码'; captchaErr.hidden = false; }
676 return;
677 }
678 doSubmit(code);
679 });
680 }
681 }
682
683 /* ---------------- 注册验证码 ---------------- */
684
685 function initSendCode() {
686 var btn = document.getElementById('send-code');
687 if (!btn) return;
688 btn.addEventListener('click', function () {
689 var emailInput = document.querySelector('input[name=' + btn.dataset.email + ']');
690 if (!emailInput || !emailInput.value) { toast('请先填写邮箱'); return; }
691 var fd = new FormData();
692 fd.append('email', emailInput.value);
693 btn.disabled = true;
694 api('/api/register-code', { method: 'POST', body: fd }).then(function (r) {
695 if (!r || !r.ok) {
696 toast((r && r.msg) || '发送失败');
697 btn.disabled = false;
698 return;
699 }
700 var left = 60;
701 btn.textContent = left + ' 秒后重发';
702 var timer = setInterval(function () {
703 left--;
704 btn.textContent = left + ' 秒后重发';
705 if (left <= 0) {
706 clearInterval(timer);
707 btn.disabled = false;
708 btn.textContent = '发送验证码';
709 }
710 }, 1000);
711 });
712 });
713 }
714
715 /* ---------------- 安装向导 ---------------- */
716
717 function initInstall() {
718 var form = document.getElementById('install-form');
719 if (!form) return;
720 var step = 1;
721 var dots = form.querySelectorAll('.step-dot');
722 var sections = form.querySelectorAll('.install-step');
723 var prev = document.getElementById('install-prev');
724 var next = document.getElementById('install-next');
725 var submit = document.getElementById('install-submit');
726 var errBox = document.getElementById('install-err');
727
728 // MySQL 字段显隐
729 var mysqlFields = document.getElementById('mysql-fields');
730 form.querySelectorAll('input[name=db_type]').forEach(function (radio) {
731 radio.addEventListener('change', function () {
732 mysqlFields.hidden = form.querySelector('input[name=db_type]:checked').value !== 'mysql';
733 });
734 });
735
736 // 分步模式:JS 可用时隐藏非当前步骤;提交按钮始终可见(便于无 JS 降级),
737 // 前两步降为次级样式,把「下一步」作为主操作
738 function render() {
739 sections.forEach(function (s) { s.hidden = Number(s.dataset.step) !== step; });
740 dots.forEach(function (d) { d.classList.toggle('active', Number(d.dataset.step) <= step); });
741 prev.hidden = step === 1;
742 next.hidden = step === 3;
743 submit.classList.toggle('btn-primary', step === 3);
744 submit.classList.toggle('btn-ghost', step !== 3);
745 errBox.hidden = true;
746 }
747
748 function validate(s) {
749 if (s === 1 && form.querySelector('input[name=db_type]:checked').value === 'mysql') {
750 var need = ['mysql_host', 'mysql_port', 'mysql_name', 'mysql_user'];
751 for (var i = 0; i < need.length; i++) {
752 if (!form.querySelector('[name=' + need[i] + ']').value.trim()) {
753 errBox.textContent = '请完整填写 MySQL 连接信息';
754 errBox.hidden = false;
755 return false;
756 }
757 }
758 }
759 if (s === 2 && !form.querySelector('[name=site_name]').value.trim()) {
760 errBox.textContent = '请填写网站名称';
761 errBox.hidden = false;
762 return false;
763 }
764 return true;
765 }
766
767 next.addEventListener('click', function () {
768 if (!validate(step)) return;
769 step = Math.min(3, step + 1);
770 render();
771 });
772 prev.addEventListener('click', function () {
773 step = Math.max(1, step - 1);
774 render();
775 });
776
777 form.addEventListener('submit', function (ev) {
778 ev.preventDefault();
779 // 提交前定位到首个校验失败的步骤
780 if (!validate(1)) { step = 1; render(); return; }
781 if (!validate(2)) { step = 2; render(); return; }
782 step = 3;
783 render();
784 var user = form.querySelector('[name=admin_user]').value.trim();
785 var pass = form.querySelector('[name=admin_pass]').value;
786 if (user.length < 3 || pass.length < 6) {
787 errBox.textContent = '管理员用户名至少 3 位、密码至少 6 位';
788 errBox.hidden = false;
789 return;
790 }
791 submit.disabled = true;
792 submit.textContent = '安装中...';
793 fetch('/install', {
794 method: 'POST',
795 headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': CSRF },
796 body: new URLSearchParams(new FormData(form)).toString()
797 }).then(function (r) { return r.json(); }).then(function (r) {
798 if (r && r.ok) { window.location.href = r.redirect || '/'; return; }
799 errBox.textContent = (r && r.msg) || '安装失败,请检查填写内容';
800 errBox.hidden = false;
801 submit.disabled = false;
802 submit.textContent = '完成安装';
803 }).catch(function () {
804 errBox.textContent = '网络异常,请稍后重试';
805 errBox.hidden = false;
806 submit.disabled = false;
807 submit.textContent = '完成安装';
808 });
809 });
810 render();
811 }
812
813 /* ---------------- AI 巡查进度动画(后台举报页) ---------------- */
814
815 function initPatrol() {
816 var overlay = document.getElementById('patrol-overlay');
817 if (!overlay) return;
818 var btn = document.getElementById('patrol-btn');
819 var ring = document.getElementById('patrol-ring');
820 var CIRC = 326.7; // 2πr, r=52
821 var timer = null;
822
823 function txt(id, v) { var e = document.getElementById(id); if (e) e.textContent = v; }
824
825 function render(p, percent) {
826 if (ring) ring.style.strokeDashoffset = String(CIRC * (1 - (percent || 0) / 100));
827 txt('patrol-pct', percent || 0);
828 txt('patrol-total', p.total || 0);
829 txt('patrol-done', p.done || 0);
830 txt('patrol-keep', p.kept || 0);
831 txt('patrol-hide', p.hidden || 0);
832 txt('patrol-del', p.deleted || 0);
833 var bar = document.getElementById('patrol-bar');
834 if (bar) bar.style.width = (percent || 0) + '%';
835 var sub = document.getElementById('patrol-sub');
836 if (sub) {
837 sub.textContent = p.stage || '正在准备';
838 if (p.running) {
839 var d = el('span', 'dots');
840 d.appendChild(el('i')); d.appendChild(el('i')); d.appendChild(el('i'));
841 sub.appendChild(d);
842 }
843 }
844 if (p.current && p.running) {
845 var cur = document.getElementById('patrol-sub');
846 if (cur) cur.title = '当前检查:' + p.current;
847 }
848 }
849
850 function finish(p) {
851 var card = document.getElementById('patrol-card');
852 if (card) card.classList.add('scan-done');
853 txt('patrol-title', '巡查完成');
854 var sub = document.getElementById('patrol-sub');
855 if (sub) sub.textContent = p.message || '本轮巡查已结束';
856 var fin = document.getElementById('patrol-finish');
857 if (fin) fin.hidden = false;
858 if (timer) { clearInterval(timer); timer = null; }
859 var hb = document.getElementById('patrol-hide-btn');
860 if (hb) hb.hidden = true;
861 }
862
863 function poll() {
864 api('/admin/reports/patrol/status').then(function (r) {
865 if (!r || !r.ok || !r.progress) return;
866 var p = r.progress;
867 render(p, r.percent);
868 if (!p.running) finish(p);
869 });
870 }
871
872 function startPolling() {
873 poll();
874 if (timer) clearInterval(timer);
875 timer = setInterval(poll, 1000);
876 }
877
878 function reset() {
879 var card = document.getElementById('patrol-card');
880 if (card) card.classList.remove('scan-done');
881 txt('patrol-title', 'AI 智能巡查中');
882 var hb = document.getElementById('patrol-hide-btn');
883 if (hb) hb.hidden = false;
884 var fin = document.getElementById('patrol-finish');
885 if (fin) fin.hidden = true;
886 render({ total: 0, done: 0, running: true, stage: '正在启动巡查' }, 0);
887 }
888
889 // 巡查入口是 POST 表单:JS 可用时拦截提交并播放进度动画,
890 // JS 不可用时正常提交(服务端 303 回本页,刷新不会重复触发)
891 var form = document.getElementById('patrol-form');
892 if (form) {
893 form.addEventListener('submit', function (ev) {
894 ev.preventDefault();
895 if (btn) btn.disabled = true;
896 reset();
897 overlay.hidden = false;
898 api('/admin/reports/patrol', { method: 'POST' }).then(function (r) {
899 if (btn) btn.disabled = false;
900 if (!r || !r.ok) {
901 overlay.hidden = true;
902 toast((r && r.msg) || '巡查启动失败');
903 return;
904 }
905 startPolling();
906 });
907 });
908 }
909
910 var hideBtn = document.getElementById('patrol-hide-btn');
911 if (hideBtn) {
912 hideBtn.addEventListener('click', function () { overlay.hidden = true; });
913 }
914 var finBtn = document.getElementById('patrol-finish');
915 if (finBtn) {
916 finBtn.addEventListener('click', function () { window.location.reload(); });
917 }
918
919 // 进入页面时若已有巡查在后台运行,自动展示进度
920 if (overlay.dataset.running === '1') {
921 overlay.hidden = false;
922 startPolling();
923 }
924 }
925
926 /* ---------------- 后台交互 ---------------- */
927
928 window.openDonate = function () {
929 var m = document.getElementById('donate-modal');
930 if (m) m.hidden = false;
931 };
932 window.closeDonate = function () {
933 var m = document.getElementById('donate-modal');
934 if (m) m.hidden = true;
935 };
936
937 function initAdmin() {
938 // 危险操作二次确认
939 document.querySelectorAll('form[data-confirm]').forEach(function (f) {
940 f.addEventListener('submit', function (ev) {
941 if (!confirm(f.dataset.confirm)) ev.preventDefault();
942 });
943 });
944 // 移动端侧边栏
945 var toggle = document.getElementById('side-toggle');
946 var side = document.getElementById('sidebar');
947 if (toggle && side) {
948 toggle.addEventListener('click', function () { side.classList.toggle('open'); });
949 }
950 }
951
952 /* ---------------- 首页话题栏:话题过多时收起为单行 ---------------- */
953 function initTopicBar() {
954 var chips = document.getElementById('topic-chips');
955 var toggle = document.getElementById('topic-toggle');
956 if (!chips || !toggle) return;
957 var first = chips.firstElementChild;
958 if (!first) return;
959
960 // 先按默认样式渲染,若换行超过一行说明话题较多
961 var wrapped = false;
962 for (var i = 1; i < chips.children.length; i++) {
963 if (chips.children[i].offsetTop !== first.offsetTop) { wrapped = true; break; }
964 }
965 if (!wrapped) return; // 话题不多,保持原有的自动换行
966
967 chips.classList.add('collapsed');
968 toggle.hidden = false;
969 var label = toggle.querySelector('span');
970
971 // 选中的话题可能被滚出可视区,居中显示它
972 function revealActive() {
973 var act = chips.querySelector('.chip.active');
974 if (!act) return;
975 chips.scrollLeft = act.offsetLeft - (chips.clientWidth - act.offsetWidth) / 2;
976 }
977 revealActive();
978
979 var rt = null;
980 window.addEventListener('resize', function () {
981 if (!chips.classList.contains('collapsed')) return;
982 clearTimeout(rt);
983 rt = setTimeout(revealActive, 150);
984 });
985
986 // PC 端:竖向滚轮转横向滚动
987 chips.addEventListener('wheel', function (e) {
988 if (!chips.classList.contains('collapsed')) return;
989 if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
990 e.preventDefault();
991 chips.scrollLeft += e.deltaY;
992 }, { passive: false });
993
994 toggle.addEventListener('click', function () {
995 if (chips.classList.contains('expanded')) {
996 chips.classList.remove('expanded');
997 chips.classList.add('collapsed');
998 toggle.setAttribute('aria-expanded', 'false');
999 if (label) label.textContent = '展开';
1000 chips.scrollTop = 0;
1001 revealActive();
1002 } else {
1003 chips.classList.remove('collapsed');
1004 chips.classList.add('expanded');
1005 toggle.setAttribute('aria-expanded', 'true');
1006 if (label) label.textContent = '收起';
1007 }
1008 });
1009 }
1010
1011 /* ---------------- 旧版数据迁移(后台) ---------------- */
1012 function initMigrate() {
1013 var form = document.getElementById('migrate-form');
1014 if (!form) return;
1015 var fileInput = document.getElementById('migrate-file');
1016 var btn = document.getElementById('migrate-start');
1017 var bar = document.getElementById('migrate-progress');
1018 var okBox = document.getElementById('migrate-ok');
1019 var errBox = document.getElementById('migrate-err');
1020 var resultCard = document.getElementById('migrate-result');
1021 var resultBody = document.getElementById('migrate-result-body');
1022 var timer = null;
1023
1024 function setBar(pct) {
1025 if (!bar) return;
1026 bar.hidden = false;
1027 var span = bar.firstElementChild;
1028 if (span) span.style.width = pct + '%';
1029 }
1030 function hideBar() { if (bar) bar.hidden = true; }
1031 function idleBtn() { if (btn) { btn.disabled = false; btn.textContent = '开始迁移'; } }
1032 function showErr(msg) {
1033 if (errBox) { errBox.textContent = msg; errBox.hidden = false; }
1034 if (okBox) okBox.hidden = true;
1035 }
1036
1037 function renderStats(st, dry) {
1038 if (!resultCard || !resultBody) return;
1039 var rows = [
1040 ['数据来源', st.source === 'mysqldump' ? 'MySQL 导出文件' : 'SQLite 数据库'],
1041 ['帖子', st.posts], ['评论', st.comments], ['话题', st.topics],
1042 ['点赞补齐', st.likes], ['媒体文件', st.media],
1043 ['公告', st.notices], ['用户', st.users], ['管理员', st.admins],
1044 ['封禁名单', st.bans], ['举报', st.reports],
1045 ['跳过(已存在)', st.skipped], ['媒体文件缺失', st.missing], ['关联失效已忽略', st.no_post_ref]
1046 ];
1047 var html = '<table class="table"><tbody>';
1048 for (var i = 0; i < rows.length; i++) {
1049 html += '<tr><td>' + rows[i][0] + '</td><td><b>' + rows[i][1] + '</b></td></tr>';
1050 }
1051 html += '</tbody></table>';
1052 html += dry
1053 ? '<p class="form-ok">以上为试运行结果,未写入任何数据。确认无误后,取消勾选「试运行」再执行一次即可正式导入。</p>'
1054 : '<p class="form-ok">迁移完成,刷新前台即可看到数据。若媒体文件缺失较多,请检查旧版 uploads 目录路径是否正确。</p>';
1055 resultBody.innerHTML = html;
1056 resultCard.hidden = false;
1057 }
1058
1059 // 轮询迁移进度(服务端异步执行,避免大文件导入时请求超时)
1060 function poll() {
1061 var xhr = new XMLHttpRequest();
1062 xhr.open('GET', '/admin/migrate/status');
1063 xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1064 xhr.onload = function () {
1065 var st = {};
1066 try { st = JSON.parse(xhr.responseText); } catch (e) { return; }
1067 if (st.running) { setBar(70); return; }
1068 if (timer) { clearInterval(timer); timer = null; }
1069 hideBar();
1070 idleBtn();
1071 if (st.err) { showErr('迁移失败:' + st.err); return; }
1072 if (st.stats) {
1073 if (okBox) { okBox.textContent = st.dry_run ? '试运行完成' : '迁移执行完成'; okBox.hidden = false; }
1074 renderStats(st.stats, st.dry_run);
1075 }
1076 };
1077 xhr.send();
1078 }
1079
1080 form.addEventListener('submit', function (ev) {
1081 ev.preventDefault();
1082 if (errBox) errBox.hidden = true;
1083 if (okBox) okBox.hidden = true;
1084 if (fileInput && !fileInput.files.length) { showErr('请选择旧版数据库文件'); return; }
1085 if (resultCard) resultCard.hidden = true;
1086
1087 var fd = new FormData(form);
1088 var xhr = new XMLHttpRequest();
1089 xhr.open('POST', '/admin/migrate/start');
1090 xhr.setRequestHeader('X-CSRF-Token', CSRF);
1091 xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1092 if (btn) { btn.disabled = true; btn.textContent = '上传中...'; }
1093 xhr.upload.onprogress = function (e) {
1094 if (e.lengthComputable) setBar(Math.round(e.loaded / e.total * 100));
1095 };
1096 xhr.upload.onload = function () { if (btn) btn.textContent = '迁移中...'; };
1097 xhr.onload = function () {
1098 var r = {};
1099 try { r = JSON.parse(xhr.responseText); } catch (e) { }
1100 if (xhr.status !== 200 || !r.ok) {
1101 hideBar();
1102 idleBtn();
1103 showErr(r.msg || '启动迁移失败');
1104 return;
1105 }
1106 setBar(70);
1107 if (timer) clearInterval(timer);
1108 timer = setInterval(poll, 1200);
1109 poll();
1110 };
1111 xhr.onerror = function () { hideBar(); idleBtn(); showErr('网络异常,请稍后重试'); };
1112 xhr.send(fd);
1113 });
1114 }
1115
1116 /* ---------------- 插件扩展接口 ---------------- */
1117 // 与 head 中的预初始化对象合并,保证插件在任意位置注入的脚本都能安全注册
1118 var CLV = (window.CLV = window.CLV || {});
1119 CLV.version = ((document.querySelector('meta[name="clv-version"]') || {}).content) || '';
1120 CLV.csrf = CSRF;
1121 CLV.api = api;
1122 CLV.el = el;
1123 CLV.toast = toast;
1124 CLV.fingerprint = fingerprint;
1125 CLV.hooks = CLV.hooks || {};
1126 CLV.hooks.cardHtml = CLV.hooks.cardHtml || [];
1127 CLV.hooks.cardEl = CLV.hooks.cardEl || [];
1128 CLV.hooks.afterRender = CLV.hooks.afterRender || [];
1129
1130 /* ---------------- 启动 ---------------- */
1131
1132 document.addEventListener('DOMContentLoaded', function () {
1133 initNotice();
1134 initMasonry();
1135 initCommentForm();
1136 initCompose();
1137 initSendCode();
1138 initInstall();
1139 initPatrol();
1140 initAdmin();
1141 initMigrate();
1142 initTopicBar();
1143 });
1144})();