nonebot-plugin-botui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3799 @@
1
+ /* ==========================================================================
2
+ * BotUI · 前端控制台脚本(原生 JS,无框架 / 无构建 / 可离线运行)
3
+ *
4
+ * 目录:
5
+ * 1. 常量与状态
6
+ * 2. 通用工具(时间 / 文本 / DOM / 提示条)
7
+ * 3. 接口请求层
8
+ * 4. 启动流程(令牌 / 元信息 / 主题)
9
+ * 5. 会话列表
10
+ * 6. 消息渲染
11
+ * 7. 打开会话 / 加载历史 / 发送
12
+ * 8. 回复 / @ / 输入框
13
+ * 9. 灯箱与右键菜单
14
+ * 10. 滚动与已读
15
+ * 11. 实时事件(WebSocket)
16
+ * 12. 启动与事件绑定
17
+ * ========================================================================== */
18
+ 'use strict';
19
+
20
+ /* ============================ 1. 常量与状态 ============================ */
21
+
22
+ // 由文档基址推导接口前缀:即使路由前缀变化也无需改代码
23
+ const API_BASE = new URL('api/', document.baseURI).href.replace(/\/$/, '');
24
+ // 插件路由根(/media、/api/file 这些不在 /api 下)
25
+ const ROOT_BASE = new URL('./', document.baseURI).href.replace(/\/$/, '');
26
+ // 插件路由根的**路径**部分(无 host、无末尾斜杠)。消息段里存的是根相对地址
27
+ // (/botui/api/media/<id>),而 ROOT_BASE 是绝对地址,判定「是不是自己的地址」
28
+ // 只能比路径,不能拿绝对 URL 去 indexOf。
29
+ const ROOT_BASE_PATH = new URL(ROOT_BASE + '/', document.baseURI).pathname.replace(/\/$/, '');
30
+
31
+ const TOKEN_KEY = 'botui_token';
32
+ const THEME_KEY = 'botui_theme';
33
+ const BOT_KEY = 'botui_bot'; // 当前选中的机器人(会话按机器人区分)
34
+ const CHAT_KEY = 'botui_chat'; // 每个机器人最近打开的会话({self_id: chat_key})
35
+ const NOTIFY_KEY = 'botui_notify'; // 网页通知偏好({enabled, sound})
36
+
37
+ const PAGE_LIMIT_FALLBACK = 50; // 每次拉取消息条数(后端可用 BOTUI_PAGE_SIZE 覆盖)
38
+ const MSG_MAX = 1200; // 前端保留的最大消息数
39
+ const BACKOFF_MIN = 1000; // 断线重连退避下限(毫秒)
40
+ const BACKOFF_MAX = 30000; // 断线重连退避上限
41
+ const RESYNC_GAP = 30; // 事件序号出现这么大的空洞就重新拉一次列表
42
+ const RESYNC_STALE = 60; // 超过这么久没收到过任何消息就主动重同步(秒)
43
+ const NEAR_BOTTOM = 80; // 距底部多少像素内算“贴着底部”
44
+
45
+ const state = {
46
+ meta: null, // /meta 结果
47
+ token: '', // 访问令牌
48
+ gateOpen: false, // 令牌页是否显示
49
+ gateFrom401: false, // 令牌页是否由 401 触发
50
+
51
+ bots: [], // 连接过的机器人(含在线状态)
52
+ botId: '', // 当前选中的机器人 self_id(会话按它区分)
53
+ botMenuOpen: false, // 机器人切换菜单是否展开
54
+ settingsOpen: false, // 设置面板是否展开
55
+ cache: null, // /api/cache 拿到的缓存概况
56
+ cacheBusy: false, // 缓存操作进行中(按钮置灰防连点)
57
+ settingsSeq: 0, // 缓存统计请求序号,丢弃过期响应
58
+
59
+ notify: { enabled: false, sound: true }, // 网页通知偏好(enabled/sound)
60
+
61
+ chats: [], // 会话列表(按时间倒序)
62
+ chatMap: new Map(), // key -> 会话
63
+ current: null, // 当前打开的会话
64
+ messages: [], // 当前会话消息(时间升序)
65
+ msgMap: new Map(), // 消息 id -> 消息
66
+ lastRendered: null, // 已渲染的最后一条消息(用于紧凑排版)
67
+ hasMore: true, // 是否还有更早的消息
68
+ loadingMore: false, // 是否正在加载更早的消息
69
+ needsScroll: false, // 渲染后是否需要滚到底部
70
+
71
+ fileSeq: 0, // 文件预览请求序号(丢弃过期结果)
72
+
73
+ unread: new Map(), // 会话 key -> 未读数
74
+ pending: new Map(), // 会话 key -> 未读消息摘要
75
+
76
+ search: '',
77
+ searchMode: 'chats', // 'chats' | 'msgs' —— 搜索范围
78
+ searchHits: [], // 消息搜索结果
79
+ searching: false, // 是否正在请求消息搜索
80
+ searchSeq: 0, // 请求序号,丢弃过期响应
81
+ focusMsgId: null, // 打开会话后要定位到的消息 id
82
+ started: false, // 是否已进入正常工作状态
83
+ online: false, // WebSocket 是否连着
84
+ ws: null, // 当前 WebSocket
85
+ wsGen: 0, // 连接代数:作废旧连接的回调
86
+ wsTimer: null, // 重连定时器
87
+ wsFails: 0,
88
+ wsDelay: BACKOFF_MIN,
89
+ resyncLast: 0, // 上次收到任何消息的时间(毫秒)
90
+ syncing: false, // 正在重新同步中
91
+ // 每次拉取的消息条数,由 /meta 的 page_size 决定(对应 BOTUI_PAGE_SIZE)
92
+ pageSize: PAGE_LIMIT_FALLBACK,
93
+ since: 0, // 事件游标(服务端事件序号,不是时间戳)
94
+ sending: false,
95
+
96
+ replyTo: null, // 待回复消息
97
+ pendingAt: [], // 待发送的 @ 目标
98
+ pendingFiles: [], // 待发送的附件(已上传,元素形如 {id,name,size,mime,kind})
99
+ uploading: 0, // 正在上传的附件数(>0 时发送按钮提示「上传中」)
100
+ members: [], // 当前会话记录到过的成员(@ 菜单数据源)
101
+ memberMap: new Map(), // 成员 id -> 成员(用于把 @ 显示成昵称)
102
+ atQuery: '', // @ 菜单的搜索词
103
+ ctxMsg: null, // 右键菜单目标消息
104
+ aliasTarget: null, // 正在编辑备注的目标({kind, ...})
105
+ sep: { day: '', time: 0 },// 分隔符状态
106
+ newCount: 0 // 未读新消息数(悬浮按钮)
107
+ };
108
+
109
+ // DOM 引用
110
+ const $ = (id) => document.getElementById(id);
111
+ const el = {
112
+ app: $('app'),
113
+ chatList: $('chatList'),
114
+ searchInput: $('searchInput'),
115
+ searchClear: $('searchClear'),
116
+ searchTabs: $('searchTabs'),
117
+ tabChats: $('tabChats'),
118
+ tabMsgs: $('tabMsgs'),
119
+ msgResults: $('msgResults'),
120
+ themeBtn: $('themeBtn'),
121
+ connDot: $('connDot'),
122
+ connText: $('connText'),
123
+ connMeta: $('connMeta'),
124
+ backBtn: $('backBtn'),
125
+ reloadBtn: $('reloadBtn'),
126
+ settingsBtn: $('settingsBtn'),
127
+ settings: $('settings'),
128
+ settingsClose: $('settingsClose'),
129
+ cacheState: $('cacheState'),
130
+ cacheStats: $('cacheStats'),
131
+ cacheRefresh: $('cacheRefresh'),
132
+ cacheClearOrphans: $('cacheClearOrphans'),
133
+ cacheClearImages: $('cacheClearImages'),
134
+ cacheClearFiles: $('cacheClearFiles'),
135
+ cacheClearAll: $('cacheClearAll'),
136
+ notifyToggle: $('notifyToggle'),
137
+ notifySound: $('notifySound'),
138
+ notifyState: $('notifyState'),
139
+ botSwitch: $('botSwitch'),
140
+ botBtn: $('botBtn'),
141
+ botDot: $('botDot'),
142
+ botBtnName: $('botBtnName'),
143
+ botMenu: $('botMenu'),
144
+ headAvatar: $('headAvatar'),
145
+ headName: $('headName'),
146
+ headRenameBtn: $('headRenameBtn'),
147
+ headKind: $('headKind'),
148
+ headSub: $('headSub'),
149
+ msgBox: $('msgViewport'),
150
+ msgList: $('msgList'),
151
+ newMsgBtn: $('newMsgBtn'),
152
+ composer: $('composer'),
153
+ replyBar: $('replyBar'),
154
+ replyBarLabel: $('replyBarLabel'),
155
+ replyBarClose: $('replyBarClose'),
156
+ pendingAt: $('pendingAt'),
157
+ pendingFiles: $('pendingFiles'),
158
+ atBtn: $('atBtn'),
159
+ atMenu: $('atMenu'),
160
+ fileBtn: $('fileBtn'),
161
+ filePicker: $('filePicker'),
162
+ input: $('composerInput'),
163
+ sendBtn: $('sendBtn'),
164
+ hint: $('composerHint'),
165
+ lightbox: $('lightbox'),
166
+ lightboxImg: $('lightboxImg'),
167
+ fileViewer: $('fileViewer'),
168
+ fileViewerTitle: $('fileViewerTitle'),
169
+ fileViewerMeta: $('fileViewerMeta'),
170
+ fileViewerBody: $('fileViewerBody'),
171
+ fileViewerClose: $('fileViewerClose'),
172
+ fileViewerSave: $('fileViewerSave'),
173
+ ctxMenu: $('ctxMenu'),
174
+ dropOverlay: $('dropOverlay'),
175
+ aliasDialog: $('aliasDialog'),
176
+ aliasTitle: $('aliasTitle'),
177
+ aliasDesc: $('aliasDesc'),
178
+ aliasInput: $('aliasInput'),
179
+ aliasClear: $('aliasClear'),
180
+ aliasSave: $('aliasSave'),
181
+ gate: $('gate'),
182
+ gateForm: $('gateForm'),
183
+ gateInput: $('gateInput'),
184
+ gateDesc: $('gateDesc'),
185
+ toast: $('toast')
186
+ };
187
+
188
+ /* ============================ 2. 通用工具 ============================ */
189
+
190
+ /** 构建元素:所有文本都走 textContent,从根上杜绝 XSS */
191
+ function h(tag, className, text) {
192
+ const node = document.createElement(tag);
193
+ if (className) node.className = className;
194
+ if (text !== undefined && text !== null) node.textContent = String(text);
195
+ return node;
196
+ }
197
+
198
+ /** 仅允许 http/https,拦截 javascript: 等伪协议 */
199
+ function safeUrl(url) {
200
+ if (typeof url !== 'string') return '';
201
+ const trimmed = url.trim();
202
+ // 本插件自己的媒体地址是服务端相对路径(/botui/api/media/<id>),同源可信
203
+ if (isOwnUrl(trimmed)) return trimmed;
204
+ return /^https?:\/\//i.test(trimmed) ? trimmed : '';
205
+ }
206
+
207
+ /**
208
+ * 判断是不是本插件自己的媒体取回地址(``/api/media/<id>``)。
209
+ *
210
+ * 消息段里存的是**根相对**地址(``/botui/api/media/<id>``),而 ``ROOT_BASE`` 是
211
+ * **绝对**地址(``http://host/botui``)—— 直接 ``indexOf`` 永远匹配不上,自己发的
212
+ * 图片就会被当成外链走无令牌的代理、加载失败。所以把两边都解析成绝对地址、只比
213
+ * **路径**(忽略 host、协议与查询串)。
214
+ *
215
+ * 同时强制**同源**:``//evil.com/botui/api/media/x`` 这类协议相对地址路径能对上,
216
+ * 却是别人的站点 —— 放行会把令牌一起带过去。只接受根相对地址与同源绝对地址。
217
+ *
218
+ * 必须收紧到这个白名单 —— 早先写成「任何 ``/api/`` 开头」,结果像
219
+ * ``/api/settings`` 这类接口地址也会被当成图片直链拿去 ``<img src>``。
220
+ */
221
+ function isOwnUrl(url) {
222
+ if (typeof url !== 'string' || !url) return false;
223
+ if (url.slice(0, 2) === '//') return false; // 协议相对 = 别的站点
224
+ let parsed;
225
+ try {
226
+ parsed = new URL(url, document.baseURI);
227
+ } catch (_) {
228
+ return false;
229
+ }
230
+ if (parsed.origin !== location.origin) return false;
231
+ return parsed.pathname.indexOf(ROOT_BASE_PATH + '/api/media/') === 0;
232
+ }
233
+
234
+ /** 接口地址的鉴权参数(<img>/<a>/<video> 带不上自定义请求头,只能走 query) */
235
+ function tokenQuery() {
236
+ return state.token ? 'token=' + encodeURIComponent(state.token) : '';
237
+ }
238
+
239
+ /**
240
+ * 自己上传的附件地址:补上令牌(可选下载参数)后可直接作为资源地址使用。
241
+ * 不是本插件接口地址时返回空串。
242
+ */
243
+ function ownUrl(raw, download) {
244
+ const url = String(raw || '').trim();
245
+ if (!isOwnUrl(url)) return '';
246
+ const parts = [];
247
+ if (url.indexOf('token=') < 0) {
248
+ const token = tokenQuery();
249
+ if (token) parts.push(token);
250
+ }
251
+ if (download) parts.push('download=1');
252
+ if (!parts.length) return url;
253
+ return url + (url.indexOf('?') >= 0 ? '&' : '?') + parts.join('&');
254
+ }
255
+
256
+ /** 鉴权参数:图片/文件走 <img>/<a>/<audio> 直接发请求,带不上自定义请求头 */
257
+ function authQuery() {
258
+ return state.token ? '&token=' + encodeURIComponent(state.token) : '';
259
+ }
260
+
261
+ /** 媒体代理地址:远程图片大多带防盗链,直连会加载失败,统一走后端代理 */
262
+ function proxied(raw) {
263
+ // 自己上传的附件本来就在服务端,直接取(再套一层代理反而多余)
264
+ const own = ownUrl(raw);
265
+ if (own) return own;
266
+ const url = safeUrl(raw);
267
+ if (!url) return '';
268
+ return ROOT_BASE + '/media?u=' + encodeURIComponent(url) + authQuery();
269
+ }
270
+
271
+ /** 文件地址:走 /api/file 代理(QQ 文件直链往往需要鉴权,且要支持 Range) */
272
+ function fileUrl(raw, name, download) {
273
+ // 媒体库地址直接取,它自带 Content-Disposition 与 Range 支持
274
+ const own = ownUrl(raw, download);
275
+ if (own) return own;
276
+ const url = safeUrl(raw);
277
+ const parts = [];
278
+ if (url) parts.push('u=' + encodeURIComponent(url));
279
+ if (hasText(name)) parts.push('name=' + encodeURIComponent(String(name)));
280
+ if (state.current && hasText(state.current.key)) {
281
+ parts.push('chat=' + encodeURIComponent(String(state.current.key)));
282
+ }
283
+ if (download) parts.push('download=1');
284
+ return ROOT_BASE + '/api/file?' + parts.join('&') + authQuery();
285
+ }
286
+
287
+ function parseTime(value) {
288
+ const n = Number(value);
289
+ return Number.isFinite(n) && n > 0 ? n : null;
290
+ }
291
+
292
+ /** null / undefined / 空白 一律视为“无值”,界面上不出现 null */
293
+ function hasText(value) {
294
+ return value !== null && value !== undefined && String(value).trim() !== '';
295
+ }
296
+
297
+ function pad2(n) { return n < 10 ? '0' + n : String(n); }
298
+
299
+ function startOfDay(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); }
300
+
301
+ function dayDiffFromToday(d) {
302
+ return Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
303
+ }
304
+
305
+ /** 会话列表时间:今天 HH:MM / 昨天 / MM-DD / YYYY-MM-DD */
306
+ function listTime(seconds) {
307
+ const t = parseTime(seconds);
308
+ if (t === null) return '';
309
+ const d = new Date(t * 1000);
310
+ const diff = dayDiffFromToday(d);
311
+ if (diff === 0) return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
312
+ if (diff === 1) return '昨天';
313
+ if (d.getFullYear() === new Date().getFullYear()) return pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
314
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
315
+ }
316
+
317
+ /** 日期分隔:今天 / 昨天 / 5月1日 / 2024年5月1日 */
318
+ function dayLabel(seconds) {
319
+ const d = new Date(seconds * 1000);
320
+ const diff = dayDiffFromToday(d);
321
+ if (diff === 0) return '今天';
322
+ if (diff === 1) return '昨天';
323
+ if (d.getFullYear() === new Date().getFullYear()) return (d.getMonth() + 1) + '月' + d.getDate() + '日';
324
+ return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日';
325
+ }
326
+
327
+ function clockLabel(seconds) {
328
+ const d = new Date(seconds * 1000);
329
+ return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
330
+ }
331
+
332
+ function fullTime(seconds) {
333
+ const d = new Date(seconds * 1000);
334
+ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' +
335
+ pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
336
+ }
337
+
338
+ /** 名称首字取稳定的头像底色 */
339
+ function colorFor(seed) {
340
+ const text = hasText(seed) ? String(seed) : '?';
341
+ let hash = 0;
342
+ for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) % 360;
343
+ return 'hsl(' + hash + ', 52%, 44%)';
344
+ }
345
+
346
+ /** 头像:有图用图(失败回落首字圆),无图直接首字彩色圆 */
347
+ function buildAvatar(name, avatarUrl, extraClass) {
348
+ const label = hasText(name) ? String(name) : '?';
349
+ const node = h('div', 'avatar' + (extraClass ? ' ' + extraClass : ''));
350
+ const url = safeUrl(avatarUrl);
351
+ if (url) {
352
+ const img = document.createElement('img');
353
+ img.alt = '';
354
+ img.loading = 'lazy';
355
+ img.referrerPolicy = 'no-referrer';
356
+ img.addEventListener('error', () => applyInitial(node, label));
357
+ img.src = url;
358
+ node.appendChild(img);
359
+ } else {
360
+ applyInitial(node, label);
361
+ }
362
+ return node;
363
+ }
364
+
365
+ function applyInitial(node, label) {
366
+ node.textContent = label.charAt(0);
367
+ node.style.background = colorFor(label);
368
+ node.title = label;
369
+ }
370
+
371
+ /** 会话列表里的一句话摘要(图片 / 语音等给出文字占位) */
372
+ function previewOf(msg) {
373
+ if (!msg) return '';
374
+ if (hasText(msg.text)) return String(msg.text);
375
+ return segmentsText(msg.segments);
376
+ }
377
+
378
+ /** 把消息段压成一行文字(引用摘要、会话列表预览都用它) */
379
+ function segmentsText(segments) {
380
+ const segs = Array.isArray(segments) ? segments : [];
381
+ const parts = [];
382
+ for (let i = 0; i < segs.length; i++) {
383
+ const seg = segs[i];
384
+ if (!seg || typeof seg !== 'object') continue;
385
+ if (seg.type === 'text') { if (hasText(seg.text)) parts.push(String(seg.text)); continue; }
386
+ if (seg.type === 'image') { parts.push('[图片]'); continue; }
387
+ if (seg.type === 'voice') { parts.push('[语音]'); continue; }
388
+ if (seg.type === 'video') { parts.push('[视频]'); continue; }
389
+ if (seg.type === 'file') { parts.push('[文件]'); continue; }
390
+ if (seg.type === 'face') { parts.push('[表情]'); continue; }
391
+ if (seg.type === 'json' || seg.type === 'xml') { parts.push('[卡片消息]'); continue; }
392
+ if (seg.type === 'forward') { parts.push('[合并转发]'); continue; }
393
+ if (seg.type === 'reply') { parts.push('[回复]'); continue; }
394
+ if (seg.type === 'at') {
395
+ parts.push('@' + (hasText(seg.name) ? seg.name : (hasText(seg.target) ? seg.target : '某人')));
396
+ continue;
397
+ }
398
+ }
399
+ const text = parts.join('').replace(/\s+/g, ' ').trim();
400
+ return text || '[消息]';
401
+ }
402
+
403
+ /** 引用消息里被引用内容的摘要文本 */
404
+ function quoteText(seg) {
405
+ if (seg && hasText(seg.preview)) return String(seg.preview);
406
+ if (seg && hasText(seg.text)) return String(seg.text);
407
+ return '';
408
+ }
409
+
410
+ function shorten(text, max) {
411
+ const value = hasText(text) ? String(text).replace(/\s+/g, ' ').trim() : '';
412
+ const limit = max || 36;
413
+ return value.length > limit ? value.slice(0, limit) + '…' : value;
414
+ }
415
+
416
+ /** 事件目标向上查找最近的匹配元素 */
417
+ function closestFrom(target, selector) {
418
+ if (!target || typeof target.closest !== 'function') return null;
419
+ return target.closest(selector);
420
+ }
421
+
422
+ /* --- 轻提示条 --- */
423
+ let toastTimer = null;
424
+ function toast(text, isError) {
425
+ const box = el.toast;
426
+ box.textContent = text || '';
427
+ box.className = 'toast show' + (isError ? ' error' : '');
428
+ box.hidden = false;
429
+ if (toastTimer) clearTimeout(toastTimer);
430
+ toastTimer = setTimeout(() => {
431
+ box.className = 'toast' + (isError ? ' error' : '');
432
+ setTimeout(() => { if (box.className.indexOf('show') < 0) box.hidden = true; }, 220);
433
+ }, 2000);
434
+ }
435
+
436
+ /* --- 剪贴板(含离线兜底) --- */
437
+ function copyText(value) {
438
+ const text = value === null || value === undefined ? '' : String(value);
439
+ if (!text) { toast('没有可复制的内容'); return; }
440
+ const done = () => toast('已复制');
441
+ if (navigator.clipboard && navigator.clipboard.writeText) {
442
+ navigator.clipboard.writeText(text).then(done, () => legacyCopy(text, done));
443
+ } else {
444
+ legacyCopy(text, done);
445
+ }
446
+ }
447
+
448
+ function legacyCopy(text, done) {
449
+ const ta = document.createElement('textarea');
450
+ ta.value = text;
451
+ ta.setAttribute('readonly', 'readonly');
452
+ ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
453
+ document.body.appendChild(ta);
454
+ ta.select();
455
+ let ok = false;
456
+ try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
457
+ document.body.removeChild(ta);
458
+ if (ok) done(); else toast('复制失败,请手动选择文本', true);
459
+ }
460
+
461
+ /* ============================ 3. 接口请求层 ============================ */
462
+
463
+ /**
464
+ * 统一请求封装:
465
+ * - 每个请求都带 X-BotUI-Token 与 X-BotUI-Bot(当前选中的机器人)
466
+ * - 非 2xx 时解析 {"ok":false,"error":"..."} 并抛出可读错误
467
+ * - 401 统一弹出令牌页
468
+ */
469
+ async function api(path, options) {
470
+ const opts = options || {};
471
+ const headers = { 'X-BotUI-Token': state.token || '' };
472
+ // 后端按这个请求头把会话/搜索限定在当前机器人名下(会话按机器人区分)。
473
+ // 用请求头而不是拼进 query,省得每个调用点都要记得带上。
474
+ if (state.botId) headers['X-BotUI-Bot'] = state.botId;
475
+ if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
476
+
477
+ let res;
478
+ try {
479
+ res = await fetch(API_BASE + path, {
480
+ method: opts.method || 'GET',
481
+ headers: headers,
482
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
483
+ cache: 'no-store',
484
+ credentials: 'same-origin'
485
+ });
486
+ } catch (err) {
487
+ const netErr = new Error('无法连接服务器');
488
+ netErr.status = 0;
489
+ throw netErr;
490
+ }
491
+
492
+ const raw = await res.text().catch(() => '');
493
+ let data = null;
494
+ if (raw) {
495
+ try { data = JSON.parse(raw); } catch (err) { data = null; }
496
+ }
497
+ if (!res.ok && !(data && typeof data === 'object')) data = null;
498
+
499
+ if (!res.ok) {
500
+ if (res.status === 401) requireToken();
501
+ const message = (data && hasText(data.error)) ? String(data.error) : ('请求失败(HTTP ' + res.status + ')');
502
+ const httpErr = new Error(message);
503
+ httpErr.status = res.status;
504
+ throw httpErr;
505
+ }
506
+ return data === null ? {} : data;
507
+ }
508
+
509
+ /**
510
+ * 上传二进制内容的请求封装(附件走这里)。
511
+ *
512
+ * 与 :func:`api` 的区别只在请求体:附件是原始字节流而非 JSON,所以这里直接
513
+ * 把 ``ArrayBuffer`` 放进 body,文件名/类型走查询参数(后端也据此读取,不需要
514
+ * multipart 依赖)。返回与错误处理逻辑与 ``api`` 保持一致。
515
+ */
516
+ async function apiBytes(path, method, buffer) {
517
+ const headers = { 'X-BotUI-Token': state.token || '' };
518
+ let res;
519
+ try {
520
+ res = await fetch(API_BASE + path, {
521
+ method: method || 'POST',
522
+ headers: headers,
523
+ body: buffer,
524
+ cache: 'no-store',
525
+ credentials: 'same-origin'
526
+ });
527
+ } catch (err) {
528
+ const netErr = new Error('无法连接服务器');
529
+ netErr.status = 0;
530
+ throw netErr;
531
+ }
532
+ const raw = await res.text().catch(() => '');
533
+ let data = null;
534
+ if (raw) {
535
+ try { data = JSON.parse(raw); } catch (err) { data = null; }
536
+ }
537
+ if (!res.ok) {
538
+ if (res.status === 401) requireToken();
539
+ const message = (data && hasText(data.error)) ? String(data.error) : ('上传失败(HTTP ' + res.status + ')');
540
+ const httpErr = new Error(message);
541
+ httpErr.status = res.status;
542
+ throw httpErr;
543
+ }
544
+ return data === null ? {} : data;
545
+ }
546
+
547
+ /* ============================ 4. 启动流程 ============================ */
548
+
549
+ /** 读取 ?token=,写入 localStorage,并从地址栏抹掉 */
550
+ function captureToken() {
551
+ let stored = readStoredToken();
552
+ let provided = '';
553
+ try {
554
+ provided = new URLSearchParams(location.search || '').get('token') || '';
555
+ } catch (err) { provided = ''; }
556
+
557
+ if (provided) {
558
+ stored = provided;
559
+ try { localStorage.setItem(TOKEN_KEY, provided); } catch (err) { /* 隐私模式下可能失败 */ }
560
+ try {
561
+ const url = new URL(location.href);
562
+ url.searchParams.delete('token');
563
+ history.replaceState(history.state, '', url.pathname + url.search + url.hash);
564
+ } catch (err) { /* 忽略 */ }
565
+ }
566
+ state.token = stored;
567
+ }
568
+
569
+ function readStoredToken() {
570
+ try { return localStorage.getItem(TOKEN_KEY) || ''; } catch (err) { return ''; }
571
+ }
572
+
573
+ function saveToken(value) {
574
+ state.token = value;
575
+ try { localStorage.setItem(TOKEN_KEY, value); } catch (err) { /* 忽略 */ }
576
+ }
577
+
578
+ /* --- 机器人选择:会话按机器人区分,选中项记在本地 --- */
579
+ function readStoredBot() {
580
+ try { return localStorage.getItem(BOT_KEY) || ''; } catch (err) { return ''; }
581
+ }
582
+
583
+ function saveBot(selfId) {
584
+ state.botId = selfId || '';
585
+ try {
586
+ if (state.botId) localStorage.setItem(BOT_KEY, state.botId);
587
+ else localStorage.removeItem(BOT_KEY);
588
+ } catch (err) { /* 忽略 */ }
589
+ }
590
+
591
+ /* --- 会话记忆:记下每个机器人最近打开的会话,下次打开自动回到它 --- */
592
+ function readStoredChats() {
593
+ try {
594
+ const raw = localStorage.getItem(CHAT_KEY);
595
+ if (!raw) return {};
596
+ const data = JSON.parse(raw);
597
+ return (data && typeof data === 'object') ? data : {};
598
+ } catch (err) { return {}; }
599
+ }
600
+
601
+ /** 取某个机器人上次打开的会话 key(没有记到则返回空串) */
602
+ function storedChatFor(selfId) {
603
+ if (!hasText(selfId)) return '';
604
+ const all = readStoredChats();
605
+ const key = all[String(selfId)];
606
+ return hasText(key) ? String(key) : '';
607
+ }
608
+
609
+ /** 记录当前打开的会话(按会话 key 里的机器人 ID 分别保存) */
610
+ function saveChat(key) {
611
+ const selfId = botOfKey(key);
612
+ if (!hasText(selfId) || !hasText(key)) return;
613
+ try {
614
+ const all = readStoredChats();
615
+ all[selfId] = String(key);
616
+ localStorage.setItem(CHAT_KEY, JSON.stringify(all));
617
+ } catch (err) { /* 忽略 */ }
618
+ }
619
+
620
+ /**
621
+ * 自动打开上次在当前机器人下查看的会话。
622
+ * 没有记录、或那个会话已经不存在(被清空/换了机器人)时什么都不做。
623
+ */
624
+ function restoreLastChat() {
625
+ const key = storedChatFor(state.botId);
626
+ if (!key || !state.chatMap.has(key)) return;
627
+ openChat(key).catch(() => { /* 静默失败:401 已弹令牌页,其余等下次重同步 */ });
628
+ }
629
+
630
+ /* --- 主题:默认深色,浅色通过 html[data-theme] --- */
631
+ function applyTheme(theme) {
632
+ const next = theme === 'light' ? 'light' : 'dark';
633
+ document.documentElement.setAttribute('data-theme', next);
634
+ try { localStorage.setItem(THEME_KEY, next); } catch (err) { /* 忽略 */ }
635
+ }
636
+
637
+ function initTheme() {
638
+ let saved = '';
639
+ try { saved = localStorage.getItem(THEME_KEY) || ''; } catch (err) { saved = ''; }
640
+ // 默认深色,只有用户主动切换过才读取本地偏好
641
+ document.documentElement.setAttribute('data-theme', saved === 'light' ? 'light' : 'dark');
642
+ }
643
+
644
+ function toggleTheme() {
645
+ applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light');
646
+ }
647
+
648
+ /* --- 令牌页 --- */
649
+ function showGate(message, desc) {
650
+ state.gateOpen = true;
651
+ state.gateFrom401 = !!message;
652
+ el.gate.hidden = false;
653
+ el.gateDesc.textContent = desc || '该服务已开启访问令牌校验,请输入令牌后继续。';
654
+ el.gateInput.value = state.token || '';
655
+ const old = el.gateForm.querySelector('.gate-error');
656
+ if (old) old.remove();
657
+ if (message) el.gateForm.appendChild(h('p', 'gate-error', message));
658
+ renderConnMeta();
659
+ setTimeout(() => { try { el.gateInput.focus(); } catch (err) { /* 忽略 */ } }, 30);
660
+ }
661
+
662
+ function hideGate() {
663
+ state.gateOpen = false;
664
+ state.gateFrom401 = false;
665
+ el.gate.hidden = true;
666
+ renderConnMeta();
667
+ }
668
+
669
+ /** 令牌缺失 / 失效:断开连接并弹出令牌页(401 统一入口) */
670
+ function requireToken() {
671
+ closeSocket();
672
+ state.started = false;
673
+ state.online = false;
674
+ if (state.gateOpen && state.gateFrom401) return;
675
+ state.token = '';
676
+ try { localStorage.removeItem(TOKEN_KEY); } catch (err) { /* 忽略 */ }
677
+ showGate('令牌无效或已过期', '请重新输入访问令牌后继续。');
678
+ }
679
+
680
+ /* --- 连接状态 --- */
681
+ function setStatus(mode, text) {
682
+ const level = mode === 'ok' || mode === 'poll' || mode === 'err' ? mode : 'idle';
683
+ el.connDot.className = 'dot dot-' + level;
684
+ el.connText.textContent = text || '';
685
+ el.connText.title = text || '';
686
+ }
687
+
688
+ function statusOk() {
689
+ setStatus('ok', '已连接 · 实时推送');
690
+ }
691
+
692
+ function statusErr(message) {
693
+ setStatus('err', message || '连接异常');
694
+ probeHealth();
695
+ }
696
+
697
+ /** 连接出错后探测 /health:数据库或机器人异常时在页脚说明原因 */
698
+ let probingHealth = false;
699
+ async function probeHealth() {
700
+ if (probingHealth || state.gateOpen) return;
701
+ probingHealth = true;
702
+ try {
703
+ const data = await api('/health');
704
+ const notes = [];
705
+ if (data && data.db === false) notes.push('数据库异常');
706
+ const bots = data && Array.isArray(data.bots) ? data.bots.filter((b) => hasText(b)) : [];
707
+ if (data && Array.isArray(data.bots) && !bots.length) notes.push('暂无在线机器人');
708
+ if (notes.length) setStatus('err', '服务异常:' + notes.join('、'));
709
+ } catch (err) {
710
+ // 探测本身失败无需额外提示,连接错误已经展示
711
+ } finally {
712
+ probingHealth = false;
713
+ }
714
+ }
715
+
716
+ function renderConnMeta() {
717
+ const meta = state.meta;
718
+ if (!meta || state.gateOpen) { el.connMeta.textContent = ''; return; }
719
+ const parts = [];
720
+ // 显示的是「当前选中的机器人」,而不是 meta 里那个随机的——否则切换后
721
+ // 页脚还写着别的机器人,看着像没切成功。
722
+ const selected = currentBot();
723
+ if (selected) {
724
+ parts.push(botLabel(selected.self_id));
725
+ if (hasText(selected.adapter)) parts.push(String(selected.adapter));
726
+ parts.push(selected.online ? '在线' : '离线');
727
+ } else if (state.botId) {
728
+ parts.push('机器人 ' + state.botId);
729
+ }
730
+ if (hasText(meta.version)) parts.push('v' + meta.version);
731
+ el.connMeta.textContent = parts.join(' · ');
732
+ }
733
+
734
+ /* --- 元信息 --- */
735
+ async function loadMeta() {
736
+ state.meta = (await api('/meta')) || {};
737
+
738
+ if (state.meta.auth_required && !state.token) {
739
+ showGate('', '该服务已开启访问令牌校验,请输入令牌后继续。');
740
+ return false;
741
+ }
742
+ if (state.gateOpen && !state.gateFrom401) hideGate();
743
+ applyMeta();
744
+ return true;
745
+ }
746
+
747
+ function applyMeta() {
748
+ const meta = state.meta || {};
749
+ const writable = meta.write_enabled !== false;
750
+ // 附件开关随「只读模式」一起失效:只读时连输入框都不给用,更不该能发文件
751
+ const canUpload = writable && meta.upload_enabled !== false;
752
+
753
+ el.input.disabled = !writable;
754
+ el.sendBtn.disabled = !writable;
755
+ el.atBtn.disabled = !writable;
756
+ el.fileBtn.disabled = !canUpload;
757
+ el.fileBtn.title = canUpload ? '发送图片 / 文件' : '未开启附件发送';
758
+ el.composer.classList.toggle('readonly', !writable);
759
+ el.input.placeholder = writable ? '输入消息,Enter 发送,Shift+Enter 换行' : '只读模式,无法发送消息';
760
+ el.hint.textContent = writable ? '' : '只读模式';
761
+
762
+ // 分页大小也听后端的,别写死:后端 BOTUI_PAGE_SIZE 被限制在 10~200,
763
+ // 前端若固定 50,改大改小都不会生效。
764
+ const ps = Number(meta.page_size);
765
+ state.pageSize = Number.isFinite(ps) && ps > 0 ? Math.floor(ps) : PAGE_LIMIT_FALLBACK;
766
+
767
+ // 机器人列表:/meta 在鉴权通过时会带上 bot_list(含在线状态),
768
+ // 用它初始化切换器;之后再靠 /bots 与 WebSocket 事件刷新。
769
+ if (Array.isArray(meta.bot_list)) setBots(meta.bot_list);
770
+
771
+ // 控制台不该被搜索引擎收录:后端在 /meta 里给出 robots 指令,这里落到 <meta>
772
+ if (hasText(meta.robots_tag)) {
773
+ let tag = document.querySelector('meta[name="robots"]');
774
+ if (!tag) { tag = document.createElement('meta'); tag.setAttribute('name', 'robots'); document.head.appendChild(tag); }
775
+ tag.setAttribute('content', String(meta.robots_tag));
776
+ }
777
+
778
+ document.title = (hasText(meta.name) ? String(meta.name) : 'BotUI') + ' · 消息控制台';
779
+ renderConnMeta();
780
+ if (!state.gateOpen) statusOk();
781
+ if (state.chats.length) renderChats();
782
+ }
783
+
784
+ /* ====================== 4b. 机器人切换器(右上角) ======================
785
+ 会话按机器人区分:切换器决定了 /chats、/search 与发送时用的 self_id。
786
+ 列表来自后端记录的全部「连接过的机器人」(含已离线的),在线状态实时更新。 */
787
+
788
+ /** 从 /meta 或 /bots 的结果里接管机器人列表,并修正当前选中项 */
789
+ function setBots(list) {
790
+ const items = Array.isArray(list) ? list.filter((b) => b && hasText(b.self_id)) : [];
791
+ state.bots = items.map((b) => Object.assign({}, b, { self_id: String(b.self_id) }));
792
+
793
+ const ids = state.bots.map((b) => b.self_id);
794
+ let current = state.botId;
795
+ if (!current || ids.indexOf(current) < 0) {
796
+ // 优先沿用本地记住的;否则选第一个在线的,再退到列表第一个
797
+ const remembered = readStoredBot();
798
+ if (remembered && ids.indexOf(remembered) >= 0) {
799
+ current = remembered;
800
+ } else {
801
+ const online = state.bots.find((b) => b.online);
802
+ current = online ? online.self_id : (ids.length ? ids[0] : '');
803
+ }
804
+ saveBot(current);
805
+ }
806
+ renderBotSwitch();
807
+ return current;
808
+ }
809
+
810
+ function currentBot() {
811
+ if (!state.botId) return null;
812
+ return state.bots.find((b) => b.self_id === state.botId) || null;
813
+ }
814
+
815
+ /** 更新 /api/bots 拿到的机器人(含在线状态),保持当前选中不变 */
816
+ function mergeBots(list) {
817
+ const items = Array.isArray(list) ? list.filter((b) => b && hasText(b.self_id)) : [];
818
+ state.bots = items.map((b) => Object.assign({}, b, { self_id: String(b.self_id) }));
819
+ renderBotSwitch();
820
+ }
821
+
822
+ function renderBotSwitch() {
823
+ const bot = currentBot();
824
+ const name = bot ? (hasText(bot.name) ? String(bot.name) : bot.self_id) : (state.botId || '选择机器人');
825
+
826
+ el.botBtnName.textContent = state.bots.length ? name : '无机器人';
827
+ el.botBtn.title = bot
828
+ ? (name + ' · ' + bot.self_id + (bot.online ? ' · 在线' : ' · 离线'))
829
+ : '切换机器人';
830
+ el.botDot.className = 'dot ' + (bot && bot.online ? 'dot-ok' : 'dot-idle');
831
+
832
+ if (!el.botMenu.hidden) renderBotMenu();
833
+ }
834
+
835
+ function renderBotMenu() {
836
+ const menu = el.botMenu;
837
+ menu.textContent = '';
838
+
839
+ if (!state.bots.length) {
840
+ menu.appendChild(h('div', 'bot-menu-empty', '还没有连接过任何机器人'));
841
+ return;
842
+ }
843
+
844
+ state.bots.forEach((bot) => {
845
+ const name = hasText(bot.name) ? String(bot.name) : bot.self_id;
846
+ const active = bot.self_id === state.botId;
847
+ const item = h('button', 'bot-item' + (active ? ' active' : ''));
848
+ item.type = 'button';
849
+ item.setAttribute('role', 'menuitemradio');
850
+ item.setAttribute('aria-checked', active ? 'true' : 'false');
851
+ item.dataset.selfId = bot.self_id;
852
+
853
+ item.appendChild(buildAvatar(name, bot.avatar, 'sm'));
854
+
855
+ const main = h('div', 'bot-item-main');
856
+ main.appendChild(h('div', 'bot-item-name', name));
857
+ const sub = [bot.self_id];
858
+ if (hasText(bot.adapter)) sub.push(String(bot.adapter));
859
+ main.appendChild(h('div', 'bot-item-sub', sub.join(' · ')));
860
+ item.appendChild(main);
861
+
862
+ const stateTag = h('span', 'bot-state' + (bot.online ? ' online' : ''));
863
+ stateTag.appendChild(h('span', 'dot ' + (bot.online ? 'dot-ok' : 'dot-idle')));
864
+ stateTag.appendChild(h('span', null, bot.online ? '在线' : '离线'));
865
+ item.appendChild(stateTag);
866
+
867
+ if (active) item.appendChild(iconCheck());
868
+
869
+ // 备注按钮:给机器人起一个认得出的名字(悬停显示)。
870
+ // 用 span 而不是 button —— 整个菜单项本身就是个 <button>,嵌套按钮是无效 HTML。
871
+ const rename = h('span', 'bot-item-rename');
872
+ rename.setAttribute('role', 'button');
873
+ rename.setAttribute('tabindex', '0');
874
+ rename.title = '设置备注';
875
+ rename.setAttribute('aria-label', '设置机器人备注');
876
+ const pencil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
877
+ pencil.setAttribute('viewBox', '0 0 24 24');
878
+ pencil.setAttribute('aria-hidden', 'true');
879
+ const pp = document.createElementNS('http://www.w3.org/2000/svg', 'path');
880
+ pp.setAttribute('d', 'M4 20h4l10-10-4-4L4 16v4z');
881
+ pencil.appendChild(pp);
882
+ rename.appendChild(pencil);
883
+ const startRename = (ev) => {
884
+ ev.stopPropagation();
885
+ ev.preventDefault();
886
+ closeBotMenu();
887
+ openAliasDialog({ kind: 'bot', self_id: bot.self_id, alias: bot.alias || '' });
888
+ };
889
+ rename.addEventListener('click', startRename);
890
+ rename.addEventListener('keydown', (ev) => {
891
+ if (ev.key === 'Enter' || ev.key === ' ') startRename(ev);
892
+ });
893
+ item.appendChild(rename);
894
+
895
+ item.addEventListener('click', () => {
896
+ closeBotMenu();
897
+ selectBot(bot.self_id);
898
+ });
899
+ menu.appendChild(item);
900
+ });
901
+ }
902
+
903
+ function iconCheck() {
904
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
905
+ svg.setAttribute('class', 'bot-check');
906
+ svg.setAttribute('viewBox', '0 0 24 24');
907
+ svg.setAttribute('aria-hidden', 'true');
908
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
909
+ path.setAttribute('d', 'M5 12.5l4.5 4.5L19 7.5');
910
+ svg.appendChild(path);
911
+ return svg;
912
+ }
913
+
914
+ function openBotMenu() {
915
+ if (!el.botMenu.hidden) return;
916
+ renderBotMenu();
917
+ el.botMenu.hidden = false;
918
+ state.botMenuOpen = true;
919
+ el.botBtn.setAttribute('aria-expanded', 'true');
920
+ }
921
+
922
+ function closeBotMenu() {
923
+ if (el.botMenu.hidden) return;
924
+ el.botMenu.hidden = true;
925
+ state.botMenuOpen = false;
926
+ el.botBtn.setAttribute('aria-expanded', 'false');
927
+ }
928
+
929
+ function toggleBotMenu() {
930
+ if (el.botMenu.hidden) openBotMenu();
931
+ else closeBotMenu();
932
+ }
933
+
934
+ /**
935
+ * 切换机器人:会话与消息都按机器人区分,所以整页要重来一遍——
936
+ * 清空当前会话、重新拉会话列表,并让 WebSocket 只订阅该机器人的事件。
937
+ */
938
+ async function selectBot(selfId) {
939
+ const next = String(selfId || '');
940
+ if (next === state.botId) return;
941
+ saveBot(next);
942
+
943
+ // 丢弃属于上一个机器人的一切:当前会话、消息、未读与搜索
944
+ state.current = null;
945
+ state.messages = [];
946
+ state.msgMap.clear();
947
+ state.chats = [];
948
+ state.chatMap.clear();
949
+ state.unread.clear();
950
+ state.pending.clear();
951
+ state.searchHits = [];
952
+ state.focusMsgId = null;
953
+ state.replyTo = null;
954
+ state.pendingAt = [];
955
+ state.newCount = 0;
956
+ el.app.dataset.pane = 'sidebar';
957
+ el.msgList.textContent = '';
958
+ el.searchInput.value = '';
959
+ state.search = '';
960
+ closeAliasDialog();
961
+ renderBotSwitch();
962
+ renderChats();
963
+ renderHead();
964
+
965
+ try {
966
+ await loadChats();
967
+ renderConnMeta();
968
+ restoreLastChat(); // 回到这个机器人上次查看的会话
969
+ } catch (err) {
970
+ if (!err || err.status !== 401) statusErr(err && err.message ? err.message : '切换机器人失败');
971
+ }
972
+ toast('已切换到 ' + botLabel(state.botId));
973
+ }
974
+
975
+ function botLabel(selfId) {
976
+ const bot = state.bots.find((b) => b.self_id === selfId);
977
+ if (!bot) return selfId || '机器人';
978
+ return hasText(bot.name) ? String(bot.name) : bot.self_id;
979
+ }
980
+
981
+ /** 拉取全部「连接过的机器人」并刷新列表(含在线状态) */
982
+ async function loadBots() {
983
+ try {
984
+ const data = await api('/bots');
985
+ if (data && Array.isArray(data.bots)) mergeBots(data.bots);
986
+ } catch (err) {
987
+ // 拉不到就保持现有列表,等下一次事件或重连再刷新
988
+ }
989
+ }
990
+
991
+ /* ====================== 4c. 设置面板(媒体库管理) ======================
992
+ 机器人收发的图片/文件、WebUI 上传的附件都在服务端媒体库里(见后端
993
+ mediastore.py),右上角的入口用来查看占用并清理。清理是不可撤销的删除
994
+ 动作,所有操作都要用户显式点按钮,不做自动清理之外的任何隐式删除。 */
995
+
996
+ function cacheEnabled() {
997
+ return !!(state.meta && state.meta.cache_enabled);
998
+ }
999
+
1000
+ function openSettings() {
1001
+ if (state.settingsOpen) return;
1002
+ state.settingsOpen = true;
1003
+ el.settings.hidden = false;
1004
+ renderNotifySettings();
1005
+ loadCache();
1006
+ }
1007
+
1008
+ function closeSettings() {
1009
+ if (!state.settingsOpen) return;
1010
+ state.settingsOpen = false;
1011
+ el.settings.hidden = true;
1012
+ // 关面板时把「待确认」状态的按钮还原,避免下次打开还停在那里
1013
+ window.clearTimeout(cacheConfirmTimer);
1014
+ disarmCacheConfirm(el.cacheClearAll);
1015
+ }
1016
+
1017
+ function toggleSettings() {
1018
+ if (state.settingsOpen) closeSettings();
1019
+ else openSettings();
1020
+ }
1021
+
1022
+ function cacheStat(label, value, sub) {
1023
+ const box = h('div', 'cache-stat');
1024
+ box.appendChild(h('div', 'cache-stat-label', label));
1025
+ box.appendChild(h('div', 'cache-stat-value', value));
1026
+ if (sub) box.appendChild(h('div', 'cache-stat-sub', sub));
1027
+ return box;
1028
+ }
1029
+
1030
+ function renderCacheStats() {
1031
+ const data = state.cache;
1032
+ const box = el.cacheStats;
1033
+ box.textContent = '';
1034
+
1035
+ if (!cacheEnabled()) {
1036
+ el.cacheState.textContent = '未开启';
1037
+ el.cacheState.className = 'settings-badge';
1038
+ box.appendChild(cacheStat('状态', '缓存已关闭'));
1039
+ setCacheButtons(false);
1040
+ return;
1041
+ }
1042
+ el.cacheState.textContent = '已开启';
1043
+ el.cacheState.className = 'settings-badge on';
1044
+
1045
+ if (!data) {
1046
+ box.appendChild(cacheStat('状态', '读取中…'));
1047
+ setCacheButtons(false);
1048
+ return;
1049
+ }
1050
+
1051
+ const limit = Number(data.max_bytes) > 0
1052
+ ? '上限 ' + formatBytes(Number(data.max_bytes))
1053
+ : (Number(data.max_files) > 0 ? '上限 ' + Number(data.max_files) + ' 个' : '不限制');
1054
+ box.appendChild(cacheStat('占用', formatBytes(Number(data.bytes)) || '0 B', limit));
1055
+ box.appendChild(cacheStat('资源数', Number(data.files) + ' 个',
1056
+ '被引用 ' + Number(data.referenced || 0) + ' 个'));
1057
+ const images = data.images || { files: 0, bytes: 0 };
1058
+ box.appendChild(cacheStat('图片', images.files + ' 个', formatBytes(images.bytes) || '0 B'));
1059
+ const others = data.others || { files: 0, bytes: 0 };
1060
+ box.appendChild(cacheStat('文件', others.files + ' 个', formatBytes(others.bytes) || '0 B'));
1061
+ if (Number(data.pending || 0) > 0) {
1062
+ box.appendChild(cacheStat('待发送', Number(data.pending) + ' 个', '选好还没发的临时附件'));
1063
+ }
1064
+ box.appendChild(cacheStat('本次运行', '命中 ' + Number(data.hits || 0) + ' / 新增 ' + Number(data.fetched || 0),
1065
+ '未命中 ' + Number(data.misses || 0)));
1066
+ box.appendChild(cacheStat('目录', hasText(data.dir) ? String(data.dir) : '—'));
1067
+
1068
+ setCacheButtons(Number(data.files) > 0);
1069
+ }
1070
+
1071
+ function setCacheButtons(enabled) {
1072
+ const busy = state.cacheBusy || !cacheEnabled();
1073
+ [el.cacheRefresh, el.cacheClearOrphans, el.cacheClearImages,
1074
+ el.cacheClearFiles, el.cacheClearAll].forEach((btn) => {
1075
+ if (!btn) return;
1076
+ btn.disabled = busy || (!enabled && btn !== el.cacheRefresh);
1077
+ });
1078
+ }
1079
+
1080
+ async function loadCache() {
1081
+ if (!cacheEnabled()) {
1082
+ state.cache = null;
1083
+ renderCacheStats();
1084
+ return;
1085
+ }
1086
+ const seq = ++state.settingsSeq;
1087
+ try {
1088
+ const data = await api('/cache');
1089
+ if (seq !== state.settingsSeq) return;
1090
+ state.cache = data && data.cache ? data.cache : null;
1091
+ } catch (err) {
1092
+ if (seq !== state.settingsSeq) return;
1093
+ state.cache = null;
1094
+ if (!err || err.status !== 401) {
1095
+ toast(err && err.message ? err.message : '读取缓存信息失败', true);
1096
+ }
1097
+ }
1098
+ renderCacheStats();
1099
+ }
1100
+
1101
+ // 破坏性操作(清空全部)不弹 window.confirm:这个项目里一个原生弹窗都没用过,
1102
+ // 突兀的浏览器弹窗体验割裂。改成「点一次变确认、再点才执行」,3 秒不点自动还原。
1103
+ let cacheConfirmTimer = 0;
1104
+
1105
+ function armCacheConfirm(btn, text) {
1106
+ if (!btn) return;
1107
+ if (btn.dataset.armed === '1') return true;
1108
+ btn.dataset.armed = '1';
1109
+ btn.dataset.label = btn.textContent;
1110
+ btn.textContent = text || '确认?';
1111
+ btn.classList.add('armed');
1112
+ window.clearTimeout(cacheConfirmTimer);
1113
+ cacheConfirmTimer = window.setTimeout(() => disarmCacheConfirm(btn), 3000);
1114
+ return false;
1115
+ }
1116
+
1117
+ function disarmCacheConfirm(btn) {
1118
+ if (!btn) return;
1119
+ btn.dataset.armed = '';
1120
+ btn.classList.remove('armed');
1121
+ if (btn.dataset.label) btn.textContent = btn.dataset.label;
1122
+ }
1123
+
1124
+ async function clearCache(mode, label, btn) {
1125
+ if (state.cacheBusy || !cacheEnabled()) return;
1126
+ // 清空全部不可撤销,要用户再点一次确认
1127
+ if (mode === 'all' && !armCacheConfirm(btn, '确认清空?')) return;
1128
+ disarmCacheConfirm(btn);
1129
+ state.cacheBusy = true;
1130
+ setCacheButtons(true);
1131
+ try {
1132
+ const data = await api('/cache', { method: 'POST', body: { action: 'clear', mode: mode } });
1133
+ state.cache = data && data.cache ? data.cache : null;
1134
+ const removed = Number(data && data.removed) || 0;
1135
+ const freed = formatBytes(Number(data && data.bytes) || 0);
1136
+ toast('已' + (label || '清理') + ' ' + removed + ' 个' + (freed ? '(' + freed + ')' : ''));
1137
+ } catch (err) {
1138
+ if (!err || err.status !== 401) {
1139
+ toast(err && err.message ? err.message : '清理缓存失败', true);
1140
+ }
1141
+ } finally {
1142
+ state.cacheBusy = false;
1143
+ renderCacheStats();
1144
+ }
1145
+ }
1146
+
1147
+ /* ====================== 4d. 新消息网页通知 ======================
1148
+ 浏览器系统通知 + 可选提示音。用户在设置面板里显式开启(开启时会请求授权)。
1149
+ 为避免打扰,只在「页面不可见、或不在该会话且窗口失焦」时提醒,自己发的
1150
+ 消息(direction === 'out')永远不提醒。 */
1151
+
1152
+ /** 浏览器是否支持 Notification API */
1153
+ function notifySupported() {
1154
+ return typeof window.Notification === 'function';
1155
+ }
1156
+
1157
+ /** 当前授权状态:'granted' / 'denied' / 'default' / 'unsupported' */
1158
+ function notifyPermission() {
1159
+ if (!notifySupported()) return 'unsupported';
1160
+ return Notification.permission || 'default';
1161
+ }
1162
+
1163
+ function readStoredNotify() {
1164
+ const fallback = { enabled: false, sound: true };
1165
+ try {
1166
+ const raw = localStorage.getItem(NOTIFY_KEY);
1167
+ if (!raw) return fallback;
1168
+ const data = JSON.parse(raw);
1169
+ if (!data || typeof data !== 'object') return fallback;
1170
+ return { enabled: data.enabled === true, sound: data.sound !== false };
1171
+ } catch (err) { return fallback; }
1172
+ }
1173
+
1174
+ function saveNotify() {
1175
+ try { localStorage.setItem(NOTIFY_KEY, JSON.stringify(state.notify)); } catch (err) { /* 忽略 */ }
1176
+ }
1177
+
1178
+ function initNotify() {
1179
+ state.notify = readStoredNotify();
1180
+ // 授权被撤销或浏览器不支持时,本地偏好也跟着失效,避免界面上显示「已开启」却不弹
1181
+ if (state.notify.enabled && (notifyPermission() !== 'granted')) state.notify.enabled = false;
1182
+ }
1183
+
1184
+ function notifyActive() {
1185
+ return !!(state.notify.enabled && notifyPermission() === 'granted');
1186
+ }
1187
+
1188
+ /** 刷新设置面板里的通知开关与状态徽章 */
1189
+ function renderNotifySettings() {
1190
+ if (!el.notifyToggle) return;
1191
+ const perm = notifyPermission();
1192
+ el.notifyToggle.checked = !!state.notify.enabled;
1193
+ el.notifySound.checked = !!state.notify.sound;
1194
+ el.notifyToggle.disabled = perm === 'denied' || perm === 'unsupported';
1195
+ el.notifySound.disabled = !state.notify.enabled;
1196
+
1197
+ if (perm === 'unsupported') {
1198
+ el.notifyState.textContent = '不支持';
1199
+ el.notifyState.className = 'settings-badge';
1200
+ } else if (perm === 'denied') {
1201
+ el.notifyState.textContent = '已拒绝';
1202
+ el.notifyState.className = 'settings-badge';
1203
+ } else if (notifyActive()) {
1204
+ el.notifyState.textContent = '已开启';
1205
+ el.notifyState.className = 'settings-badge on';
1206
+ } else {
1207
+ el.notifyState.textContent = '已关闭';
1208
+ el.notifyState.className = 'settings-badge';
1209
+ }
1210
+ }
1211
+
1212
+ /** 用户在设置里切换开关:开启时先确保拿到授权 */
1213
+ async function setNotifyEnabled(on) {
1214
+ if (!on) {
1215
+ state.notify.enabled = false;
1216
+ saveNotify();
1217
+ renderNotifySettings();
1218
+ return;
1219
+ }
1220
+ if (!notifySupported()) {
1221
+ toast('当前浏览器不支持网页通知', true);
1222
+ renderNotifySettings();
1223
+ return;
1224
+ }
1225
+ let perm = notifyPermission();
1226
+ if (perm === 'default') {
1227
+ try { perm = await Notification.requestPermission(); } catch (err) { perm = notifyPermission(); }
1228
+ }
1229
+ if (perm !== 'granted') {
1230
+ state.notify.enabled = false;
1231
+ toast(perm === 'denied' ? '通知权限已被拒绝,请在浏览器设置里允许' : '未获得通知权限', true);
1232
+ saveNotify();
1233
+ renderNotifySettings();
1234
+ return;
1235
+ }
1236
+ state.notify.enabled = true;
1237
+ saveNotify();
1238
+ renderNotifySettings();
1239
+ toast('已开启新消息通知');
1240
+ }
1241
+
1242
+ function setNotifySound(on) {
1243
+ state.notify.sound = !!on;
1244
+ saveNotify();
1245
+ renderNotifySettings();
1246
+ }
1247
+
1248
+ /**
1249
+ * 判断一条新消息是否需要提醒。
1250
+ *
1251
+ * 规则:自己发的、正在看且窗口可见的、浏览器不支持/未授权的都不提醒。
1252
+ * 另外只在「不在该会话,或窗口失焦/页面隐藏」时提醒 —— 换句话说,
1253
+ * 用户正盯着这个会话时不该被打扰。
1254
+ */
1255
+ function shouldNotify(msg, isOpen) {
1256
+ if (!notifyActive()) return false;
1257
+ if (!msg || msg.direction === 'out') return false;
1258
+ const blurred = !document.hasFocus() || document.visibilityState !== 'visible';
1259
+ return !isOpen || blurred;
1260
+ }
1261
+
1262
+ /** 弹出一条系统通知(失败静默,不影响消息流) */
1263
+ function pushNotification(msg) {
1264
+ try {
1265
+ const key = String(msg.chat);
1266
+ const chat = state.chatMap.get(key) || (state.current && state.current.key === key ? state.current : null);
1267
+ const title = (chat && hasText(chat.name)) ? String(chat.name)
1268
+ : (hasText(msg.chat_name) ? String(msg.chat_name) : '新消息');
1269
+ const who = hasText(msg.user_name) ? String(msg.user_name) : (hasText(msg.user_id) ? String(msg.user_id) : '');
1270
+ const body = (who ? who + ':' : '') + (previewOf(msg) || '新消息');
1271
+ const n = new Notification(title, { body: shorten(body, 120), tag: key, renotify: true });
1272
+ n.onclick = () => {
1273
+ try { window.focus(); } catch (err) { /* 忽略 */ }
1274
+ if (state.chatMap.has(key)) openChat(key);
1275
+ n.close();
1276
+ };
1277
+ } catch (err) { /* 通知失败(权限被撤销等)不影响主流程 */ }
1278
+ }
1279
+
1280
+ /* --- 提示音:用 WebAudio 合成两声短提示,免外部音频文件 --- */
1281
+ let audioCtx = null;
1282
+ function playNotifySound() {
1283
+ if (!state.notify.sound) return;
1284
+ try {
1285
+ const Ctx = window.AudioContext || window.webkitAudioContext;
1286
+ if (!Ctx) return;
1287
+ if (!audioCtx) audioCtx = new Ctx();
1288
+ if (audioCtx.state === 'suspended') audioCtx.resume();
1289
+ const now = audioCtx.currentTime;
1290
+ [880, 1174].forEach((freq, i) => {
1291
+ const osc = audioCtx.createOscillator();
1292
+ const gain = audioCtx.createGain();
1293
+ osc.type = 'sine';
1294
+ osc.frequency.value = freq;
1295
+ const start = now + i * 0.12;
1296
+ gain.gain.setValueAtTime(0.0001, start);
1297
+ gain.gain.exponentialRampToValueAtTime(0.12, start + 0.02);
1298
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.18);
1299
+ osc.connect(gain).connect(audioCtx.destination);
1300
+ osc.start(start);
1301
+ osc.stop(start + 0.2);
1302
+ });
1303
+ } catch (err) { /* 自动播放被拦截等,静默 */ }
1304
+ }
1305
+
1306
+ /** 收到新消息时的统一提醒入口(通知 + 提示音) */
1307
+ function notifyMessage(msg, isOpen) {
1308
+ if (!shouldNotify(msg, isOpen)) return;
1309
+ pushNotification(msg);
1310
+ playNotifySound();
1311
+ }
1312
+
1313
+ /* ================== 4e. 备注(别名) ==================
1314
+ 有些适配器拿不到真实的群名或用户昵称,界面上就只剩一串号码。备注让用户
1315
+ 自己给会话/成员/机器人起个认得出的名字,只影响展示,不写入聊天记录,也
1316
+ 不会被适配器后续上报的名称覆盖。同时只能打开一个编辑框,目标存在 state 里。 */
1317
+
1318
+ function aliasTargetName(chat) {
1319
+ if (!chat) return '会话';
1320
+ return chat.alias ? String(chat.alias) : (hasText(chat.name) ? String(chat.name) : String(chat.id || '会话'));
1321
+ }
1322
+
1323
+ /** 打开备注编辑框:target 形如 {kind:'chat'|'member'|'bot', ...} */
1324
+ function openAliasDialog(target) {
1325
+ if (!target || !target.kind) return;
1326
+ if (state.meta && state.meta.write_enabled === false) { toast('只读模式,无法设置备注', true); return; }
1327
+ state.aliasTarget = target;
1328
+ el.aliasTitle.textContent = target.kind === 'chat' ? '会话备注'
1329
+ : (target.kind === 'member' ? '成员备注' : '机器人备注');
1330
+ el.aliasDesc.textContent = '备注只影响界面显示,不会写入聊天记录,也不会被适配器上报的名称覆盖。'
1331
+ + '留空并保存即可清除。';
1332
+ el.aliasInput.value = target.alias ? String(target.alias) : '';
1333
+ el.aliasDialog.hidden = false;
1334
+ el.aliasClear.hidden = !target.alias;
1335
+ el.aliasInput.focus();
1336
+ el.aliasInput.select();
1337
+ }
1338
+
1339
+ function closeAliasDialog() {
1340
+ if (el.aliasDialog.hidden) return;
1341
+ el.aliasDialog.hidden = true;
1342
+ state.aliasTarget = null;
1343
+ el.aliasInput.value = '';
1344
+ }
1345
+
1346
+ async function saveAlias(alias) {
1347
+ const target = state.aliasTarget;
1348
+ if (!target) return;
1349
+ const body = Object.assign({}, target, { alias: alias || '' });
1350
+ try {
1351
+ const data = await api('/rename', { method: 'POST', body: body });
1352
+ applyAliasResult(target, data);
1353
+ closeAliasDialog();
1354
+ toast(alias ? '备注已保存' : '备注已清除');
1355
+ } catch (err) {
1356
+ if (!err || err.status !== 401) toast(err && err.message ? err.message : '备注保存失败', true);
1357
+ }
1358
+ }
1359
+
1360
+ /** 把服务端返回的结果落到本地状态(不整表重拉) */
1361
+ function applyAliasResult(target, data) {
1362
+ if (target.kind === 'chat' && data && data.chat) {
1363
+ const chat = state.chatMap.get(String(data.chat.key));
1364
+ if (chat) {
1365
+ chat.alias = data.chat.alias || '';
1366
+ chat.name = data.chat.name; // 服务端已算好展示名(备注优先)
1367
+ chat.raw_name = data.chat.raw_name || null;
1368
+ }
1369
+ if (state.current && state.current.key === String(data.chat.key)) {
1370
+ state.current = state.chatMap.get(String(data.chat.key)) || state.current;
1371
+ renderHead();
1372
+ }
1373
+ sortChats();
1374
+ renderChats();
1375
+ return;
1376
+ }
1377
+ if (target.kind === 'member' && data && data.member) {
1378
+ const key = String(target.chat);
1379
+ const member = data.member;
1380
+ const idx = state.members.findIndex((m) => String(m.id) === String(member.id));
1381
+ if (idx >= 0) state.members[idx] = member; else state.members.push(member);
1382
+ state.memberMap.set(String(member.id), member);
1383
+ if (!el.atMenu.hidden) renderAtMenu();
1384
+ renderPendingAt();
1385
+ return;
1386
+ }
1387
+ if (target.kind === 'bot' && data && data.bot) {
1388
+ const idx = state.bots.findIndex((b) => b.self_id === String(data.bot.self_id));
1389
+ if (idx >= 0) state.bots[idx] = Object.assign({}, state.bots[idx], data.bot);
1390
+ renderBotSwitch();
1391
+ renderConnMeta();
1392
+ }
1393
+ }
1394
+
1395
+ /* ============================ 5. 会话列表 ============================ */
1396
+
1397
+ function sortChats() {
1398
+ state.chats.sort((a, b) => (parseTime(b.last_at) || 0) - (parseTime(a.last_at) || 0));
1399
+ }
1400
+
1401
+ async function loadChats() {
1402
+ renderSkeletons();
1403
+ let data;
1404
+ try {
1405
+ data = await api('/chats?limit=200');
1406
+ } catch (err) {
1407
+ renderChatError(err);
1408
+ throw err;
1409
+ }
1410
+
1411
+ const list = data && Array.isArray(data.chats) ? data.chats : [];
1412
+ state.chatMap.clear();
1413
+ state.chats = list.filter((c) => c && hasText(c.key)).map((raw) => {
1414
+ const chat = Object.assign({}, raw);
1415
+ chat.key = String(chat.key);
1416
+ if (!hasText(chat.name)) chat.name = hasText(chat.id) ? String(chat.id) : '未知会话';
1417
+ if (!state.unread.has(chat.key) && Number(chat.unread) > 0) {
1418
+ state.unread.set(chat.key, Math.floor(Number(chat.unread)));
1419
+ }
1420
+ state.chatMap.set(chat.key, chat);
1421
+ return chat;
1422
+ });
1423
+ if (state.current) state.current = state.chatMap.get(state.current.key) || state.current;
1424
+ sortChats();
1425
+ renderChats();
1426
+ renderHead();
1427
+ }
1428
+
1429
+ function renderSkeletons() {
1430
+ const box = el.chatList;
1431
+ box.textContent = '';
1432
+ for (let i = 0; i < 5; i++) {
1433
+ const row = h('div', 'skeleton-item');
1434
+ row.appendChild(h('div', 'sk-box'));
1435
+ const lines = h('div', 'sk-lines');
1436
+ lines.appendChild(h('div', 'sk-line w60'));
1437
+ lines.appendChild(h('div', 'sk-line w85'));
1438
+ row.appendChild(lines);
1439
+ box.appendChild(row);
1440
+ }
1441
+ }
1442
+
1443
+ function renderChatError(err) {
1444
+ const box = el.chatList;
1445
+ box.textContent = '';
1446
+ box.appendChild(emptyState('会话加载失败', err && err.message ? err.message : '未知错误'));
1447
+ }
1448
+
1449
+ function renderChats() {
1450
+ const box = el.chatList;
1451
+ box.textContent = '';
1452
+
1453
+ const keyword = state.search.trim().toLowerCase();
1454
+ const searching = state.searchMode === 'msgs' && !!keyword;
1455
+
1456
+ // 消息搜索模式下由 renderSearchHits 负责结果区,这里只管会话列表
1457
+ el.searchTabs.hidden = !keyword;
1458
+ el.searchClear.hidden = !keyword;
1459
+ el.msgResults.hidden = !searching;
1460
+ box.hidden = searching;
1461
+
1462
+ if (searching) {
1463
+ renderSearchHits();
1464
+ return;
1465
+ }
1466
+
1467
+ if (!state.chats.length) {
1468
+ box.appendChild(emptyState('还没有任何会话', keyword ? '试试其他关键词' : '等待机器人收到消息'));
1469
+ return;
1470
+ }
1471
+
1472
+ // 会话名 / 群号 / 最后一条消息都是本地过滤,即时响应
1473
+ const list = keyword ? state.chats.filter((c) => {
1474
+ return String(c.name || '').toLowerCase().indexOf(keyword) >= 0 ||
1475
+ String(c.id || '').toLowerCase().indexOf(keyword) >= 0 ||
1476
+ String(c.last_text || '').toLowerCase().indexOf(keyword) >= 0;
1477
+ }) : state.chats;
1478
+
1479
+ if (!list.length) {
1480
+ box.appendChild(emptyState('没有匹配的会话', '试试切到「消息」搜索正文'));
1481
+ return;
1482
+ }
1483
+
1484
+ const frag = document.createDocumentFragment();
1485
+ list.forEach((chat) => frag.appendChild(buildChatItem(chat)));
1486
+ box.appendChild(frag);
1487
+ }
1488
+
1489
+ /** 把搜索关键词在文本里高亮出来(返回 DocumentFragment) */
1490
+ function highlight(text, keyword) {
1491
+ const frag = document.createDocumentFragment();
1492
+ const source = String(text == null ? '' : text);
1493
+ const needle = String(keyword || '').toLowerCase();
1494
+ if (!needle) { frag.appendChild(document.createTextNode(source)); return frag; }
1495
+
1496
+ const lower = source.toLowerCase();
1497
+ let from = 0;
1498
+ let at = lower.indexOf(needle);
1499
+ while (at >= 0) {
1500
+ if (at > from) frag.appendChild(document.createTextNode(source.slice(from, at)));
1501
+ frag.appendChild(h('mark', 'hit', source.slice(at, at + needle.length)));
1502
+ from = at + needle.length;
1503
+ at = lower.indexOf(needle, from);
1504
+ }
1505
+ frag.appendChild(document.createTextNode(source.slice(from)));
1506
+ return frag;
1507
+ }
1508
+
1509
+ /** 关键词前后各截一段,避免长消息把结果撑爆 */
1510
+ function snippet(text, keyword, span) {
1511
+ const source = String(text == null ? '' : text);
1512
+ const width = span || 60;
1513
+ if (source.length <= width * 2) return source;
1514
+ const at = source.toLowerCase().indexOf(String(keyword || '').toLowerCase());
1515
+ if (at < 0) return source.slice(0, width * 2) + '…';
1516
+ const start = Math.max(0, at - width);
1517
+ const end = Math.min(source.length, at + width);
1518
+ return (start > 0 ? '…' : '') + source.slice(start, end) + (end < source.length ? '…' : '');
1519
+ }
1520
+
1521
+ function renderSearchHits() {
1522
+ const box = el.msgResults;
1523
+ box.textContent = '';
1524
+ const keyword = state.search.trim();
1525
+
1526
+ if (state.searching && !state.searchHits.length) {
1527
+ for (let i = 0; i < 4; i++) {
1528
+ const row = h('div', 'skeleton-item');
1529
+ row.appendChild(h('div', 'sk-line w85'));
1530
+ box.appendChild(row);
1531
+ }
1532
+ return;
1533
+ }
1534
+ if (!state.searchHits.length) {
1535
+ box.appendChild(emptyState('没有找到匹配的消息', '换个关键词试试'));
1536
+ return;
1537
+ }
1538
+
1539
+ const frag = document.createDocumentFragment();
1540
+ state.searchHits.forEach((msg) => {
1541
+ const item = h('div', 'hit-item');
1542
+ item.setAttribute('role', 'listitem');
1543
+ item.dataset.chatKey = msg.chat_key || '';
1544
+ item.dataset.msgId = String(msg.id);
1545
+
1546
+ const top = h('div', 'hit-top');
1547
+ top.appendChild(h('span', 'hit-chat', msg.chat_name || msg.chat_id || '未知会话'));
1548
+ top.appendChild(h('span', 'hit-time', listTime(msg.ts)));
1549
+ item.appendChild(top);
1550
+
1551
+ const who = h('span', 'hit-who');
1552
+ who.textContent = (msg.direction === 'out' ? '我' : (msg.user_name || msg.user_id || '未知')) + ':';
1553
+ const body = h('div', 'hit-text');
1554
+ body.appendChild(who);
1555
+ body.appendChild(highlight(snippet(msg.text, keyword), keyword));
1556
+ item.appendChild(body);
1557
+
1558
+ frag.appendChild(item);
1559
+ });
1560
+ box.appendChild(frag);
1561
+ }
1562
+
1563
+ /** 请求服务端做消息全文搜索 */
1564
+ async function runSearch() {
1565
+ const keyword = state.search.trim();
1566
+ if (!keyword || state.searchMode !== 'msgs') { state.searchHits = []; return; }
1567
+
1568
+ const seq = ++state.searchSeq;
1569
+ state.searching = true;
1570
+ renderSearchHits();
1571
+ try {
1572
+ const data = await api('/search?q=' + encodeURIComponent(keyword) + '&limit=100');
1573
+ // 期间又输入了新的关键词就丢弃这次结果
1574
+ if (seq !== state.searchSeq) return;
1575
+ state.searchHits = data && Array.isArray(data.messages) ? data.messages : [];
1576
+ } catch (err) {
1577
+ if (seq !== state.searchSeq) return;
1578
+ state.searchHits = [];
1579
+ toast('搜索失败:' + (err && err.message ? err.message : '未知错误'), true);
1580
+ } finally {
1581
+ if (seq === state.searchSeq) {
1582
+ state.searching = false;
1583
+ renderSearchHits();
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ function setSearchMode(mode) {
1589
+ if (state.searchMode === mode) return;
1590
+ state.searchMode = mode;
1591
+ const isMsgs = mode === 'msgs';
1592
+ el.tabChats.classList.toggle('active', !isMsgs);
1593
+ el.tabMsgs.classList.toggle('active', isMsgs);
1594
+ el.tabChats.setAttribute('aria-selected', String(!isMsgs));
1595
+ el.tabMsgs.setAttribute('aria-selected', String(isMsgs));
1596
+ renderChats();
1597
+ if (isMsgs && state.search.trim()) runSearch();
1598
+ }
1599
+
1600
+ /** 点搜索结果:打开对应会话并定位到那条消息 */
1601
+ async function openSearchHit(item) {
1602
+ const key = item.dataset.chatKey;
1603
+ const msgId = Number(item.dataset.msgId);
1604
+ if (!key) return;
1605
+ state.focusMsgId = Number.isFinite(msgId) ? msgId : null;
1606
+ await openChat(key);
1607
+ }
1608
+
1609
+ function buildChatItem(chat) {
1610
+ const active = !!(state.current && state.current.key === chat.key);
1611
+ const item = h('div', 'chat-item' + (active ? ' active' : ''));
1612
+ item.setAttribute('role', 'listitem');
1613
+ item.dataset.key = chat.key;
1614
+ item.title = (chat.kind === 'group' ? '群聊' : '私聊') + ' · ' + String(chat.id || '');
1615
+
1616
+ item.appendChild(buildAvatar(chat.name, chat.avatar));
1617
+
1618
+ const main = h('div', 'chat-item-main');
1619
+ const top = h('div', 'chat-item-top');
1620
+ top.appendChild(h('div', 'chat-item-name', chat.name));
1621
+
1622
+ const unread = state.unread.get(chat.key) || 0;
1623
+ if (unread > 0) {
1624
+ const pill = h('div', 'unread-pill', unread > 99 ? '99+' : String(unread));
1625
+ pill.title = unread + ' 条未读';
1626
+ top.appendChild(pill);
1627
+ }
1628
+ top.appendChild(h('div', 'chat-item-time', listTime(chat.last_at)));
1629
+ main.appendChild(top);
1630
+
1631
+ const bottom = h('div', 'chat-item-bottom');
1632
+ const last = h('div', 'chat-item-last');
1633
+ const pending = state.pending.get(chat.key);
1634
+ if (pending) {
1635
+ // 未打开时有新消息:显示“新”标记 + 摘要
1636
+ last.appendChild(h('span', 'unread-pill muted', '新'));
1637
+ last.appendChild(document.createTextNode(' ' + (pending.text || '新消息')));
1638
+ } else {
1639
+ const text = hasText(chat.last_text) ? String(chat.last_text) : '[暂无内容]';
1640
+ last.textContent = chat.last_direction === 'out' ? '我: ' + text : text;
1641
+ }
1642
+ bottom.appendChild(last);
1643
+ main.appendChild(bottom);
1644
+ item.appendChild(main);
1645
+ return item;
1646
+ }
1647
+
1648
+ function emptyState(title, sub) {
1649
+ const box = h('div', 'empty');
1650
+ const ico = h('div', 'empty-ico');
1651
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1652
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1653
+ circle.setAttribute('cx', '12');
1654
+ circle.setAttribute('cy', '12');
1655
+ circle.setAttribute('r', '7.6');
1656
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1657
+ path.setAttribute('d', 'M8.4 11.4h7.2M8.4 14.8h4.4');
1658
+ svg.appendChild(circle);
1659
+ svg.appendChild(path);
1660
+ ico.appendChild(svg);
1661
+ box.appendChild(ico);
1662
+ box.appendChild(h('div', null, title));
1663
+ if (hasText(sub)) box.appendChild(h('div', null, sub));
1664
+ return box;
1665
+ }
1666
+
1667
+ /* --- 会话头部 --- */
1668
+ function renderHead() {
1669
+ const chat = state.current;
1670
+ el.headAvatar.textContent = '';
1671
+
1672
+ if (!chat) {
1673
+ const placeholder = h('div', 'avatar lg');
1674
+ applyInitial(placeholder, '?');
1675
+ el.headAvatar.appendChild(placeholder);
1676
+ el.headName.textContent = '未选择会话';
1677
+ el.headKind.hidden = true;
1678
+ el.headRenameBtn.hidden = true;
1679
+ el.headSub.textContent = '从左侧选择一个会话开始查看消息';
1680
+ return;
1681
+ }
1682
+
1683
+ const isGroup = chat.kind === 'group';
1684
+ el.headAvatar.appendChild(buildAvatar(chat.name, chat.avatar, 'lg'));
1685
+ el.headName.textContent = chat.name;
1686
+ el.headRenameBtn.hidden = !(state.meta && state.meta.write_enabled !== false);
1687
+ el.headKind.hidden = false;
1688
+ el.headKind.textContent = isGroup ? '群聊' : '私聊';
1689
+ el.headKind.className = 'badge' + (isGroup ? '' : ' kind-private');
1690
+
1691
+ const parts = [];
1692
+ if (chat.alias && hasText(chat.raw_name)) parts.push('原名 ' + String(chat.raw_name));
1693
+ if (hasText(chat.id)) parts.push((isGroup ? '群号 ' : 'QQ ') + chat.id);
1694
+ const count = Number(chat.member_count);
1695
+ if (Number.isFinite(count) && count > 0) parts.push(count + ' 人');
1696
+ el.headSub.textContent = parts.length ? parts.join(' · ') : (isGroup ? '群聊' : '私聊');
1697
+ }
1698
+
1699
+ /* ============================ 6. 消息渲染 ============================ */
1700
+
1701
+ /** 按时间插入日期 / 时间分隔符 */
1702
+ function appendSeparators(container, seconds) {
1703
+ const t = parseTime(seconds);
1704
+ if (t === null) return;
1705
+ const label = dayLabel(t);
1706
+ if (label !== state.sep.day) {
1707
+ state.sep.day = label;
1708
+ state.sep.time = t;
1709
+ const sep = h('div', 'sep');
1710
+ sep.appendChild(h('span', null, label));
1711
+ container.appendChild(sep);
1712
+ return;
1713
+ }
1714
+ if (state.sep.time && t - state.sep.time > 300) {
1715
+ const sep = h('div', 'sep time');
1716
+ sep.appendChild(h('span', null, clockLabel(t)));
1717
+ container.appendChild(sep);
1718
+ }
1719
+ state.sep.time = t;
1720
+ }
1721
+
1722
+ function buildMessage(msg) {
1723
+ const out = msg.direction === 'out';
1724
+ const isGroup = !!(state.current && state.current.kind === 'group');
1725
+ const prev = state.lastRendered;
1726
+ const sameUser = !!(prev && prev.user_id === msg.user_id && prev.direction === msg.direction);
1727
+ const compact = !out && isGroup && sameUser && !!prev;
1728
+
1729
+ const row = h('div', 'msg ' + (out ? 'out' : 'in') + (compact ? ' compact' : ''));
1730
+ row.dataset.id = String(msg.id);
1731
+
1732
+ // 入群消息显示头像;同一人连续发言时留出等宽占位
1733
+ if (!out) {
1734
+ const name = hasText(msg.user_name) ? String(msg.user_name) : String(msg.user_id || '未知用户');
1735
+ if (compact) row.appendChild(h('div', 'avatar-spacer'));
1736
+ else row.appendChild(buildAvatar(name, msg.user_avatar));
1737
+ }
1738
+
1739
+ const body = h('div', 'msg-body');
1740
+
1741
+ // 群聊里的入群消息展示昵称 + 身份 + 时间;私聊只在不紧凑时显示时间
1742
+ if (!out && (isGroup || !compact)) {
1743
+ const meta = h('div', 'msg-meta');
1744
+ meta.appendChild(h('span', 'msg-name', hasText(msg.user_name) ? String(msg.user_name) : String(msg.user_id || '未知用户')));
1745
+ if (msg.role === 'owner') meta.appendChild(h('span', 'role-tag owner', '群主'));
1746
+ else if (msg.role === 'admin') meta.appendChild(h('span', 'role-tag admin', '管理员'));
1747
+ const t = parseTime(msg.time);
1748
+ if (t !== null) {
1749
+ const time = h('span', 'msg-time', clockLabel(t));
1750
+ time.title = fullTime(t);
1751
+ meta.appendChild(time);
1752
+ }
1753
+ body.appendChild(meta);
1754
+ }
1755
+
1756
+ const segs = Array.isArray(msg.segments) ? msg.segments : [];
1757
+ const segments = segs.length ? segs : (hasText(msg.text) ? [{ type: 'text', text: msg.text }] : []);
1758
+ body.appendChild(buildBubble(segments));
1759
+ row.appendChild(body);
1760
+
1761
+ state.lastRendered = msg;
1762
+ return row;
1763
+ }
1764
+
1765
+ function buildBubble(segments) {
1766
+ const hasImage = segments.some((s) => s && s.type === 'image' && safeUrl(s.url));
1767
+ const bubble = h('div', 'bubble' + (hasImage ? ' media' : ''));
1768
+ const hasContent = segments.some((s) => !(s && s.type === 'text' && !hasText(s.text)));
1769
+
1770
+ if (!hasContent) {
1771
+ bubble.appendChild(h('span', 'seg-text', '[空消息]'));
1772
+ return bubble;
1773
+ }
1774
+
1775
+ segments.forEach((seg) => {
1776
+ if (!seg || typeof seg !== 'object') return;
1777
+ switch (seg.type) {
1778
+ case 'text': {
1779
+ if (!hasText(seg.text)) return;
1780
+ const span = h('span', 'seg-text');
1781
+ span.textContent = String(seg.text); // 纯文本插入,保留换行
1782
+ bubble.appendChild(span);
1783
+ return;
1784
+ }
1785
+ case 'at': {
1786
+ const name = hasText(seg.name) ? String(seg.name) : (hasText(seg.target) ? String(seg.target) : '某人');
1787
+ const at = h('span', 'seg-at', '@' + name);
1788
+ if (hasText(seg.target)) at.title = 'QQ ' + seg.target;
1789
+ bubble.appendChild(at);
1790
+ return;
1791
+ }
1792
+ case 'image':
1793
+ bubble.appendChild(buildImage(seg));
1794
+ return;
1795
+ case 'face':
1796
+ bubble.appendChild(h('span', 'chip seg-face', '[表情 ' + (hasText(seg.id) ? seg.id : '?') + ']'));
1797
+ return;
1798
+ case 'reply': {
1799
+ bubble.appendChild(buildQuote(seg));
1800
+ return;
1801
+ }
1802
+ case 'voice':
1803
+ bubble.appendChild(buildMediaPlayer(seg, 'voice'));
1804
+ return;
1805
+ case 'audio':
1806
+ bubble.appendChild(buildMediaPlayer(seg, 'audio'));
1807
+ return;
1808
+ case 'video':
1809
+ bubble.appendChild(buildMediaPlayer(seg, 'video'));
1810
+ return;
1811
+ case 'button': {
1812
+ const label = hasText(seg.label) ? String(seg.label) : '[按钮]';
1813
+ const chip = h('span', 'chip seg-button', label);
1814
+ if (hasText(seg.flag)) chip.title = '指令:' + seg.flag;
1815
+ const link = safeUrl(seg.url);
1816
+ if (link) {
1817
+ chip.appendChild(externalLink(link, '打开'));
1818
+ chip.classList.add('clickable');
1819
+ }
1820
+ bubble.appendChild(chip);
1821
+ return;
1822
+ }
1823
+ case 'file': {
1824
+ bubble.appendChild(buildFileCard(seg));
1825
+ return;
1826
+ }
1827
+ case 'forward': {
1828
+ bubble.appendChild(buildForwardCard(seg));
1829
+ return;
1830
+ }
1831
+ case 'json': {
1832
+ const chip = h('span', 'chip clickable', '[卡片消息]');
1833
+ chip.title = '点击复制原始数据';
1834
+ chip.dataset.copy = hasText(seg.data) ? String(seg.data) : '[卡片消息]';
1835
+ bubble.appendChild(chip);
1836
+ return;
1837
+ }
1838
+ default:
1839
+ bubble.appendChild(h('span', 'chip seg-unknown', '[不支持的段]'));
1840
+ }
1841
+ });
1842
+ return bubble;
1843
+ }
1844
+
1845
+ /**
1846
+ * 引用消息。
1847
+ *
1848
+ * 只显示「回复 12345」是看不出回复了什么的,所以这里尽量渲染成一条引用块:
1849
+ * 有「谁 + 内容摘要」就显示它们,都没有就退化成原来的「回复 <id>」小标签,
1850
+ * 两种情况点击时都会跳到被引用的那条消息。
1851
+ */
1852
+ function buildQuote(seg) {
1853
+ const id = hasText(seg.id) ? String(seg.id) : '';
1854
+ const who = hasText(seg.name) ? String(seg.name) : '';
1855
+ const body = quoteText(seg);
1856
+
1857
+ if (!who && !body) {
1858
+ const chip = h('span', 'chip seg-reply clickable', '回复 ' + (id || '?'));
1859
+ if (id) {
1860
+ chip.dataset.replyId = id;
1861
+ chip.title = '点击定位被回复的消息';
1862
+ }
1863
+ return chip;
1864
+ }
1865
+
1866
+ const quote = h('span', 'quote' + (id ? ' clickable' : ''));
1867
+ if (who) quote.appendChild(h('span', 'quote-who', who + ':'));
1868
+ quote.appendChild(h('span', 'quote-body', body || id || ''));
1869
+ if (id) {
1870
+ quote.dataset.replyId = id;
1871
+ quote.title = '消息 ' + id + ' · 点击定位被回复的消息';
1872
+ }
1873
+ return quote;
1874
+ }
1875
+
1876
+ function placeholderChip(label, url) {
1877
+ const link = safeUrl(url);
1878
+ const chip = h('span', 'chip');
1879
+ chip.appendChild(h('span', null, '[' + label + ']'));
1880
+ if (link) {
1881
+ chip.appendChild(externalLink(proxied(url), '打开'));
1882
+ chip.classList.add('clickable');
1883
+ } else {
1884
+ chip.appendChild(h('span', null, '暂不支持播放'));
1885
+ }
1886
+ return chip;
1887
+ }
1888
+
1889
+ /**
1890
+ * 音视频内联播放器。
1891
+ *
1892
+ * QQ 的语音/视频链接同样有防盗链,所以 src 统一走后端 /media 代理;代理
1893
+ * 支持 Range,拖动进度条也不会整段重下。拿不到链接时退化成占位标签。
1894
+ */
1895
+ function buildMediaPlayer(seg, kind) {
1896
+ const label = kind === 'voice' ? '语音' : (kind === 'audio' ? '音频' : '视频');
1897
+ const tags = {
1898
+ voice: 'audio', audio: 'audio', video: 'video'
1899
+ };
1900
+ const duration = Number(seg.duration);
1901
+ const suffix = Number.isFinite(duration) && duration > 0 ? '(' + Math.round(duration) + 's)' : '';
1902
+
1903
+ const source = safeUrl(seg.url);
1904
+ if (!source) {
1905
+ const chip = h('span', 'chip');
1906
+ chip.appendChild(h('span', null, '[' + label + suffix + ']'));
1907
+ chip.appendChild(h('span', null, '暂不支持播放'));
1908
+ return chip;
1909
+ }
1910
+
1911
+ const wrap = h('span', 'seg-block seg-media');
1912
+ const player = document.createElement(tags[kind] || 'audio');
1913
+ player.className = 'seg-player' + (kind === 'video' ? ' seg-video' : '');
1914
+ player.controls = true;
1915
+ player.preload = 'metadata';
1916
+ if (kind === 'video') player.playsInline = true;
1917
+ // 统一走 /api/file:媒体库副本命中时它直接回本地文件,未命中则代理原链接,
1918
+ // 两条路径都带 Range 支持(音视频拖进度条靠它)。
1919
+ player.src = fileUrl(seg.url, fileNameOf(seg), false);
1920
+ player.addEventListener('error', () => {
1921
+ if (!player.parentNode) return;
1922
+ const fallback = placeholderChip(label + suffix, seg.url);
1923
+ player.parentNode.replaceChild(fallback, player);
1924
+ });
1925
+ wrap.appendChild(player);
1926
+ return wrap;
1927
+ }
1928
+
1929
+ function fileExt(name) {
1930
+ const text = hasText(name) ? String(name).trim() : '';
1931
+ const dot = text.lastIndexOf('.');
1932
+ return dot >= 0 && dot < text.length - 1 ? text.slice(dot + 1).toLowerCase() : '';
1933
+ }
1934
+
1935
+ /** 通用段给出的占位文件名(拿不到真名时就是它们) */
1936
+ const PLACEHOLDER_NAMES = ['file.bin', 'file', 'files', 'media', 'media.bin',
1937
+ 'image', 'image.png', 'audio.mp3', 'voice.wav', 'video.mp4'];
1938
+
1939
+ /**
1940
+ * 从下载直链里还原真实文件名。
1941
+ * 与服务端 segments.resolve_file_name 同一套规则(这里只是给旧记录兜底,
1942
+ * 新记录服务端已经存好真名了):查询串 fname/filename/name → 路径末段。
1943
+ */
1944
+ function fileNameFromUrl(raw) {
1945
+ const url = hasText(raw) ? String(raw) : '';
1946
+ if (!url) return '';
1947
+ let path = url;
1948
+ let query = '';
1949
+ const q = url.indexOf('?');
1950
+ if (q >= 0) { query = url.slice(q + 1); path = url.slice(0, q); }
1951
+ const keys = ['fname', 'filename', 'file_name', 'name', 'realname', 'real_name'];
1952
+ for (const part of query.split('&')) {
1953
+ const eq = part.indexOf('=');
1954
+ if (eq < 0) continue;
1955
+ const key = part.slice(0, eq).toLowerCase();
1956
+ if (keys.indexOf(key) < 0) continue;
1957
+ let value = part.slice(eq + 1);
1958
+ try { value = decodeURIComponent(value.replace(/\+/g, ' ')); } catch { /* 保底用原值 */ }
1959
+ const clean = cleanFileName(value);
1960
+ if (clean && !isPlaceholderName(clean)) return clean;
1961
+ }
1962
+ const last = path.replace(/\/+$/, '').split('/').pop() || '';
1963
+ let decoded = last;
1964
+ try { decoded = decodeURIComponent(last); } catch { /* 保底用原值 */ }
1965
+ const clean = cleanFileName(decoded);
1966
+ if (clean && !isPlaceholderName(clean) && clean.indexOf('.') >= 0) return clean;
1967
+ return '';
1968
+ }
1969
+
1970
+ /** 去掉文件名里的非法字符(保留中文等非 ASCII) */
1971
+ function cleanFileName(value) {
1972
+ return String(value == null ? '' : value)
1973
+ .replace(/[\\/:*?"<>|\r\n\t]/g, '')
1974
+ .replace(/^\.+|\.+$/g, '')
1975
+ .trim();
1976
+ }
1977
+
1978
+ function isPlaceholderName(name) {
1979
+ const text = String(name || '').trim().toLowerCase();
1980
+ return !text || PLACEHOLDER_NAMES.indexOf(text) >= 0;
1981
+ }
1982
+
1983
+ /** 文件显示名:段里的 name 优先,是占位名就从链接里还原 */
1984
+ function fileNameOf(seg) {
1985
+ const raw = hasText(seg.name) ? String(seg.name).trim() : '';
1986
+ if (raw && !isPlaceholderName(raw)) return raw;
1987
+ return fileNameFromUrl(seg.url) || '未命名文件';
1988
+ }
1989
+
1990
+ /** 文件 MIME:段里的优先,没有就按链接里的后缀猜(供预览方式判断) */
1991
+ function fileMimeOf(seg) {
1992
+ if (hasText(seg.mime)) return String(seg.mime);
1993
+ const ext = fileExt(fileNameOf(seg));
1994
+ const map = {
1995
+ txt: 'text/plain', md: 'text/markdown', log: 'text/plain', json: 'application/json',
1996
+ xml: 'application/xml', yaml: 'application/yaml', yml: 'application/yaml',
1997
+ toml: 'text/plain', ini: 'text/plain', csv: 'text/csv', py: 'text/plain',
1998
+ js: 'text/javascript', ts: 'text/plain', html: 'text/html', htm: 'text/html',
1999
+ css: 'text/css', sql: 'application/sql', vue: 'text/plain',
2000
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
2001
+ webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', ico: 'image/x-icon',
2002
+ pdf: 'application/pdf', mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac',
2003
+ m4a: 'audio/mp4', ogg: 'audio/ogg', mp4: 'video/mp4', mov: 'video/quicktime',
2004
+ mkv: 'video/x-matroska', webm: 'video/webm', avi: 'video/x-msvideo'
2005
+ };
2006
+ return map[ext] || '';
2007
+ }
2008
+
2009
+ /** 按扩展名给文件卡片一个可读的类别名(界面上显示为图标文字) */
2010
+ function fileKind(name, mime) {
2011
+ const ext = fileExt(name);
2012
+ const byExt = {
2013
+ png: '图片', jpg: '图片', jpeg: '图片', gif: '图片', webp: '图片', bmp: '图片',
2014
+ svg: '图片', ico: '图片',
2015
+ pdf: 'PDF',
2016
+ txt: '文本', md: '文本', log: '文本', json: '文本', xml: '文本', yaml: '文本',
2017
+ yml: '文本', toml: '文本', ini: '文本', csv: '文本', py: '文本', js: '文本',
2018
+ ts: '文本', java: '文本', go: '文本', rs: '文本', c: '文本', cpp: '文本',
2019
+ h: '文本', sh: '文本', html: '文本', css: '文本', sql: '文本', vue: '文本',
2020
+ mp3: '音频', wav: '音频', flac: '音频', m4a: '音频', ogg: '音频', amr: '音频',
2021
+ mp4: '视频', mov: '视频', mkv: '视频', webm: '视频', avi: '视频',
2022
+ zip: '压缩包', rar: '压缩包', '7z': '压缩包', tar: '压缩包', gz: '压缩包',
2023
+ doc: '文档', docx: '文档', xls: '表格', xlsx: '表格', ppt: '演示', pptx: '演示',
2024
+ exe: '程序', apk: '安装包', dmg: '安装包'
2025
+ };
2026
+ if (byExt[ext]) return byExt[ext];
2027
+ const type = String(mime || '').toLowerCase();
2028
+ if (type.startsWith('image/')) return '图片';
2029
+ if (type.startsWith('audio/')) return '音频';
2030
+ if (type.startsWith('video/')) return '视频';
2031
+ if (type === 'application/pdf') return 'PDF';
2032
+ return '文件';
2033
+ }
2034
+
2035
+ /** 判断能否在线预览,以及用哪种方式预览 */
2036
+ function previewKind(name, mime) {
2037
+ const ext = fileExt(name);
2038
+ const type = String(mime || '').toLowerCase();
2039
+ if (type === 'application/pdf' || ext === 'pdf') return 'pdf';
2040
+ if (type.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].indexOf(ext) >= 0) return 'image';
2041
+ if (type.startsWith('audio/') || ['mp3', 'wav', 'flac', 'm4a', 'ogg', 'amr'].indexOf(ext) >= 0) return 'audio';
2042
+ if (type.startsWith('video/') || ['mp4', 'mov', 'mkv', 'webm', 'avi'].indexOf(ext) >= 0) return 'video';
2043
+ if (type.startsWith('text/') || type === 'application/json' || type === 'application/xml') return 'text';
2044
+ if (ext && TEXT_EXTS.indexOf(ext) >= 0) return 'text';
2045
+ return '';
2046
+ }
2047
+
2048
+ const TEXT_EXTS = [
2049
+ 'txt', 'md', 'markdown', 'log', 'json', 'jsonl', 'xml', 'yaml', 'yml', 'toml',
2050
+ 'ini', 'cfg', 'conf', 'csv', 'tsv', 'env', 'py', 'js', 'mjs', 'cjs', 'ts',
2051
+ 'tsx', 'jsx', 'java', 'kt', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'rb',
2052
+ 'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'lua', 'sql', 'html',
2053
+ 'htm', 'css', 'scss', 'less', 'vue', 'svelte', 'patch', 'diff', 'srt', 'vtt'
2054
+ ];
2055
+
2056
+ /** 文件卡片:类别图标 + 文件名 + 新窗口预览 / 下载 */
2057
+ function buildFileCard(seg) {
2058
+ const name = fileNameOf(seg);
2059
+ const source = safeUrl(seg.url);
2060
+ const card = h('span', 'chip seg-file');
2061
+
2062
+ card.appendChild(h('span', 'file-badge', fileKind(name)));
2063
+ const main = h('span', 'file-main');
2064
+ main.appendChild(h('span', 'file-name', name));
2065
+ const size = Number(seg.size);
2066
+ if (Number.isFinite(size) && size > 0) {
2067
+ main.appendChild(h('span', 'file-size', formatBytes(size)));
2068
+ }
2069
+ card.appendChild(main);
2070
+
2071
+ if (!source) {
2072
+ card.appendChild(h('span', 'file-hint', '无直链'));
2073
+ return card;
2074
+ }
2075
+
2076
+ const canPreview = previewKind(name, fileMimeOf(seg)) && (!state.meta || state.meta.file_preview !== false);
2077
+ if (canPreview) {
2078
+ const btn = h('button', 'file-btn', '预览');
2079
+ btn.type = 'button';
2080
+ btn.addEventListener('click', (ev) => {
2081
+ ev.stopPropagation();
2082
+ openFilePreview(seg);
2083
+ });
2084
+ card.appendChild(btn);
2085
+ card.classList.add('clickable');
2086
+ }
2087
+ const link = externalLink(fileUrl(seg.url, name, true), '下载');
2088
+ link.className = 'file-btn';
2089
+ card.appendChild(link);
2090
+ return card;
2091
+ }
2092
+
2093
+ function formatBytes(value) {
2094
+ const n = Number(value);
2095
+ if (!Number.isFinite(n) || n <= 0) return '';
2096
+ const units = ['B', 'KB', 'MB', 'GB'];
2097
+ let size = n;
2098
+ let i = 0;
2099
+ while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1; }
2100
+ return (i === 0 ? String(size) : size.toFixed(1)) + ' ' + units[i];
2101
+ }
2102
+
2103
+ /**
2104
+ * 合并转发:可折叠的内嵌聊天记录。
2105
+ *
2106
+ * 默认收起,点标题栏原地展开成一条条小聊天记录(而不是弹窗、也不是把内容
2107
+ * 拼进当前气泡里)。节点已经内联在段里就直接渲染;只有 id 的话在**首次展开**
2108
+ * 时才去后端拉取,收起再展开不会重复请求。
2109
+ */
2110
+ function buildForwardCard(seg) {
2111
+ const card = h('div', 'fwd-card');
2112
+ const id = hasText(seg.id) ? String(seg.id) : '';
2113
+ const inline = Array.isArray(seg.nodes) ? seg.nodes : [];
2114
+
2115
+ const head = h('button', 'fwd-head');
2116
+ head.type = 'button';
2117
+ head.setAttribute('aria-expanded', 'false');
2118
+ const title = h('span', 'fwd-title', '合并转发的聊天记录');
2119
+ const count = h('span', 'fwd-count', forwardCountLabel(seg, inline));
2120
+ head.appendChild(title);
2121
+ head.appendChild(count);
2122
+ head.appendChild(h('span', 'fwd-caret'));
2123
+
2124
+ const body = h('div', 'fwd-body');
2125
+ body.hidden = true;
2126
+ card.appendChild(head);
2127
+ card.appendChild(body);
2128
+
2129
+ let loaded = false;
2130
+ let loading = false;
2131
+
2132
+ const ensureLoaded = () => {
2133
+ if (loaded || loading) return;
2134
+ if (inline.length) { loaded = true; renderForwardNodes(body, inline); return; }
2135
+ if (!id) {
2136
+ loaded = true;
2137
+ body.appendChild(h('div', 'fwd-hint', '这条合并转发没有可用的 ID,无法展开'));
2138
+ return;
2139
+ }
2140
+ loading = true;
2141
+ body.textContent = ''; // 清掉上次失败留下的提示,避免反复展开时重叠
2142
+ const hint = h('div', 'fwd-hint', '正在展开…');
2143
+ body.appendChild(hint);
2144
+ (async () => {
2145
+ try {
2146
+ const query = 'id=' + encodeURIComponent(id)
2147
+ + (state.current && state.current.key ? '&chat=' + encodeURIComponent(state.current.key) : '');
2148
+ const data = await api('/forward?' + query);
2149
+ // 卡片可能已被移除(切换会话/重绘),丢弃过期结果即可;
2150
+ // 收起不会中断请求,结果照样写进隐藏的 body,下次展开是瞬时的
2151
+ if (!card.isConnected) return;
2152
+ hint.remove();
2153
+ const list = data && Array.isArray(data.nodes) ? data.nodes : [];
2154
+ if (!list.length) { loaded = true; body.appendChild(h('div', 'fwd-hint', '这条合并转发是空的')); return; }
2155
+ loaded = true;
2156
+ count.textContent = list.length + ' 条消息';
2157
+ renderForwardNodes(body, list);
2158
+ } catch (err) {
2159
+ if (!card.isConnected) return;
2160
+ hint.remove();
2161
+ const message = (err && err.message) ? err.message : '请求失败';
2162
+ body.appendChild(h('div', 'fwd-hint', message.indexOf('无法展开') === 0 ? message : '无法展开:' + message));
2163
+ } finally {
2164
+ loading = false;
2165
+ }
2166
+ })();
2167
+ };
2168
+
2169
+ head.addEventListener('click', () => {
2170
+ const open = body.hidden;
2171
+ card.classList.toggle('open', open);
2172
+ head.setAttribute('aria-expanded', open ? 'true' : 'false');
2173
+ body.hidden = !open;
2174
+ if (open) ensureLoaded();
2175
+ });
2176
+
2177
+ return card;
2178
+ }
2179
+
2180
+ /** 合并转发标题栏右边的条数:优先用内联节点数,其次用段里记录的 count */
2181
+ function forwardCountLabel(seg, inline) {
2182
+ const n = inline.length || Number(seg.count);
2183
+ return Number.isFinite(n) && n > 0 ? n + ' 条消息' : '聊天记录';
2184
+ }
2185
+
2186
+ /** 渲染合并转发里的每条节点(沿用普通消息的气泡,图片/文件展开后仍可点开) */
2187
+ function renderForwardNodes(box, nodes) {
2188
+ nodes.forEach((node) => {
2189
+ const row = h('div', 'fwd-node');
2190
+ const meta = h('div', 'fwd-node-meta');
2191
+ const name = hasText(node.name) ? String(node.name)
2192
+ : (hasText(node.user_id) ? String(node.user_id) : '未知');
2193
+ meta.appendChild(h('span', 'fwd-node-name', name));
2194
+ const t = parseTime(node.time);
2195
+ if (t !== null) {
2196
+ const time = h('span', 'fwd-node-time', fullTime(t));
2197
+ time.title = fullTime(t);
2198
+ meta.appendChild(time);
2199
+ }
2200
+ row.appendChild(meta);
2201
+ const segs = Array.isArray(node.segments) ? node.segments : [];
2202
+ row.appendChild(buildBubble(segs.length ? segs : [{ type: 'text', text: '[空消息]' }]));
2203
+ box.appendChild(row);
2204
+ });
2205
+ }
2206
+
2207
+ function externalLink(href, text) {
2208
+ const a = h('a', 'chip-link', text);
2209
+ a.href = href;
2210
+ a.target = '_blank';
2211
+ a.rel = 'noopener noreferrer';
2212
+ return a;
2213
+ }
2214
+
2215
+ function buildImage(seg) {
2216
+ const wrap = h('span', 'seg-block');
2217
+ const source = safeUrl(seg.url);
2218
+ const url = proxied(seg.url);
2219
+ if (!url) {
2220
+ wrap.appendChild(h('span', 'chip seg-unknown', '[图片] ' + (hasText(seg.file) ? String(seg.file) : '无法加载')));
2221
+ return wrap;
2222
+ }
2223
+ const img = document.createElement('img');
2224
+ img.className = 'seg-image';
2225
+ img.loading = 'lazy';
2226
+ img.decoding = 'async';
2227
+ img.referrerPolicy = 'no-referrer';
2228
+ img.alt = hasText(seg.name) ? String(seg.name) : (hasText(seg.file) ? String(seg.file) : '图片');
2229
+ img.src = url;
2230
+ img.addEventListener('error', () => {
2231
+ if (img.parentNode) {
2232
+ img.parentNode.replaceChild(h('span', 'img-fallback', '图片加载失败'), img);
2233
+ }
2234
+ });
2235
+ img.addEventListener('click', (ev) => {
2236
+ ev.stopPropagation();
2237
+ openLightbox(source);
2238
+ });
2239
+ wrap.appendChild(img);
2240
+ return wrap;
2241
+ }
2242
+
2243
+ /** 会话切换时的骨架占位 */
2244
+ function renderMessageSkeleton() {
2245
+ const box = el.msgList;
2246
+ box.textContent = '';
2247
+ for (let i = 0; i < 5; i++) {
2248
+ const row = h('div', 'msg ' + (i % 3 === 2 ? 'out' : 'in'));
2249
+ row.appendChild(h('div', 'avatar sm sk-box'));
2250
+ const body = h('div', 'msg-body');
2251
+ const bubble = h('div', 'bubble');
2252
+ const line = h('div', 'sk-line');
2253
+ line.style.width = (110 + (i * 47) % 150) + 'px';
2254
+ line.style.height = '12px';
2255
+ line.style.margin = '4px 0';
2256
+ bubble.appendChild(line);
2257
+ body.appendChild(bubble);
2258
+ row.appendChild(body);
2259
+ box.appendChild(row);
2260
+ }
2261
+ }
2262
+
2263
+ /**
2264
+ * 重绘消息区。
2265
+ * reset=true 时整体重建(需要重算分隔符);keepAnchor=true 时保持视觉位置(加载更早消息)。
2266
+ */
2267
+ function renderMessages(reset, keepAnchor) {
2268
+ const box = el.msgList;
2269
+ const prevHeight = el.msgBox.scrollHeight;
2270
+ const prevTop = el.msgBox.scrollTop;
2271
+ const atBottom = isNearBottom();
2272
+
2273
+ if (reset) {
2274
+ box.textContent = '';
2275
+ state.sep = { day: '', time: 0 };
2276
+ state.lastRendered = null;
2277
+ }
2278
+
2279
+ const frag = document.createDocumentFragment();
2280
+ if (state.hasMore) {
2281
+ const more = h('div', 'load-more');
2282
+ const btn = h('button', null, state.loadingMore ? '加载中…' : '加载更早的消息');
2283
+ btn.type = 'button';
2284
+ btn.id = 'loadMoreBtn';
2285
+ btn.disabled = !!state.loadingMore;
2286
+ more.appendChild(btn);
2287
+ frag.appendChild(more);
2288
+ }
2289
+ if (!state.messages.length) {
2290
+ frag.appendChild(emptyState('暂无消息', '这个会话还没有任何消息记录'));
2291
+ } else {
2292
+ state.messages.forEach((msg) => {
2293
+ appendSeparators(frag, msg.time);
2294
+ frag.appendChild(buildMessage(msg));
2295
+ });
2296
+ }
2297
+ box.appendChild(frag);
2298
+
2299
+ if (state.needsScroll) {
2300
+ state.needsScroll = false;
2301
+ scrollToBottom(false);
2302
+ } else if (keepAnchor || !atBottom) {
2303
+ // 不在底部时一定要补回滚动位置:重绘会把容器清空,滚动位置被顶到 0,
2304
+ // 不补回来就会「明明在看历史,来了一条新消息画面突然跳到别处」。
2305
+ const delta = el.msgBox.scrollHeight - prevHeight;
2306
+ el.msgBox.scrollTop = Math.max(0, prevTop + delta);
2307
+ } else {
2308
+ scrollToBottom(false);
2309
+ }
2310
+ updateNewMsgButton();
2311
+ }
2312
+
2313
+ /** 追加单条消息(实时事件 / 发送成功) */
2314
+ function appendMessage(msg) {
2315
+ if (!state.current || String(msg.chat) !== state.current.key) return false;
2316
+ const id = String(msg.id);
2317
+ if (state.msgMap.has(id)) return false;
2318
+
2319
+ const atBottom = isNearBottom();
2320
+ state.messages.push(msg);
2321
+ state.msgMap.set(id, msg);
2322
+
2323
+ if (state.messages.length > MSG_MAX) {
2324
+ const dropped = state.messages.shift();
2325
+ if (dropped) state.msgMap.delete(String(dropped.id));
2326
+ // 这里必须整段重绘(顶部少了一条,分隔符也要重算),
2327
+ // 而 renderMessages 内部已经处理了「不在底部就保持位置」。
2328
+ renderMessages(true);
2329
+ return true;
2330
+ }
2331
+
2332
+ const empty = el.msgList.querySelector('.empty');
2333
+ if (empty) empty.remove();
2334
+
2335
+ const frag = document.createDocumentFragment();
2336
+ appendSeparators(frag, msg.time);
2337
+ frag.appendChild(buildMessage(msg));
2338
+ el.msgList.appendChild(frag);
2339
+ // 只在本来就贴着底部时跟随;否则纹丝不动,让用户安心看历史
2340
+ if (atBottom) scrollToBottom(true);
2341
+ updateNewMsgButton();
2342
+ return true;
2343
+ }
2344
+
2345
+ /* ============================ 7. 打开会话 / 历史 / 发送 ============================ */
2346
+
2347
+ async function openChat(key) {
2348
+ const chat = state.chatMap.get(String(key));
2349
+ if (!chat) return;
2350
+
2351
+ state.current = chat;
2352
+ saveChat(chat.key); // 记住这个会话,下次打开自动回到它
2353
+ state.unread.set(chat.key, 0);
2354
+ state.pending.delete(chat.key);
2355
+ state.messages = [];
2356
+ state.msgMap.clear();
2357
+ state.hasMore = true;
2358
+ state.needsScroll = true;
2359
+ state.newCount = 0;
2360
+ state.replyTo = null;
2361
+ state.pendingAt = [];
2362
+ // 待发送的附件属于「上一个会话」:切换会话时清空本地列表(服务端那份留着,
2363
+ // 会由定时清理回收),避免误发到别的会话
2364
+ state.pendingFiles = [];
2365
+ state.members = [];
2366
+ state.memberMap.clear();
2367
+ closeAtMenu();
2368
+
2369
+ el.app.dataset.pane = 'chat';
2370
+ renderChats();
2371
+ renderHead();
2372
+ renderReplyBar();
2373
+ renderPendingAt();
2374
+ renderPendingFiles();
2375
+ renderMessageSkeleton();
2376
+ updateNewMsgButton();
2377
+
2378
+ try {
2379
+ // 用局部变量记住本次请求的条数,避免请求途中 pageSize 变化导致比较错位
2380
+ const limit = state.pageSize;
2381
+ const data = await api('/messages?chat=' + encodeURIComponent(chat.key) + '&limit=' + limit);
2382
+ const list = data && Array.isArray(data.messages) ? data.messages : [];
2383
+ if (!state.current || state.current.key !== chat.key) return; // 期间切换了会话
2384
+ state.messages = list.slice();
2385
+ state.messages.forEach((m) => state.msgMap.set(String(m.id), m));
2386
+ state.hasMore = list.length >= limit;
2387
+ renderMessages(true);
2388
+ markRead();
2389
+ // 打开会话算一次“刚同步过”:期间即使有事件漏掉,也不会马上触发重同步
2390
+ state.resyncLast = Date.now();
2391
+ // 从搜索结果进来时,等这一页渲染完再定位到那条消息
2392
+ if (state.focusMsgId != null) {
2393
+ const target = state.focusMsgId;
2394
+ state.focusMsgId = null;
2395
+ jumpToMessage(target);
2396
+ }
2397
+ } catch (err) {
2398
+ // 定位目标是一次性的:加载失败就丢掉,否则会在之后某次刷新时莫名其妙跳走
2399
+ state.focusMsgId = null;
2400
+ if (!state.current || state.current.key !== chat.key) return;
2401
+ if (err && err.status === 401) return; // 401 已由 api() 弹出令牌页
2402
+ el.msgList.textContent = '';
2403
+ el.msgList.appendChild(emptyState('消息加载失败', err && err.message ? err.message : '未知错误'));
2404
+ statusErr(err && err.message ? err.message : '消息加载失败');
2405
+ }
2406
+ }
2407
+
2408
+ /** 向上滚动时加载更早的历史 */
2409
+ async function loadOlder() {
2410
+ const chat = state.current;
2411
+ if (!chat || state.loadingMore || !state.hasMore) return;
2412
+ const oldest = state.messages.length ? parseTime(state.messages[0].time) : null;
2413
+ if (oldest === null) { state.hasMore = false; renderMessages(true); return; }
2414
+ // 同时带上最旧一条的行号:同一时刻可能有多条消息,只按时间戳翻页会漏掉它们
2415
+ const oldestId = state.messages.length ? String(state.messages[0].id) : '';
2416
+
2417
+ state.loadingMore = true;
2418
+ renderMessages(true, true);
2419
+ try {
2420
+ const limit = state.pageSize;
2421
+ let url = '/messages?chat=' + encodeURIComponent(chat.key) +
2422
+ '&limit=' + limit + '&before=' + encodeURIComponent(String(oldest));
2423
+ if (oldestId) url += '&before_id=' + encodeURIComponent(oldestId);
2424
+ const data = await api(url);
2425
+ const list = data && Array.isArray(data.messages) ? data.messages : [];
2426
+ if (!state.current || state.current.key !== chat.key) return;
2427
+
2428
+ const known = new Set(state.messages.map((m) => String(m.id)));
2429
+ const fresh = list.filter((m) => !known.has(String(m.id)));
2430
+ if (fresh.length) {
2431
+ state.messages = fresh.concat(state.messages);
2432
+ fresh.forEach((m) => state.msgMap.set(String(m.id), m));
2433
+ if (state.messages.length > MSG_MAX) {
2434
+ state.messages.slice(MSG_MAX).forEach((m) => state.msgMap.delete(String(m.id)));
2435
+ state.messages = state.messages.slice(0, MSG_MAX);
2436
+ }
2437
+ }
2438
+ // 返回不足一页 / 有重复 / 已达上限 → 说明没有更早的消息了
2439
+ if (!list.length || list.length < limit || fresh.length < list.length) state.hasMore = false;
2440
+ if (state.messages.length >= MSG_MAX) state.hasMore = false;
2441
+ const newOldest = state.messages.length ? parseTime(state.messages[0].time) : null;
2442
+ if (newOldest === null || newOldest >= oldest) state.hasMore = false;
2443
+ } catch (err) {
2444
+ if (!err || err.status !== 401) toast(err && err.message ? err.message : '加载更早的消息失败', true);
2445
+ } finally {
2446
+ state.loadingMore = false;
2447
+ if (state.current && state.current.key === chat.key) renderMessages(true, true);
2448
+ }
2449
+ }
2450
+
2451
+ async function reloadCurrent() {
2452
+ const chat = state.current;
2453
+ if (!chat) return;
2454
+ setStatus('poll', '正在刷新…');
2455
+ state.current = null;
2456
+ await openChat(chat.key);
2457
+ if (state.started) statusOk();
2458
+ }
2459
+
2460
+ async function sendMessage() {
2461
+ const chat = state.current;
2462
+ if (!chat || state.sending) return;
2463
+ if (state.meta && state.meta.write_enabled === false) { toast('只读模式,无法发送消息', true); return; }
2464
+ if (state.uploading) { toast('还有附件正在上传,稍等一下', true); return; }
2465
+
2466
+ const text = el.input.value.replace(/\s+$/, '');
2467
+ const at = state.pendingAt.slice();
2468
+ const files = state.pendingFiles.slice();
2469
+ if (!text && !at.length && !files.length) return;
2470
+
2471
+ const reply = state.replyTo;
2472
+ const body = { chat: chat.key, text: text };
2473
+ if (at.length) body.at = at;
2474
+ if (files.length) body.uploads = files.map((f) => f.id);
2475
+ if (reply && hasText(reply.message_id)) body.reply_to = String(reply.message_id);
2476
+
2477
+ state.sending = true;
2478
+ el.sendBtn.disabled = true;
2479
+ el.sendBtn.textContent = '发送中';
2480
+ setStatus('poll', '正在发送…');
2481
+ try {
2482
+ const data = await api('/send', { method: 'POST', body: body });
2483
+ const msg = data && data.message ? data.message : null;
2484
+ el.input.value = '';
2485
+ autoGrow();
2486
+ state.replyTo = null;
2487
+ state.pendingAt = [];
2488
+ state.pendingFiles = [];
2489
+ renderReplyBar();
2490
+ renderPendingAt();
2491
+ renderPendingFiles();
2492
+
2493
+ if (msg && hasText(msg.chat) && String(msg.chat) === chat.key) {
2494
+ appendMessage(msg);
2495
+ updateChatList(msg, true);
2496
+ } else {
2497
+ await openChat(chat.key); // 未回传消息体:重新拉取
2498
+ updateChatList({ chat: chat.key, direction: 'out', text: text, time: Date.now() / 1000 }, true);
2499
+ }
2500
+ } catch (err) {
2501
+ if (!err || err.status !== 401) toast(err && err.message ? err.message : '发送失败', true);
2502
+ } finally {
2503
+ state.sending = false;
2504
+ el.sendBtn.disabled = !!(state.meta && state.meta.write_enabled === false);
2505
+ el.sendBtn.textContent = '发送';
2506
+ if (state.started && state.online) statusOk();
2507
+ el.input.focus();
2508
+ }
2509
+ }
2510
+
2511
+ /** 收到 / 发出消息后更新会话列表(置顶、摘要、未读) */
2512
+ function updateChatList(msg, isOpen) {
2513
+ if (!msg || !hasText(msg.chat)) return;
2514
+ const key = String(msg.chat);
2515
+ const chat = state.chatMap.get(key);
2516
+ if (!chat) { refreshChatsSoon(); return; }
2517
+
2518
+ const preview = previewOf(msg);
2519
+ if (hasText(preview)) chat.last_text = preview;
2520
+ chat.last_direction = msg.direction === 'out' ? 'out' : 'in';
2521
+ const t = parseTime(msg.time);
2522
+ if (t !== null) chat.last_at = t;
2523
+ chat.message_count = (Number(chat.message_count) || 0) + 1;
2524
+
2525
+ if (!isOpen) {
2526
+ state.unread.set(key, (state.unread.get(key) || 0) + 1);
2527
+ state.pending.set(key, { text: shorten(preview, 30) });
2528
+ }
2529
+ sortChats();
2530
+ renderChats();
2531
+ }
2532
+
2533
+ let refreshTimer = null;
2534
+ function refreshChatsSoon() {
2535
+ if (refreshTimer) return;
2536
+ refreshTimer = setTimeout(() => {
2537
+ refreshTimer = null;
2538
+ loadChats().catch(() => { /* 静默失败,等下一次重同步 */ });
2539
+ }, 400);
2540
+ }
2541
+
2542
+ /* ============================ 8. 回复 / @ / 输入框 ============================ */
2543
+
2544
+ function renderReplyBar() {
2545
+ const msg = state.replyTo;
2546
+ if (!msg) {
2547
+ el.replyBar.hidden = true;
2548
+ el.replyBarLabel.textContent = '';
2549
+ return;
2550
+ }
2551
+ const who = hasText(msg.user_name) ? String(msg.user_name) : (hasText(msg.user_id) ? String(msg.user_id) : '');
2552
+ const id = hasText(msg.message_id) ? msg.message_id : msg.id;
2553
+ const preview = hasText(msg.text) ? shorten(msg.text, 24) : previewOf(msg);
2554
+ el.replyBarLabel.textContent = '回复 ' + id + (who ? ' · ' + who : '') + ':' + preview;
2555
+ el.replyBar.hidden = false;
2556
+ }
2557
+
2558
+ function setReply(msg) {
2559
+ state.replyTo = msg || null;
2560
+ renderReplyBar();
2561
+ if (msg) el.input.focus();
2562
+ }
2563
+
2564
+ function renderPendingAt() {
2565
+ const box = el.pendingAt;
2566
+ box.textContent = '';
2567
+ if (!state.pendingAt.length) { box.hidden = true; return; }
2568
+ box.hidden = false;
2569
+ state.pendingAt.forEach((target) => {
2570
+ const chip = h('span', 'at-chip');
2571
+ chip.appendChild(h('span', null, '@' + atName(target)));
2572
+ const remove = h('button', null, '✕');
2573
+ remove.type = 'button';
2574
+ remove.title = '移除该 @';
2575
+ remove.dataset.removeAt = target;
2576
+ chip.appendChild(remove);
2577
+ box.appendChild(chip);
2578
+ });
2579
+ }
2580
+
2581
+ function atName(id) {
2582
+ const value = String(id || '');
2583
+ if (value === 'all') return '全体成员';
2584
+ const known = state.memberMap.get(value);
2585
+ if (known && hasText(known.name)) return String(known.name);
2586
+ const chat = state.current;
2587
+ // 私聊里 @ 的就是对方本人,直接用会话名
2588
+ if (chat && chat.kind === 'private' && String(chat.id) === value && hasText(chat.name)) return String(chat.name);
2589
+ return value;
2590
+ }
2591
+
2592
+ function addAt(id, name) {
2593
+ const value = String(id || '').trim();
2594
+ if (!value) return;
2595
+ if (name) state.memberMap.set(value, { id: value, name: String(name) });
2596
+ if (state.pendingAt.indexOf(value) < 0) state.pendingAt.push(value);
2597
+ renderPendingAt();
2598
+ }
2599
+
2600
+ /* ---------- 待发送附件 ----------
2601
+ 选中的图片/文件先上传到服务端的**系统临时目录**(返回 id),发送时只带 id
2602
+ 列表。这么拆是为了能在发送前显示缩略图 / 文件名,也避免把文件字节塞进发送
2603
+ 请求体;发送那一刻服务端才按内容 md5 正式入库。
2604
+ 移除待发送项时顺带把临时文件删掉,不占磁盘。 */
2605
+
2606
+ function canUpload() {
2607
+ const meta = state.meta || {};
2608
+ return meta.write_enabled !== false && meta.upload_enabled !== false;
2609
+ }
2610
+
2611
+ function renderPendingFiles() {
2612
+ const box = el.pendingFiles;
2613
+ box.textContent = '';
2614
+ if (!state.pendingFiles.length) { box.hidden = true; return; }
2615
+ box.hidden = false;
2616
+ state.pendingFiles.forEach((item) => {
2617
+ const chip = h('span', 'file-chip' + (item.kind === 'image' ? ' image' : ''));
2618
+ if (item.kind === 'image' && item.url) {
2619
+ const img = document.createElement('img');
2620
+ img.className = 'file-chip-thumb';
2621
+ img.alt = item.name;
2622
+ img.src = item.url;
2623
+ chip.appendChild(img);
2624
+ } else {
2625
+ chip.appendChild(h('span', 'file-chip-badge', fileKind(item.name, item.mime)));
2626
+ }
2627
+ chip.appendChild(h('span', 'file-chip-name', item.name));
2628
+ const size = formatBytes(Number(item.size));
2629
+ if (size) chip.appendChild(h('span', 'file-chip-size', size));
2630
+
2631
+ const remove = h('button', null, '✕');
2632
+ remove.type = 'button';
2633
+ remove.title = '移除该附件';
2634
+ remove.addEventListener('click', () => removePendingFile(item.id));
2635
+ chip.appendChild(remove);
2636
+ box.appendChild(chip);
2637
+ });
2638
+ }
2639
+
2640
+ function addPendingFile(item) {
2641
+ if (!item || !hasText(item.id)) return;
2642
+ if (state.pendingFiles.some((f) => f.id === item.id)) return;
2643
+ // 与服务端、与消息段里用的是同一个取回地址,随手动带上令牌
2644
+ const base = ROOT_BASE + '/api/media/' + encodeURIComponent(String(item.id));
2645
+ item.url = ownUrl(base);
2646
+ state.pendingFiles.push(item);
2647
+ renderPendingFiles();
2648
+ }
2649
+
2650
+ function removePendingFile(id) {
2651
+ const keep = state.pendingFiles.filter((f) => f.id !== id);
2652
+ if (keep.length === state.pendingFiles.length) return;
2653
+ state.pendingFiles = keep;
2654
+ renderPendingFiles();
2655
+ // 服务端那份临时文件也删掉;删不掉(已过期)不影响界面,静默即可
2656
+ api('/media/' + encodeURIComponent(String(id)), { method: 'DELETE' }).catch(() => {});
2657
+ }
2658
+
2659
+ /** 上传一个本地文件(File / Blob),成功后加入待发送列表 */
2660
+ async function uploadFile(file) {
2661
+ if (!file) return;
2662
+ const meta = state.meta || {};
2663
+ const limit = Number(meta.upload_max_bytes);
2664
+ if (Number.isFinite(limit) && limit > 0 && file.size > limit) {
2665
+ toast('「' + (file.name || '文件') + '」超过大小上限(' + formatBytes(limit) + ')', true);
2666
+ return;
2667
+ }
2668
+ const name = hasText(file.name) ? String(file.name) : ('file-' + Date.now());
2669
+ const type = hasText(file.type) ? String(file.type) : '';
2670
+ const query = '?name=' + encodeURIComponent(name) + (type ? '&type=' + encodeURIComponent(type) : '');
2671
+ state.uploading += 1;
2672
+ updateSendButtonHint();
2673
+ setStatus('poll', '正在上传附件…');
2674
+ try {
2675
+ const raw = await file.arrayBuffer();
2676
+ const data = await apiBytes('/upload' + query, 'POST', raw);
2677
+ if (data && data.file) {
2678
+ addPendingFile(data.file);
2679
+ toast('已添加附件:' + (data.file.name || name));
2680
+ }
2681
+ } catch (err) {
2682
+ if (!err || err.status !== 401) toast(err && err.message ? err.message : '上传失败', true);
2683
+ } finally {
2684
+ state.uploading = Math.max(0, state.uploading - 1);
2685
+ updateSendButtonHint();
2686
+ if (!state.uploading && state.started && state.online) statusOk();
2687
+ }
2688
+ }
2689
+
2690
+ /** 批量上传本地文件/图片并加入待发送列表 */
2691
+ async function uploadFiles(list) {
2692
+ if (!canUpload()) {
2693
+ toast(state.meta && state.meta.write_enabled === false ? '只读模式,无法发送附件' : '未开启附件发送', true);
2694
+ return;
2695
+ }
2696
+ const items = Array.from(list || []);
2697
+ if (!items.length) return;
2698
+ for (const file of items) {
2699
+ // 串行上传:并发太多在弱网下更容易失败,而且顺序对小图也更友好
2700
+ await uploadFile(file);
2701
+ }
2702
+ }
2703
+
2704
+ /** 上传过程中把发送按钮的提示文字改一下,避免用户以为卡住了 */
2705
+ function updateSendButtonHint() {
2706
+ if (state.sending) return;
2707
+ el.sendBtn.textContent = state.uploading ? '上传中' : '发送';
2708
+ }
2709
+
2710
+ /* ---------- @ 成员选择菜单 ----------
2711
+ 以前这里弹一个输入框让用户敲 QQ 号,既容易敲错也没人记得住号码。
2712
+ 现在改成从「记录到过的成员」里点选:名册由后端从历史消息里攒出来,
2713
+ 本地再按输入的关键词过滤。名册为空时如实说明,不退回让人手填号码。 */
2714
+ function renderAtMenu() {
2715
+ const menu = el.atMenu;
2716
+ menu.textContent = '';
2717
+
2718
+ const query = String(state.atQuery || '').trim().toLowerCase();
2719
+ let items = state.members;
2720
+ if (query) {
2721
+ items = items.filter((m) =>
2722
+ String(m.name || '').toLowerCase().indexOf(query) >= 0 ||
2723
+ String(m.id || '').toLowerCase().indexOf(query) >= 0);
2724
+ }
2725
+ // 「全体成员」只在群聊里给,且要匹配关键词
2726
+ const chat = state.current;
2727
+ const canAll = !!(chat && chat.kind === 'group');
2728
+ if (canAll && (!query || '全体成员'.indexOf(query) >= 0 || 'all'.indexOf(query) >= 0)) {
2729
+ menu.appendChild(buildAtItem({ id: 'all', name: '全体成员', role: null }, true));
2730
+ }
2731
+ items.forEach((member) => menu.appendChild(buildAtItem(member, false)));
2732
+
2733
+ if (!menu.childElementCount) {
2734
+ const tip = state.members.length
2735
+ ? '没有匹配的成员'
2736
+ : '还没有记录到成员。等群里有人发言、或有人被 @ 之后,这里就会列出来。';
2737
+ menu.appendChild(h('div', 'at-menu-empty', tip));
2738
+ }
2739
+ positionAtMenu();
2740
+ }
2741
+
2742
+ function buildAtItem(member, isAll) {
2743
+ const id = String(member.id || '');
2744
+ const name = hasText(member.name) ? String(member.name) : id;
2745
+ const btn = h('button', 'at-item');
2746
+ btn.type = 'button';
2747
+ btn.setAttribute('role', 'menuitem');
2748
+
2749
+ btn.appendChild(buildAvatar(isAll ? '@' : name, member.avatar, 'sm'));
2750
+ const main = h('div', 'at-item-main');
2751
+ main.appendChild(h('div', 'at-item-name', name));
2752
+ if (!isAll && hasText(id) && id !== name) main.appendChild(h('div', 'at-item-id', 'QQ ' + id));
2753
+ btn.appendChild(main);
2754
+ if (member.role === 'owner') btn.appendChild(h('span', 'role-tag owner', '群主'));
2755
+ else if (member.role === 'admin') btn.appendChild(h('span', 'role-tag admin', '管理员'));
2756
+
2757
+ btn.addEventListener('click', () => {
2758
+ addAt(id, name);
2759
+ closeAtMenu();
2760
+ el.input.focus();
2761
+ });
2762
+ // 右键成员:给他起备注(适配器拿不到真实昵称时很有用)
2763
+ if (!isAll) {
2764
+ btn.addEventListener('contextmenu', (ev) => {
2765
+ ev.preventDefault();
2766
+ openAliasDialog({
2767
+ kind: 'member',
2768
+ chat: state.current ? state.current.key : '',
2769
+ user_id: id,
2770
+ alias: member.alias || ''
2771
+ });
2772
+ });
2773
+ }
2774
+ return btn;
2775
+ }
2776
+
2777
+ function positionAtMenu() {
2778
+ const menu = el.atMenu;
2779
+ const anchor = el.atBtn.getBoundingClientRect();
2780
+ menu.hidden = false;
2781
+ const rect = menu.getBoundingClientRect();
2782
+ const left = Math.max(6, Math.min(anchor.left, window.innerWidth - rect.width - 6));
2783
+ // 默认贴着按钮上沿往上弹(输入区在页面底部),放不下就往下摆
2784
+ let top = anchor.top - rect.height - 6;
2785
+ if (top < 6) top = Math.min(anchor.bottom + 6, window.innerHeight - rect.height - 6);
2786
+ menu.style.left = left + 'px';
2787
+ menu.style.top = Math.max(6, top) + 'px';
2788
+ }
2789
+
2790
+ async function loadMembers() {
2791
+ const chat = state.current;
2792
+ if (!chat) return;
2793
+ const key = chat.key;
2794
+ try {
2795
+ const data = await api('/members?chat=' + encodeURIComponent(key) + '&limit=500');
2796
+ if (!state.current || state.current.key !== key) return;
2797
+ state.members = data && Array.isArray(data.members) ? data.members : [];
2798
+ } catch (err) {
2799
+ if (err && err.status === 401) return;
2800
+ state.members = [];
2801
+ }
2802
+ state.memberMap.clear();
2803
+ state.members.forEach((m) => {
2804
+ if (m && hasText(m.id)) state.memberMap.set(String(m.id), m);
2805
+ });
2806
+ // 名册可能补上了昵称,刷新一下已选 @ 的显示
2807
+ renderPendingAt();
2808
+ if (!el.atMenu.hidden) renderAtMenu();
2809
+ }
2810
+
2811
+ async function openAtMenu() {
2812
+ const chat = state.current;
2813
+ if (!chat) { toast('请先选择一个会话'); return; }
2814
+ if (state.meta && state.meta.write_enabled === false) { toast('只读模式,无法提及成员', true); return; }
2815
+ state.atQuery = '';
2816
+ el.atMenu.textContent = '';
2817
+ el.atMenu.hidden = false;
2818
+ await loadMembers();
2819
+ if (el.atMenu.hidden) return; // 加载期间被关掉了
2820
+ renderAtMenu();
2821
+ el.atMenu.focus();
2822
+ }
2823
+
2824
+ function closeAtMenu() {
2825
+ if (el.atMenu.hidden) return;
2826
+ el.atMenu.hidden = true;
2827
+ el.atMenu.textContent = '';
2828
+ state.atQuery = '';
2829
+ }
2830
+
2831
+ function toggleAtMenu() {
2832
+ if (el.atMenu.hidden) openAtMenu();
2833
+ else closeAtMenu();
2834
+ }
2835
+
2836
+ function atMenuKeydown(ev) {
2837
+ if (ev.key === 'Escape') { ev.preventDefault(); closeAtMenu(); el.input.focus(); return; }
2838
+ if (ev.key === 'Enter') {
2839
+ const first = el.atMenu.querySelector('.at-item');
2840
+ if (first) { ev.preventDefault(); first.click(); }
2841
+ return;
2842
+ }
2843
+ if (ev.key === 'Backspace') {
2844
+ // 退格删掉搜索词的最后一个字符;删空后不关闭菜单
2845
+ ev.preventDefault();
2846
+ state.atQuery = String(state.atQuery || '').slice(0, -1);
2847
+ renderAtMenu();
2848
+ return;
2849
+ }
2850
+ if (ev.key && ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
2851
+ ev.preventDefault();
2852
+ state.atQuery = String(state.atQuery || '') + ev.key;
2853
+ renderAtMenu();
2854
+ }
2855
+ }
2856
+
2857
+ /** 输入框随内容增高,最多约 5 行 */
2858
+ function autoGrow() {
2859
+ const box = el.input;
2860
+ box.style.height = 'auto';
2861
+ box.style.height = Math.min(box.scrollHeight, 132) + 'px';
2862
+ box.style.overflowY = box.scrollHeight > 132 ? 'auto' : 'hidden';
2863
+ }
2864
+
2865
+ /* ============================ 9. 灯箱与右键菜单 ============================ */
2866
+
2867
+ function openLightbox(url) {
2868
+ const link = proxied(url) || safeUrl(url);
2869
+ if (!link) return;
2870
+ el.lightboxImg.src = link;
2871
+ el.lightbox.hidden = false;
2872
+ }
2873
+
2874
+ function closeLightbox() {
2875
+ if (el.lightbox.hidden) return;
2876
+ el.lightbox.hidden = true;
2877
+ el.lightboxImg.removeAttribute('src');
2878
+ }
2879
+
2880
+ /* ---------- 文件在线预览弹窗 ----------
2881
+ 文本类直接展示内容,图片 / PDF / 音视频用内嵌元素。所有资源都走
2882
+ /api/file 代理,既能绕过防盗链,也能通过令牌。 */
2883
+
2884
+ function openFileViewer(title, meta) {
2885
+ el.fileViewerTitle.textContent = title || '文件预览';
2886
+ el.fileViewerMeta.textContent = meta || '';
2887
+ el.fileViewerBody.textContent = '';
2888
+ el.fileViewer.hidden = false;
2889
+ }
2890
+
2891
+ function closeFileViewer() {
2892
+ if (el.fileViewer.hidden) return;
2893
+ el.fileViewer.hidden = true;
2894
+ el.fileViewerBody.textContent = '';
2895
+ el.fileViewerSave.removeAttribute('href');
2896
+ }
2897
+
2898
+ /** 读取自己上传的文本附件内容(浏览器会按响应头/嗅探猜编码) */
2899
+ async function fetchOwnText(url) {
2900
+ const res = await fetch(url, { cache: 'no-store', credentials: 'same-origin' });
2901
+ if (!res.ok) throw new Error('读取失败(HTTP ' + res.status + ')');
2902
+ return await res.text();
2903
+ }
2904
+
2905
+ async function openFilePreview(seg) {
2906
+ const name = fileNameOf(seg);
2907
+ const kind = previewKind(name, fileMimeOf(seg));
2908
+ if (!kind) { toast('该文件类型不支持预览', true); return; }
2909
+ if (state.meta && state.meta.file_preview === false) {
2910
+ toast('未开启文件在线预览', true);
2911
+ return;
2912
+ }
2913
+
2914
+ const save = fileUrl(seg.url, name, true);
2915
+ el.fileViewerSave.href = save;
2916
+ el.fileViewerSave.setAttribute('download', name);
2917
+
2918
+ if (kind === 'text') {
2919
+ const seq = ++state.fileSeq;
2920
+ openFileViewer(name, '正在读取内容…');
2921
+ const box = h('pre', 'file-text', '');
2922
+ el.fileViewerBody.appendChild(box);
2923
+ try {
2924
+ // 自己上传的附件直接读原始内容(/api/preview 只认远程 http 链接),
2925
+ // 其余仍交给后端的文本预览接口(它会只取前 512 KB 并给出编码判断)
2926
+ if (isOwnUrl(seg.url)) {
2927
+ const text = await fetchOwnText(ownUrl(seg.url));
2928
+ if (seq !== state.fileSeq || el.fileViewer.hidden) return;
2929
+ box.textContent = text;
2930
+ el.fileViewerMeta.textContent = fileMimeOf(seg) || '';
2931
+ return;
2932
+ }
2933
+ const query = 'name=' + encodeURIComponent(name)
2934
+ + '&u=' + encodeURIComponent(safeUrl(seg.url) || String(seg.url || ''))
2935
+ + (state.current && state.current.key ? '&chat=' + encodeURIComponent(state.current.key) : '');
2936
+ const data = await api('/preview?' + query);
2937
+ if (seq !== state.fileSeq || el.fileViewer.hidden) return;
2938
+ box.textContent = data && typeof data.text === 'string' ? data.text : '';
2939
+ const parts = [];
2940
+ if (hasText(data && data.content_type)) parts.push(String(data.content_type));
2941
+ if (Number.isFinite(Number(data && data.bytes)) && Number(data.bytes) > 0) {
2942
+ parts.push(formatBytes(Number(data.bytes)));
2943
+ }
2944
+ if (data && data.truncated) parts.push('仅显示前 512 KB');
2945
+ el.fileViewerMeta.textContent = parts.join(' · ');
2946
+ } catch (err) {
2947
+ if (seq !== state.fileSeq) return;
2948
+ box.textContent = '';
2949
+ el.fileViewerBody.textContent = '';
2950
+ el.fileViewerBody.appendChild(emptyState('无法预览', (err && err.message) ? err.message : '读取失败'));
2951
+ el.fileViewerMeta.textContent = '';
2952
+ }
2953
+ return;
2954
+ }
2955
+
2956
+ const url = fileUrl(seg.url, name, false);
2957
+ const size = formatBytes(Number(seg.size));
2958
+ openFileViewer(name, [fileKind(name, fileMimeOf(seg)), size].filter(hasText).join(' · '));
2959
+
2960
+ if (kind === 'image') {
2961
+ const img = document.createElement('img');
2962
+ img.className = 'file-image';
2963
+ img.alt = name;
2964
+ img.src = url;
2965
+ img.addEventListener('error', () => {
2966
+ el.fileViewerBody.textContent = '';
2967
+ el.fileViewerBody.appendChild(emptyState('图片加载失败', '可尝试直接下载'));
2968
+ });
2969
+ el.fileViewerBody.appendChild(img);
2970
+ return;
2971
+ }
2972
+ if (kind === 'pdf') {
2973
+ const frame = document.createElement('iframe');
2974
+ frame.className = 'file-frame';
2975
+ frame.src = url;
2976
+ frame.title = name;
2977
+ el.fileViewerBody.appendChild(frame);
2978
+ return;
2979
+ }
2980
+ const tag = kind === 'video' ? 'video' : 'audio';
2981
+ const media = document.createElement(tag);
2982
+ media.className = 'file-media';
2983
+ media.controls = true;
2984
+ media.src = url;
2985
+ if (tag === 'video') media.playsInline = true;
2986
+ el.fileViewerBody.appendChild(media);
2987
+ }
2988
+
2989
+ function openCtxMenu(x, y, msg) {
2990
+ state.ctxMsg = msg;
2991
+ const menu = el.ctxMenu;
2992
+ menu.textContent = '';
2993
+
2994
+ const capability = state.meta && state.meta.capabilities ? state.meta.capabilities.recall !== false : true;
2995
+ const recallable = msg.recallable === true && capability;
2996
+ const fileSeg = firstFileSeg(msg);
2997
+ const previewable = fileSeg
2998
+ && previewKind(fileNameOf(fileSeg), fileMimeOf(fileSeg))
2999
+ && (!state.meta || state.meta.file_preview !== false);
3000
+
3001
+ const items = [
3002
+ { label: '复制文本', run: () => copyText(hasText(msg.text) ? msg.text : previewOf(msg)) },
3003
+ { label: '回复', run: () => setReply(msg) },
3004
+ { label: '预览文件', run: () => openFilePreview(fileSeg), disabled: !previewable },
3005
+ {
3006
+ label: '备注发言人',
3007
+ run: () => openAliasDialog({
3008
+ kind: 'member',
3009
+ chat: msg.chat,
3010
+ user_id: msg.user_id,
3011
+ alias: (state.memberMap.get(String(msg.user_id)) || {}).alias || ''
3012
+ }),
3013
+ disabled: msg.direction === 'out' || !hasText(msg.user_id)
3014
+ },
3015
+ { label: '撤回', run: () => recallMessage(msg), disabled: !recallable, danger: true },
3016
+ { sep: true },
3017
+ { label: '导出聊天记录', run: () => exportChat() },
3018
+ { label: '复制消息ID', run: () => copyText(hasText(msg.message_id) ? msg.message_id : msg.id) }
3019
+ ];
3020
+
3021
+ items.forEach((item) => {
3022
+ if (item.sep) { menu.appendChild(h('div', 'ctx-sep')); return; }
3023
+ const btn = h('button', 'ctx-item' + (item.danger ? ' danger' : ''), item.label);
3024
+ btn.type = 'button';
3025
+ btn.setAttribute('role', 'menuitem');
3026
+ if (item.disabled) btn.disabled = true;
3027
+ btn.addEventListener('click', () => {
3028
+ closeCtxMenu();
3029
+ item.run();
3030
+ });
3031
+ menu.appendChild(btn);
3032
+ });
3033
+
3034
+ positionMenu(menu, x, y);
3035
+ }
3036
+
3037
+ /** 会话列表的右键菜单(目前只有「设置备注」),与消息右键菜单共用定位逻辑 */
3038
+ function openChatMenu(x, y, chat) {
3039
+ const menu = el.ctxMenu;
3040
+ menu.textContent = '';
3041
+ state.ctxMsg = null;
3042
+ const items = [
3043
+ {
3044
+ label: '设置备注',
3045
+ run: () => openAliasDialog({
3046
+ kind: 'chat', chat: chat.key, alias: chat.alias || ''
3047
+ }),
3048
+ disabled: !!(state.meta && state.meta.write_enabled === false)
3049
+ }
3050
+ ];
3051
+ items.forEach((item) => {
3052
+ const btn = h('button', 'ctx-item', item.label);
3053
+ btn.type = 'button';
3054
+ btn.setAttribute('role', 'menuitem');
3055
+ if (item.disabled) btn.disabled = true;
3056
+ btn.addEventListener('click', () => { closeCtxMenu(); item.run(); });
3057
+ menu.appendChild(btn);
3058
+ });
3059
+ positionMenu(menu, x, y);
3060
+ }
3061
+
3062
+ /** 把右键菜单摆到鼠标位置,并确保不超出视口 */
3063
+ function positionMenu(menu, x, y) {
3064
+ menu.hidden = false;
3065
+ const rect = menu.getBoundingClientRect();
3066
+ menu.style.left = Math.max(6, Math.min(x, window.innerWidth - rect.width - 6)) + 'px';
3067
+ menu.style.top = Math.max(6, Math.min(y, window.innerHeight - rect.height - 6)) + 'px';
3068
+ }
3069
+
3070
+ function closeCtxMenu() {
3071
+ if (el.ctxMenu.hidden) return;
3072
+ el.ctxMenu.hidden = true;
3073
+ el.ctxMenu.textContent = '';
3074
+ state.ctxMsg = null;
3075
+ }
3076
+
3077
+ async function recallMessage(msg) {
3078
+ if (!msg) return;
3079
+ const chat = hasText(msg.chat) ? String(msg.chat) : (state.current ? state.current.key : '');
3080
+ if (!chat || !hasText(msg.id)) return;
3081
+ try {
3082
+ await api('/recall', { method: 'POST', body: { chat: chat, id: msg.id } });
3083
+ removeMessage(msg.id);
3084
+ toast('已撤回');
3085
+ } catch (err) {
3086
+ if (!err || err.status !== 401) toast(err && err.message ? err.message : '撤回失败', true);
3087
+ }
3088
+ }
3089
+
3090
+ function removeMessage(id) {
3091
+ const key = String(id);
3092
+ const index = state.messages.findIndex((m) => String(m.id) === key);
3093
+ if (index < 0 && !state.msgMap.has(key)) return;
3094
+
3095
+ if (index >= 0) state.messages.splice(index, 1);
3096
+ state.msgMap.delete(key);
3097
+ if (state.replyTo && String(state.replyTo.id) === key) setReply(null);
3098
+
3099
+ // 删掉的是首条消息时会顺带带走日期分隔符,因此整体重绘
3100
+ if (index === 0) { renderMessages(true); return; }
3101
+ const node = el.msgList.querySelector('.msg[data-id="' + cssEscape(key) + '"]');
3102
+ if (node) node.remove();
3103
+ if (!state.messages.length) renderMessages(true);
3104
+ }
3105
+
3106
+ function cssEscape(value) {
3107
+ if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value));
3108
+ return String(value).replace(/["\\]/g, '\\$&');
3109
+ }
3110
+
3111
+ /** 取出消息里第一个带可用链接的文件段(用于右键菜单的“预览文件”) */
3112
+ function firstFileSeg(msg) {
3113
+ if (!msg || !Array.isArray(msg.segments)) return null;
3114
+ for (let i = 0; i < msg.segments.length; i++) {
3115
+ const seg = msg.segments[i];
3116
+ if (!seg) continue;
3117
+ if ((seg.type === 'file' || seg.type === 'video' || seg.type === 'audio' || seg.type === 'voice') && hasText(seg.url)) {
3118
+ return seg;
3119
+ }
3120
+ }
3121
+ return null;
3122
+ }
3123
+
3124
+ /** 导出当前会话的聊天记录(后端直接返回 JSON 附件) */
3125
+ function exportChat() {
3126
+ const chat = state.current;
3127
+ if (!chat) { toast('请先选择一个会话', true); return; }
3128
+ const url = API_BASE + '/export?chat=' + encodeURIComponent(chat.key)
3129
+ + '&limit=20000' + authQuery();
3130
+ const link = document.createElement('a');
3131
+ link.href = url;
3132
+ link.rel = 'noopener';
3133
+ document.body.appendChild(link);
3134
+ link.click();
3135
+ document.body.removeChild(link);
3136
+ toast('正在导出聊天记录');
3137
+ }
3138
+
3139
+ /* ============================ 10. 滚动与已读 ============================ */
3140
+
3141
+ function isNearBottom() {
3142
+ const box = el.msgBox;
3143
+ return box.scrollHeight - box.scrollTop - box.clientHeight < NEAR_BOTTOM;
3144
+ }
3145
+
3146
+ function scrollToBottom(smooth) {
3147
+ const box = el.msgBox;
3148
+ if (smooth) {
3149
+ box.scrollTo({ top: box.scrollHeight, behavior: 'smooth' });
3150
+ } else {
3151
+ const saved = box.style.scrollBehavior;
3152
+ box.style.scrollBehavior = 'auto';
3153
+ box.scrollTop = box.scrollHeight;
3154
+ box.style.scrollBehavior = saved || '';
3155
+ }
3156
+ state.newCount = 0;
3157
+ updateNewMsgButton();
3158
+ }
3159
+
3160
+ function updateNewMsgButton() {
3161
+ const btn = el.newMsgBtn;
3162
+ if (!state.newCount) { btn.hidden = true; return; }
3163
+ btn.hidden = false;
3164
+ btn.textContent = state.newCount + ' 条新消息';
3165
+ }
3166
+
3167
+ function markRead() {
3168
+ if (!state.current) return;
3169
+ const key = state.current.key;
3170
+ // 没有变化就不重绘,避免滚动时反复重建列表
3171
+ if (!(state.unread.get(key) || 0) && !state.pending.has(key)) return;
3172
+ state.unread.set(key, 0);
3173
+ state.pending.delete(key);
3174
+ renderChats();
3175
+ }
3176
+
3177
+ /* ============================ 11. 实时事件(WebSocket) ============================ */
3178
+
3179
+ function wsUrl(since) {
3180
+ const url = new URL(API_BASE + '/ws');
3181
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
3182
+ url.searchParams.set('since', String(since || 0));
3183
+ url.searchParams.set('token', state.token || '');
3184
+ return url.href;
3185
+ }
3186
+
3187
+ function startRealtime() {
3188
+ closeSocket();
3189
+ state.since = 0;
3190
+ state.online = false;
3191
+ state.wsFails = 0;
3192
+ state.wsDelay = BACKOFF_MIN;
3193
+ state.resyncLast = 0;
3194
+ connectSocket();
3195
+ }
3196
+
3197
+ function closeSocket() {
3198
+ if (state.wsTimer) {
3199
+ clearTimeout(state.wsTimer);
3200
+ state.wsTimer = null;
3201
+ }
3202
+ state.wsGen += 1; // 让旧连接的回调全部失效
3203
+ const ws = state.ws;
3204
+ state.ws = null;
3205
+ if (!ws) return;
3206
+ ws.onopen = null;
3207
+ ws.onmessage = null;
3208
+ ws.onerror = null;
3209
+ ws.onclose = null;
3210
+ try { ws.close(); } catch (err) { /* 忽略 */ }
3211
+ }
3212
+
3213
+ function scheduleReconnect() {
3214
+ if (state.gateOpen || !state.started || state.wsTimer) return;
3215
+ state.wsTimer = setTimeout(() => {
3216
+ state.wsTimer = null;
3217
+ connectSocket();
3218
+ }, state.wsDelay);
3219
+ // 退避阶梯:1s → 2s → 4s → 8s → 16s → 30s,连上后重置
3220
+ state.wsDelay = Math.min(BACKOFF_MAX, BACKOFF_MIN * Math.pow(2, state.wsFails));
3221
+ }
3222
+
3223
+ function connectSocket() {
3224
+ if (state.gateOpen || !state.started) return;
3225
+ closeSocket();
3226
+ const gen = state.wsGen;
3227
+
3228
+ let ws;
3229
+ try {
3230
+ ws = new WebSocket(wsUrl(state.since));
3231
+ } catch (err) {
3232
+ state.wsFails += 1;
3233
+ statusErr('无法建立实时连接,正在重试…');
3234
+ scheduleReconnect();
3235
+ return;
3236
+ }
3237
+ state.ws = ws;
3238
+
3239
+ ws.onopen = () => {
3240
+ if (gen !== state.wsGen) return;
3241
+ const reconnect = state.wsFails > 0;
3242
+ state.wsFails = 0;
3243
+ state.wsDelay = BACKOFF_MIN;
3244
+ state.resyncLast = Date.now();
3245
+ state.online = true;
3246
+ statusOk();
3247
+ // 重连回来时对齐一次列表与会话:断开期间漏掉的事件由服务端补发,
3248
+ // 但会话摘要这类「派生状态」还是以接口为准更划算。
3249
+ if (reconnect) resync(state.since, true);
3250
+ };
3251
+
3252
+ ws.onmessage = (ev) => {
3253
+ if (gen !== state.wsGen) return;
3254
+ let payload = null;
3255
+ try { payload = JSON.parse(ev.data); } catch (err) { return; }
3256
+ if (!payload || typeof payload !== 'object') return;
3257
+ state.resyncLast = Date.now();
3258
+ handleEvent(payload);
3259
+ };
3260
+
3261
+ ws.onerror = () => {
3262
+ if (gen !== state.wsGen) return;
3263
+ // 具体原因交给 onclose(那里能拿到 code),这里只保证状态提示不是「已连接」
3264
+ if (state.online) statusErr('实时连接异常');
3265
+ };
3266
+
3267
+ ws.onclose = (ev) => {
3268
+ if (gen !== state.wsGen) return;
3269
+ state.ws = null;
3270
+ state.online = false;
3271
+
3272
+ const code = ev && ev.code;
3273
+ if (code === 4401) { requireToken(); return; } // 令牌失效:api() 的 401 入口
3274
+ if (code === 4403) {
3275
+ setStatus('err', '该来源被拒绝访问(仅允许本机,见 BOTUI_ALLOW_REMOTE)');
3276
+ return; // 重连也没用,等用户处理
3277
+ }
3278
+ if (state.gateOpen || !state.started) return;
3279
+
3280
+ state.wsFails += 1;
3281
+ statusErr('实时连接已断开,正在重连…');
3282
+ scheduleReconnect();
3283
+ };
3284
+ }
3285
+
3286
+ /** 长连接长时间静默(sleep / 代理假死)后主动重同步一次 */
3287
+ function checkStale() {
3288
+ if (!state.online || !state.resyncLast) return;
3289
+ if (Date.now() - state.resyncLast < RESYNC_STALE * 1000) return;
3290
+ resync(state.since, true);
3291
+ }
3292
+
3293
+ /**
3294
+ * 重新同步:把会话列表与当前会话的消息重新拉一遍。
3295
+ * 事件只是「增量提示」,页面状态始终以这两个接口为准,所以无论因为什么原因
3296
+ * 怀疑自己落后了(序号空洞、长时间静默、重连回来),都可以靠它兜底。
3297
+ */
3298
+ async function resync(since, silent) {
3299
+ if (state.syncing) return;
3300
+ state.syncing = true;
3301
+ state.resyncLast = Date.now();
3302
+ try {
3303
+ await loadBots();
3304
+ await loadChats();
3305
+ if (state.current) {
3306
+ const key = state.current.key;
3307
+ const limit = state.pageSize;
3308
+ const data = await api('/messages?chat=' + encodeURIComponent(key) + '&limit=' + limit);
3309
+ if (state.current && state.current.key === key) {
3310
+ const list = data && Array.isArray(data.messages) ? data.messages : [];
3311
+ applyMessageList(list, limit);
3312
+ }
3313
+ }
3314
+ state.since = Math.max(state.since, Number(since) || 0);
3315
+ } catch (err) {
3316
+ // 重同步失败不是致命问题:连接断开时下一次重连还会再试一次
3317
+ if (!silent && err && err.status !== 401) statusErr(err.message || '同步失败');
3318
+ } finally {
3319
+ state.syncing = false;
3320
+ }
3321
+ }
3322
+
3323
+ /** 用接口返回的整页消息替换当前列表(保留滚动位置) */
3324
+ function applyMessageList(list, limit) {
3325
+ const atBottom = isNearBottom();
3326
+ const prevHeight = el.msgBox.scrollHeight;
3327
+ const prevTop = el.msgBox.scrollTop;
3328
+
3329
+ state.messages = list.slice();
3330
+ state.msgMap.clear();
3331
+ state.messages.forEach((m) => state.msgMap.set(String(m.id), m));
3332
+ state.hasMore = list.length >= limit;
3333
+ state.newCount = 0;
3334
+ renderMessages(true);
3335
+
3336
+ if (!atBottom) el.msgBox.scrollTop = Math.max(0, prevTop + (el.msgBox.scrollHeight - prevHeight));
3337
+ updateNewMsgButton();
3338
+ markRead();
3339
+ }
3340
+
3341
+ /** 处理单条事件,未知类型直接忽略 */
3342
+ function handleEvent(ev) {
3343
+ if (!ev || typeof ev !== 'object') return;
3344
+
3345
+ if (ev.type === 'ping' || ev.type === 'ready') {
3346
+ if (Number.isFinite(Number(ev.cursor))) state.since = Math.max(state.since, Number(ev.cursor));
3347
+ return;
3348
+ }
3349
+
3350
+ // 序号出现空洞说明中间的事件丢了(服务端队列满、或者曾短暂断开),
3351
+ // 这时不做花哨的补洞,直接整体重同步一次最省心。
3352
+ const seq = Number(ev.seq);
3353
+ if (Number.isFinite(seq) && seq > 0) {
3354
+ if (state.since && seq > state.since + RESYNC_GAP) {
3355
+ resync(state.since, true);
3356
+ return;
3357
+ }
3358
+ if (seq > state.since) state.since = seq;
3359
+ }
3360
+
3361
+ // 机器人上线/离线:更新切换器里的在线标记
3362
+ if (ev.type === 'bot' && ev.bot && hasText(ev.bot.self_id)) {
3363
+ applyBotEvent(ev.bot);
3364
+ return;
3365
+ }
3366
+
3367
+ // 会话资料变化(比如别处设置了备注):合并进列表并刷新头部
3368
+ if (ev.type === 'chat' && ev.chat && hasText(ev.chat.key)) {
3369
+ if (botOfKey(String(ev.chat.key)) !== state.botId) return;
3370
+ mergeChat(ev.chat);
3371
+ renderChats();
3372
+ return;
3373
+ }
3374
+
3375
+ if (ev.type === 'message' && ev.message) {
3376
+ const msg = ev.message;
3377
+ const key = String(msg.chat);
3378
+ // 会话 key 里内嵌了机器人 ID,只处理当前选中机器人的消息
3379
+ if (botOfKey(key) !== state.botId) return;
3380
+ const isOpen = !!(state.current && state.current.key === key);
3381
+
3382
+ if (isOpen) {
3383
+ const atBottom = isNearBottom();
3384
+ // 不在底部时不打扰阅读,只浮出“N 条新消息”
3385
+ if (appendMessage(msg) && !atBottom) {
3386
+ state.newCount += 1;
3387
+ updateNewMsgButton();
3388
+ }
3389
+ }
3390
+ // 网页通知:自己发的、正在看的都不提醒(见 shouldNotify)
3391
+ notifyMessage(msg, isOpen);
3392
+ if (ev.chat && typeof ev.chat === 'object' && hasText(ev.chat.key)) mergeChat(ev.chat);
3393
+ updateChatList(msg, isOpen);
3394
+ return;
3395
+ }
3396
+
3397
+ if (ev.type === 'recall' && ev.id !== undefined && ev.id !== null) {
3398
+ // 撤回事件只带行号;不属于当前机器人时直接忽略(removeMessage 找不到也不会出错)
3399
+ removeMessage(ev.id);
3400
+ }
3401
+ }
3402
+
3403
+ /** 从会话 key(``12345:group_678``)里取出机器人 ID */
3404
+ function botOfKey(key) {
3405
+ const text = String(key || '');
3406
+ const idx = text.indexOf(':');
3407
+ return idx > 0 ? text.slice(0, idx) : '';
3408
+ }
3409
+
3410
+ /** 更新单个机器人的在线状态(WebSocket 推来的 bot 事件) */
3411
+ function applyBotEvent(bot) {
3412
+ const selfId = String(bot.self_id);
3413
+ const existing = state.bots.find((b) => b.self_id === selfId);
3414
+ if (existing) {
3415
+ existing.online = !!bot.online;
3416
+ if (hasText(bot.name)) existing.name = String(bot.name);
3417
+ if (bot.avatar !== undefined && bot.avatar !== null) existing.avatar = bot.avatar;
3418
+ if (hasText(bot.adapter)) existing.adapter = String(bot.adapter);
3419
+ } else {
3420
+ state.bots.unshift(Object.assign({}, bot, { self_id: selfId }));
3421
+ // 之前一个机器人都没有时,新上线的机器人自动成为当前选中项
3422
+ if (!state.botId) {
3423
+ saveBot(selfId);
3424
+ renderBotSwitch();
3425
+ loadChats().catch(() => { /* 静默失败 */ });
3426
+ return;
3427
+ }
3428
+ }
3429
+ renderBotSwitch();
3430
+ renderConnMeta();
3431
+ }
3432
+
3433
+ /** 合并事件携带的会话信息(新会话自动补入列表) */
3434
+ function mergeChat(chat) {
3435
+ const key = String(chat.key);
3436
+ const existing = state.chatMap.get(key);
3437
+ if (existing) {
3438
+ if (hasText(chat.name)) existing.name = chat.name;
3439
+ if (chat.alias !== undefined) existing.alias = chat.alias;
3440
+ if (chat.raw_name !== undefined) existing.raw_name = chat.raw_name;
3441
+ if (chat.avatar !== undefined) existing.avatar = chat.avatar;
3442
+ if (hasText(chat.kind)) existing.kind = chat.kind;
3443
+ if (hasText(chat.id)) existing.id = chat.id;
3444
+ const count = Number(chat.member_count);
3445
+ if (Number.isFinite(count) && count > 0) existing.member_count = count;
3446
+ if (state.current && state.current.key === key) renderHead();
3447
+ return;
3448
+ }
3449
+ const item = Object.assign({}, chat);
3450
+ item.key = key;
3451
+ if (!hasText(item.name)) item.name = hasText(item.id) ? String(item.id) : '未知会话';
3452
+ state.chatMap.set(key, item);
3453
+ state.chats.push(item);
3454
+ sortChats();
3455
+ renderChats();
3456
+ }
3457
+
3458
+ /* ============================ 12. 启动与事件绑定 ============================ */
3459
+
3460
+ async function enterApp() {
3461
+ state.started = true;
3462
+ state.online = false;
3463
+ // 先对齐机器人列表:/chats 要按选中的机器人过滤,所以顺序不能反
3464
+ await loadBots();
3465
+ await loadChats();
3466
+ renderConnMeta();
3467
+ startRealtime();
3468
+ // 自动回到上次查看的会话(在实时连接建立后打开,不阻塞连接)
3469
+ restoreLastChat();
3470
+ }
3471
+
3472
+ async function bootstrap() {
3473
+ initTheme();
3474
+ initNotify();
3475
+ captureToken();
3476
+ renderConnMeta();
3477
+
3478
+ try {
3479
+ const ready = await loadMeta();
3480
+ if (!ready) { renderChats(); renderHead(); return; }
3481
+ await enterApp();
3482
+ } catch (err) {
3483
+ if (err && err.status === 401) { renderChats(); renderHead(); return; }
3484
+ statusErr(err && err.message ? err.message : '初始化失败');
3485
+ renderChatError(err);
3486
+ // 初始化失败也保持连接重试,网络恢复后自动接上
3487
+ state.started = true;
3488
+ startRealtime();
3489
+ }
3490
+ }
3491
+
3492
+ /** 保存令牌后重新进入应用 */
3493
+ async function submitToken() {
3494
+ hideGate();
3495
+ closeSocket();
3496
+ state.started = false;
3497
+ state.since = 0;
3498
+ try {
3499
+ const ready = await loadMeta();
3500
+ if (!ready) return;
3501
+ await enterApp();
3502
+ if (state.current) openChat(state.current.key);
3503
+ toast('令牌已保存');
3504
+ } catch (err) {
3505
+ if (err && err.status === 401) return; // api() 已重新弹出令牌页
3506
+ statusErr(err && err.message ? err.message : '令牌校验失败');
3507
+ toast(err && err.message ? err.message : '令牌校验失败', true);
3508
+ }
3509
+ }
3510
+
3511
+ function bindEvents() {
3512
+ el.themeBtn.addEventListener('click', toggleTheme);
3513
+
3514
+ // 搜索:会话名本地即时过滤;「消息」标签下再请求服务端做正文检索
3515
+ let searchTimer = null;
3516
+ el.searchInput.addEventListener('input', () => {
3517
+ state.search = el.searchInput.value || '';
3518
+ if (searchTimer) clearTimeout(searchTimer);
3519
+ searchTimer = setTimeout(() => {
3520
+ renderChats();
3521
+ if (state.searchMode === 'msgs' && state.search.trim()) runSearch();
3522
+ else { state.searchHits = []; }
3523
+ }, 120);
3524
+ });
3525
+
3526
+ el.searchClear.addEventListener('click', () => {
3527
+ el.searchInput.value = '';
3528
+ state.search = '';
3529
+ state.searchHits = [];
3530
+ state.searchSeq += 1; // 让在途请求的结果失效
3531
+ el.searchInput.focus();
3532
+ renderChats();
3533
+ });
3534
+ el.tabChats.addEventListener('click', () => setSearchMode('chats'));
3535
+ el.tabMsgs.addEventListener('click', () => setSearchMode('msgs'));
3536
+ el.msgResults.addEventListener('click', (ev) => {
3537
+ const item = closestFrom(ev.target, '.hit-item');
3538
+ if (item) openSearchHit(item);
3539
+ });
3540
+
3541
+ // 会话列表:事件委托
3542
+ el.chatList.addEventListener('click', (ev) => {
3543
+ const item = closestFrom(ev.target, '.chat-item');
3544
+ if (item && item.dataset.key) openChat(item.dataset.key);
3545
+ });
3546
+
3547
+ // 会话列表右键:给会话设置备注(适配器拿不到群名时很有用)
3548
+ el.chatList.addEventListener('contextmenu', (ev) => {
3549
+ const item = closestFrom(ev.target, '.chat-item');
3550
+ if (!item || !item.dataset.key) return;
3551
+ const chat = state.chatMap.get(item.dataset.key);
3552
+ if (!chat) return;
3553
+ ev.preventDefault();
3554
+ openChatMenu(ev.clientX, ev.clientY, chat);
3555
+ });
3556
+
3557
+ // 窄屏返回列表
3558
+ el.backBtn.addEventListener('click', () => { el.app.dataset.pane = 'sidebar'; });
3559
+
3560
+ el.reloadBtn.addEventListener('click', () => { reloadCurrent(); });
3561
+
3562
+ // 会话备注:头部铅笔按钮
3563
+ el.headRenameBtn.addEventListener('click', () => {
3564
+ if (!state.current) return;
3565
+ openAliasDialog({ kind: 'chat', chat: state.current.key, alias: state.current.alias || '' });
3566
+ });
3567
+
3568
+ // 备注编辑框:保存 / 清除 / 回车 / Esc / 点遮罩关闭
3569
+ el.aliasSave.addEventListener('click', () => saveAlias(el.aliasInput.value.trim()));
3570
+ el.aliasClear.addEventListener('click', () => saveAlias(''));
3571
+ el.aliasInput.addEventListener('keydown', (ev) => {
3572
+ if (ev.key === 'Enter') { ev.preventDefault(); saveAlias(el.aliasInput.value.trim()); }
3573
+ else if (ev.key === 'Escape') { ev.preventDefault(); closeAliasDialog(); }
3574
+ });
3575
+ el.aliasDialog.addEventListener('click', (ev) => {
3576
+ if (ev.target === el.aliasDialog) closeAliasDialog();
3577
+ });
3578
+
3579
+ // 设置面板:右上角齿轮开合,面板内的缓存按钮各管一个清理模式
3580
+ el.settingsBtn.addEventListener('click', toggleSettings);
3581
+ el.settingsClose.addEventListener('click', closeSettings);
3582
+ el.settings.addEventListener('click', (ev) => {
3583
+ // 点卡片外的遮罩关闭(和文件预览弹窗一致的手感)
3584
+ if (ev.target === el.settings) closeSettings();
3585
+ });
3586
+ el.cacheRefresh.addEventListener('click', () => { loadCache(); });
3587
+ el.cacheClearOrphans.addEventListener('click', (ev) => clearCache('orphans', '清理未引用缓存', ev.currentTarget));
3588
+ el.cacheClearImages.addEventListener('click', (ev) => clearCache('image', '清理图片缓存', ev.currentTarget));
3589
+ el.cacheClearFiles.addEventListener('click', (ev) => clearCache('file', '清理文件缓存', ev.currentTarget));
3590
+ el.cacheClearAll.addEventListener('click', (ev) => clearCache('all', '清空缓存', ev.currentTarget));
3591
+
3592
+ // 网页通知开关
3593
+ el.notifyToggle.addEventListener('change', () => setNotifyEnabled(el.notifyToggle.checked));
3594
+ el.notifySound.addEventListener('change', () => setNotifySound(el.notifySound.checked));
3595
+
3596
+ // 机器人切换器:点按钮开合菜单,点菜单项切换
3597
+ el.botBtn.addEventListener('click', (ev) => {
3598
+ ev.stopPropagation();
3599
+ toggleBotMenu();
3600
+ });
3601
+ el.botMenu.addEventListener('click', (ev) => {
3602
+ // 菜单项自己绑了 click;这里兜一层 stopPropagation,避免被 document
3603
+ // 那个「点空白就关」的监听器抢先关掉再触发
3604
+ ev.stopPropagation();
3605
+ });
3606
+
3607
+ // 消息区:加载更早 / 定位被回复消息 / 展开合并转发 / 复制卡片
3608
+ el.msgList.addEventListener('click', (ev) => {
3609
+ if (closestFrom(ev.target, '#loadMoreBtn')) { loadOlder(); return; }
3610
+
3611
+ const replyChip = closestFrom(ev.target, '[data-reply-id]');
3612
+ if (replyChip) {
3613
+ jumpToMessage(replyChip.dataset.replyId);
3614
+ return;
3615
+ }
3616
+ const copyChip = closestFrom(ev.target, '[data-copy]');
3617
+ if (copyChip) copyText(copyChip.dataset.copy);
3618
+ });
3619
+
3620
+ // 右键菜单
3621
+ el.msgList.addEventListener('contextmenu', (ev) => {
3622
+ const row = closestFrom(ev.target, '.msg');
3623
+ if (!row || !row.dataset.id) return;
3624
+ const msg = state.msgMap.get(String(row.dataset.id));
3625
+ if (!msg) return;
3626
+ ev.preventDefault();
3627
+ openCtxMenu(ev.clientX, ev.clientY, msg);
3628
+ });
3629
+
3630
+ // 滚动到底部附近即视为已读
3631
+ el.msgBox.addEventListener('scroll', () => {
3632
+ if (!isNearBottom()) return;
3633
+ if (state.newCount) { state.newCount = 0; updateNewMsgButton(); }
3634
+ markRead();
3635
+ }, { passive: true });
3636
+
3637
+ el.newMsgBtn.addEventListener('click', () => scrollToBottom(true));
3638
+
3639
+ // 输入框:Enter 发送,Shift+Enter 换行
3640
+ el.input.addEventListener('input', autoGrow);
3641
+ el.input.addEventListener('keydown', (ev) => {
3642
+ if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) {
3643
+ ev.preventDefault();
3644
+ sendMessage();
3645
+ }
3646
+ });
3647
+ el.sendBtn.addEventListener('click', sendMessage);
3648
+
3649
+ // 附件:点按钮选文件,或直接把文件拖进页面 / 粘贴
3650
+ el.fileBtn.addEventListener('click', () => {
3651
+ if (!canUpload()) { toast('未开启附件发送', true); return; }
3652
+ el.filePicker.click();
3653
+ });
3654
+ el.filePicker.addEventListener('change', () => {
3655
+ // 必须先快照成数组再清空 value:FileList 是实时对象,value='' 会把列表一起清空,
3656
+ // 直接传 el.filePicker.files 的话会让上传拿到一个空列表(表现为选了文件没任何反应)。
3657
+ const files = Array.from(el.filePicker.files || []);
3658
+ el.filePicker.value = ''; // 清空,方便连续选同一个文件
3659
+ uploadFiles(files);
3660
+ });
3661
+ el.input.addEventListener('paste', (ev) => {
3662
+ if (!canUpload()) return;
3663
+ const items = ev.clipboardData ? ev.clipboardData.items : null;
3664
+ if (!items) return;
3665
+ const picked = [];
3666
+ for (const item of items) {
3667
+ if (item.kind === 'file') {
3668
+ const file = item.getAsFile();
3669
+ if (file) picked.push(file);
3670
+ }
3671
+ }
3672
+ if (picked.length) {
3673
+ ev.preventDefault();
3674
+ uploadFiles(picked);
3675
+ }
3676
+ });
3677
+
3678
+ // 回复条 / @
3679
+ el.replyBarClose.addEventListener('click', () => setReply(null));
3680
+ el.atBtn.addEventListener('click', toggleAtMenu);
3681
+ el.atMenu.addEventListener('keydown', atMenuKeydown);
3682
+ el.pendingAt.addEventListener('click', (ev) => {
3683
+ const btn = closestFrom(ev.target, '[data-remove-at]');
3684
+ if (!btn) return;
3685
+ state.pendingAt = state.pendingAt.filter((item) => item !== btn.dataset.removeAt);
3686
+ renderPendingAt();
3687
+ });
3688
+
3689
+ // 灯箱 / 文件预览弹窗
3690
+ el.lightbox.addEventListener('click', closeLightbox);
3691
+ el.fileViewerClose.addEventListener('click', closeFileViewer);
3692
+ el.fileViewer.addEventListener('click', (ev) => {
3693
+ // 点弹窗背景(而不是内容)时关闭;内容和关闭按钮自己处理
3694
+ if (ev.target === el.fileViewer) closeFileViewer();
3695
+ });
3696
+
3697
+ // 令牌表单
3698
+ el.gateForm.addEventListener('submit', (ev) => {
3699
+ ev.preventDefault();
3700
+ const value = (el.gateInput.value || '').trim();
3701
+ if (!value) { toast('请输入访问令牌', true); return; }
3702
+ saveToken(value);
3703
+ submitToken();
3704
+ });
3705
+
3706
+ // Esc / 点击空白关闭浮层
3707
+ document.addEventListener('keydown', (ev) => {
3708
+ if (ev.key !== 'Escape') return;
3709
+ if (!el.aliasDialog.hidden) { closeAliasDialog(); return; }
3710
+ if (!el.settings.hidden) { closeSettings(); return; }
3711
+ if (!el.fileViewer.hidden) { closeFileViewer(); return; }
3712
+ if (!el.lightbox.hidden) { closeLightbox(); return; }
3713
+ if (!el.atMenu.hidden) { closeAtMenu(); return; }
3714
+ if (!el.botMenu.hidden) { closeBotMenu(); return; }
3715
+ closeCtxMenu();
3716
+ });
3717
+ document.addEventListener('click', (ev) => {
3718
+ if (!el.atMenu.hidden && !el.atMenu.contains(ev.target) && ev.target !== el.atBtn) {
3719
+ closeAtMenu();
3720
+ }
3721
+ // 点切换器按钮或其菜单之外的任何地方都收起菜单
3722
+ if (!el.botMenu.hidden && !el.botSwitch.contains(ev.target)) closeBotMenu();
3723
+ if (el.ctxMenu.hidden) return;
3724
+ if (!el.ctxMenu.contains(ev.target)) closeCtxMenu();
3725
+ });
3726
+ window.addEventListener('resize', () => { closeAtMenu(); closeCtxMenu(); closeBotMenu(); });
3727
+ window.addEventListener('blur', () => { closeCtxMenu(); closeBotMenu(); });
3728
+ // 拖拽文件到窗口任意位置:显示提示层,松手后上传。
3729
+ // dragenter/dragleave 会在子元素间反复触发,所以用一个计数器判断是否仍在窗口内。
3730
+ let dragDepth = 0;
3731
+ const hasFiles = (ev) => {
3732
+ const types = ev.dataTransfer ? ev.dataTransfer.types : null;
3733
+ return !!types && Array.prototype.indexOf.call(types, 'Files') >= 0;
3734
+ };
3735
+ document.addEventListener('dragenter', (ev) => {
3736
+ if (!hasFiles(ev)) return;
3737
+ ev.preventDefault();
3738
+ dragDepth += 1;
3739
+ if (canUpload()) el.dropOverlay.hidden = false;
3740
+ });
3741
+ document.addEventListener('dragover', (ev) => {
3742
+ if (!hasFiles(ev)) return;
3743
+ ev.preventDefault(); // 不阻止的话浏览器会直接打开文件
3744
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'copy';
3745
+ });
3746
+ document.addEventListener('dragleave', (ev) => {
3747
+ if (!hasFiles(ev)) return;
3748
+ dragDepth = Math.max(0, dragDepth - 1);
3749
+ if (!dragDepth) el.dropOverlay.hidden = true;
3750
+ });
3751
+ document.addEventListener('drop', (ev) => {
3752
+ if (!hasFiles(ev)) return;
3753
+ ev.preventDefault();
3754
+ dragDepth = 0;
3755
+ el.dropOverlay.hidden = true;
3756
+ const files = ev.dataTransfer ? ev.dataTransfer.files : null;
3757
+ uploadFiles(files);
3758
+ });
3759
+
3760
+ // 页面重新可见时补一次同步:后台标签页的定时器会被浏览器节流,
3761
+ // 长连接也可能已经被中间设备掐掉,回来先对齐再继续。
3762
+ document.addEventListener('visibilitychange', () => {
3763
+ if (document.visibilityState !== 'visible' || !state.started) return;
3764
+ if (!state.ws || state.ws.readyState === WebSocket.CLOSED) connectSocket();
3765
+ else resync(state.since, true);
3766
+ });
3767
+ }
3768
+
3769
+ // 心跳与静默检测:前者是 WebSocket 协议层保活(挡掉代理空闲断连),
3770
+ // 后者比对最近一次收到服务端消息的时间,发现假死就重新同步。
3771
+ const WS_PING = 30000;
3772
+ setInterval(() => {
3773
+ if (state.gateOpen || !state.started) return;
3774
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
3775
+ try { state.ws.send('ping'); } catch (err) { /* 交给 onclose 处理 */ }
3776
+ }
3777
+ checkStale();
3778
+ }, WS_PING);
3779
+
3780
+ /** 定位到被回复的消息并高亮 */
3781
+ function jumpToMessage(id) {
3782
+ const node = el.msgList.querySelector('.msg[data-id="' + cssEscape(id) + '"]');
3783
+ if (!node) {
3784
+ // 目标不在已加载的这一页(搜索结果可能很旧),把当前会话翻到最新再提示
3785
+ if (String(id) !== '') toast('这条消息不在已加载的记录里,可点「重新加载」或继续向上翻');
3786
+ return;
3787
+ }
3788
+ node.scrollIntoView({ block: 'center', behavior: 'smooth' });
3789
+ const bubble = node.querySelector('.bubble');
3790
+ if (!bubble) return;
3791
+ const saved = bubble.style.boxShadow;
3792
+ bubble.style.transition = 'box-shadow 0.2s ease';
3793
+ bubble.style.boxShadow = '0 0 0 2px var(--accent)';
3794
+ setTimeout(() => { bubble.style.boxShadow = saved; }, 1200);
3795
+ }
3796
+
3797
+ // 入口
3798
+ bindEvents();
3799
+ bootstrap();