(() => { const login = document.getElementById('loginBtn'); const nav = document.querySelector('[data-view="messages"]'); const view = document.getElementById('messages'); if (!login || !nav || !view) return; document.querySelector('[data-view="groups"]')?.remove(); let token = '', user = null, selectedKey = '', replyTo = null, avatarVersion = Date.now(), selectionMode = false, pollTimer = null, messageQuery = ''; const selectedIds = new Set(); const drafts = new Map(), pendingByConversation = new Map(), scrollByConversation = new Map(), groupMemberCache = new Map(); let sessions = [], objectUrls = [], cache = { groups: [], messages: [], direct: [], pins: [] }; const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const api = async (url, options = {}) => { const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}) } }); const body = await response.json(); if (!response.ok) throw new Error(body.error || '操作失败'); return body; }; const avatar = (name, cls = '', image = true, stableAgentId = '') => { const known = { '单号查询员': 'order-query', '自家成本智能体': 'cost-quote', '售价智能体': 'retail-quote', '经销商智能体': 'dealer-quote', '报价智能体': 'dealer-quote' }; const agentId = stableAgentId || known[name] || ''; const src = agentId ? `/api/agents/${agentId}/avatar?v=${avatarVersion}` : `/api/profile/avatar/${encodeURIComponent(name)}?v=${avatarVersion}`; return `${esc(String(name || '?').slice(0, 1))}${image ? `${esc(name)}头像` : ''}`; }; const agentNote = (agent) => agent.role === 'order-query' ? '单号和订单查询' : ({ cost: '仅查询自家成本', retail: '仅查询售价价格', dealer: '仅查询经销商价格' }[agent.priceCategory] || '产品报价'); const size = (n) => Number(n || 0) < 1024 ? `${Number(n || 0)} B` : Number(n) < 1048576 ? `${(Number(n) / 1024).toFixed(1)} KB` : `${(Number(n) / 1048576).toFixed(1)} MB`; const urlFor = (s, m) => s.type === 'group' ? `/api/group-files/${m.id}` : `/api/direct-files/${m.id}`; const itemsFor = (s) => !s ? [] : s.type === 'group' ? cache.messages.filter((m) => m.groupId === s.id) : cache.direct.filter((m) => (m.from === user.name && m.to === s.id) || (m.to === user.name && m.from === s.id)); const pendingFor = (key) => { if (!pendingByConversation.has(key)) pendingByConversation.set(key, []); return pendingByConversation.get(key); }; const highlightMentions = (text) => esc(text).replace(/(@全体|@[\u4e00-\u9fffA-Za-z0-9_-]+)/g, '$1'); const validAttachment = (file) => !file.size ? '不能发送空文件' : file.size > 100 * 1024 * 1024 ? '单个文件不能超过 100 MB' : !/\.(jpe?g|png|webp|gif|xls|xlsx|csv|tsv|pdf|docx?|pptx?)$/i.test(file.name) ? '不支持此文件类型' : ''; function attachment(s, m) { const url = urlFor(s, m); const ext = String(m.fileName || '').toLowerCase().match(/\.[^.]+$/)?.[0] || ''; const kind = m.fileKind || (/^\.(jpg|jpeg|png|webp|gif)$/.test(ext) ? 'image' : /^\.(xls|xlsx|csv|tsv)$/.test(ext) ? 'spreadsheet' : 'file'); if (kind === 'image') return ``; const status = m.ingestStatus === 'success' ? '已录入' : m.ingestStatus === 'failed' ? '录入失败' : '文件'; return ``; } function messageHtml(s, m, all) { const sender = s.type === 'direct' ? m.from : m.sender; const quoted = m.replyTo && all.find((x) => x.id === m.replyTo); return `
${selectionMode ? `` : ''}${avatar(sender, 'avatar-message', true, m.agentId)}
${esc(sender)}${quoted ? `
${esc(quoted.sender || quoted.from)}:${esc(quoted.text || quoted.fileName || '消息')}
` : ''}${m.forwardedFrom ? '
转发的消息
' : ''}${m.fileName ? attachment(s, m) : `
${highlightMentions(m.text)}
`}${selectionMode ? '' : ''}
`; } function rebuildSessions() { const people = [...new Set(cache.direct.flatMap((m) => [m.from, m.to]).filter((n) => n && n !== user.name))]; const make = (type, id, name, messages) => { const last = messages.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)).at(-1); const group = type === 'group' ? cache.groups.find((item) => item.id === id) : null; return { key: `${type}:${id}`, type, id, name, time: last?.createdAt || '', preview: last?.fileName ? `[文件] ${last.fileName}` : last?.text || '暂无消息', unreadCount: group?.unreadCount || 0, mentionedUnread: group?.mentionedUnread === true }; }; sessions = [ ...cache.groups.filter((g) => !g.dissolved).map((g) => make('group', g.id, g.name, cache.messages.filter((m) => m.groupId === g.id))), ...people.map((name) => make('direct', name, name, cache.direct.filter((m) => m.from === name || m.to === name))) ].sort((a, b) => (cache.pins.includes(b.key) - cache.pins.includes(a.key)) || (new Date(b.time || 0) - new Date(a.time || 0))); if (selectedKey && !sessions.some((s) => s.key === selectedKey)) selectedKey = ''; } const releaseUrls = () => { objectUrls.forEach(URL.revokeObjectURL); objectUrls = []; }; async function fileBlob(url) { const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) throw new Error('文件读取失败或无权访问'); return response.blob(); } function hydrateFiles() { view.querySelectorAll('[data-image-url]').forEach(async (button) => { try { const url = URL.createObjectURL(await fileBlob(button.dataset.imageUrl)); objectUrls.push(url); button.innerHTML = `聊天图片`; button.onclick = () => window.open(url, '_blank', 'noopener'); } catch (e) { button.innerHTML = `${esc(e.message)}`; } }); view.querySelectorAll('[data-download-url]').forEach((button) => { button.onclick = async () => { button.disabled = true; try { const url = URL.createObjectURL(await fileBlob(button.dataset.downloadUrl)); const a = document.createElement('a'); a.href = url; a.download = button.dataset.fileName || '下载文件'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (e) { alert(e.message); } finally { button.disabled = false; } }; }); } async function memberDialog(group, mode = 'view') { try { const currentMembers = await api(`/api/groups/${group.id}/members`); groupMemberCache.set(group.id, currentMembers); let contacts = currentMembers.contacts || [], agents = currentMembers.agents || []; if (mode === 'invite' && currentMembers.canManage) { const [contactSource, agentSource] = await Promise.all([api('/api/contacts'), api('/api/agents')]); const existing = new Set([...contacts.map((x) => x.name), ...agents.map((x) => x.name)]); contacts = [{ name: user.name, role: user.role }, ...(contactSource.items || [])].map((x) => ({ ...x, existing: existing.has(x.name) })); agents = (agentSource.items || []).map((x) => ({ ...x, existing: existing.has(x.name) })); } else { contacts = contacts.map((x) => ({ ...x, existing: true })); agents = agents.map((x) => ({ ...x, existing: true })); } if (mode === 'remove') { contacts = contacts.filter((x) => x.name !== '伊青荣'); agents = agents.map((x) => ({ ...x, existing: false })); } const selectable = mode !== 'view' && currentMembers.canManage; const rows = (items, type) => items.map((item) => ``).join('') || '

暂无

'; const content = `

联系人(${contacts.length})

${rows(contacts, 'contact')}

智能体(${agents.length})

${rows(agents, 'agent')}
`; const footer = mode === 'view' ? `${currentMembers.canManage ? '' : ''}` : ''; const wrap = closeableDialog(mode === 'invite' ? '邀请成员' : mode === 'remove' ? '移除成员' : `群成员(${currentMembers.memberCount})`, content, footer); wrap.querySelector('[data-member-search]').oninput = (event) => wrap.querySelectorAll('[data-search-name]').forEach((row) => { row.hidden = !row.dataset.searchName.toLowerCase().includes(event.target.value.trim().toLowerCase()); }); wrap.querySelector('[data-invite]')?.addEventListener('click', () => { wrap.remove(); memberDialog(group, 'invite'); }); wrap.querySelector('[data-remove]')?.addEventListener('click', () => { wrap.remove(); memberDialog(group, 'remove'); }); wrap.querySelector('[data-group-action="rename"]')?.addEventListener('click', async () => { const name = prompt('请输入新的群名称', group.name); if (!name?.trim()) return; try { await api(`/api/groups/${group.id}`, { method: 'PATCH', body: JSON.stringify({ name: name.trim() }) }); wrap.remove(); await load(); } catch (error) { alert(error.message); } }); wrap.querySelector('[data-group-action="clear"]')?.addEventListener('click', () => { wrap.remove(); const session = { type: 'group', id: group.id, key: `group:${group.id}`, name: group.name }; cleanupDialog(session, itemsFor(session)); }); wrap.querySelector('[data-group-action="dissolve"]')?.addEventListener('click', async () => { if (!confirm(`确定解散“${group.name}”?历史消息会保留。`)) return; try { await api(`/api/groups/${group.id}`, { method: 'PATCH', body: JSON.stringify({ dissolved: true }) }); wrap.remove(); selectedKey = ''; await load(); } catch (error) { alert(error.message); } }); wrap.querySelector('[data-confirm]')?.addEventListener('click', async () => { const members = [...wrap.querySelectorAll('input[type="checkbox"]:checked')].map((input) => ({ type: input.dataset.memberType, id: input.dataset.memberId || undefined, name: input.value })); if (!members.length) return alert('请至少选择一位成员'); await api(`/api/groups/${group.id}/members`, { method: mode === 'invite' ? 'POST' : 'DELETE', body: JSON.stringify({ members }) }); wrap.remove(); await load(); }); } catch (error) { alert(error.message); } } function closeableDialog(title, content, footer) { const wrap = document.createElement('div'); wrap.className = 'invite-modal action-modal'; wrap.innerHTML = `
${esc(title)}
${content}
`; document.body.appendChild(wrap); wrap.querySelectorAll('[data-close]').forEach((button) => button.onclick = () => wrap.remove()); return wrap; } async function forwardMessages(messageIds, mode = 'individual') { const contacts = (await api('/api/contacts')).items || []; const groups = cache.groups.filter((group) => !group.dissolved); const content = `

群聊

${groups.map((group) => ``).join('') || '

暂无群聊

'}

联系人

${contacts.map((contact) => ``).join('') || '

暂无联系人

'}
`; const wrap = closeableDialog(mode === 'merged' ? '合并转发' : '逐条转发', content, ''); wrap.querySelector('[data-confirm]').onclick = async () => { const picked = wrap.querySelector('input[name="forwardTarget"]:checked'); if (!picked) return alert('请选择群聊或联系人'); const split = picked.value.indexOf(':'); const targetType = picked.value.slice(0, split), targetId = picked.value.slice(split + 1); try { await api('/api/messages/forward', { method: 'POST', body: JSON.stringify({ messageIds, targetType, targetId, mode }) }); wrap.remove(); selectionMode = false; selectedIds.clear(); await load(); } catch (error) { alert(error.message); } }; } function confirmSelection(title, text, onConfirm) { const wrap = closeableDialog(title, `

${esc(text)}

`, ''); wrap.querySelector('[data-confirm]').onclick = async () => { const button = wrap.querySelector('[data-confirm]'); button.disabled = true; try { await onConfirm(); wrap.remove(); } catch (error) { alert(error.message); button.disabled = false; } }; } function cleanupDialog(selected, all) { const wrap = closeableDialog('选择性清除聊天记录', '
已选择 0 条

只对你本人隐藏,不影响群成员、联系人和本地文件。

', ''); const range = wrap.querySelector('[data-range]'), list = wrap.querySelector('.cleanup-list'), custom = wrap.querySelector('.custom-dates'); const checked = new Set(); const filtered = () => { const now = new Date(); let start = null, end = null; if (range.value === 'today') { start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); } else if (/^\d+$/.test(range.value)) start = new Date(now.getTime() - Number(range.value) * 86400000); else if (range.value === 'custom') { const startValue = wrap.querySelector('[data-start]').value, endValue = wrap.querySelector('[data-end]').value; start = startValue ? new Date(`${startValue}T00:00:00`) : null; end = endValue ? new Date(`${endValue}T23:59:59`) : null; } return all.filter((message) => (!start || new Date(message.createdAt) >= start) && (!end || new Date(message.createdAt) <= end)); }; const updateCount = () => { wrap.querySelector('[data-count]').textContent = `已选择 ${checked.size} 条`; }; const draw = () => { const rows = filtered(); list.innerHTML = rows.map((message) => ``).join('') || '

该时间范围内没有消息

'; list.querySelectorAll('input').forEach((input) => input.onchange = () => { input.checked ? checked.add(input.value) : checked.delete(input.value); updateCount(); }); updateCount(); }; range.onchange = () => { custom.hidden = range.value !== 'custom'; checked.clear(); draw(); }; wrap.querySelectorAll('[data-start],[data-end]').forEach((input) => input.onchange = () => { checked.clear(); draw(); }); wrap.querySelector('[data-all]').onclick = () => { filtered().forEach((message) => checked.add(message.id)); draw(); }; wrap.querySelector('[data-confirm]').onclick = () => { if (!checked.size) return alert('请选择要清除的消息'); confirmSelection('确认清除', `将从你本人的“${selected.name}”会话中隐藏 ${checked.size} 条消息,其他人仍然可以看到。`, async () => { await api('/api/conversation-state', { method: 'POST', body: JSON.stringify({ key: selected.key, action: 'hide_messages', messageIds: [...checked] }) }); wrap.remove(); await load(); }); }; draw(); } function render(options = {}) { const oldInput = view.querySelector('#rebuildInput'); const restoreInputFocus = oldInput && document.activeElement === oldInput; const oldSelectionStart = oldInput?.selectionStart ?? null; const oldSelectionEnd = oldInput?.selectionEnd ?? null; if (oldInput && selectedKey) drafts.set(selectedKey, oldInput.value); const oldHistory = view.querySelector('.rebuild-msg main>section'); if (oldHistory && selectedKey) scrollByConversation.set(selectedKey, { top: oldHistory.scrollTop, nearBottom: oldHistory.scrollHeight - oldHistory.scrollTop - oldHistory.clientHeight < 80 }); releaseUrls(); const selected = sessions.find((s) => s.key === selectedKey), all = itemsFor(selected), filteredItems = messageQuery ? all.filter((item) => `${item.sender || item.from || ''} ${item.text || ''} ${item.fileName || ''}`.toLocaleLowerCase().includes(messageQuery.toLocaleLowerCase())) : all, items = filteredItems.slice(-200); const list = sessions.map((s) => ``).join(''); const selectedGroup = selected?.type === 'group' ? cache.groups.find((g) => g.id === selected.id) : null; const groupTools = selectedGroup ? `` : ''; const replyBar = replyTo ? `
引用 ${esc(replyTo.sender || replyTo.from)}:${esc(replyTo.text || replyTo.fileName)}
` : ''; const selectionBar = `
已选择 ${selectedIds.size} 条
`; const pending = selected ? pendingFor(selected.key) : []; const pendingHtml = pending.length ? `
${pending.map((item) => `
${item.previewUrl ? `待发送图片` : `${esc(item.file.name.split('.').pop()?.toUpperCase() || '文件')}`}${esc(item.file.name)}${size(item.file.size)} · ${item.status === 'sending' ? '发送中' : item.status === 'failed' ? item.error : '待发送'}${item.status === 'failed' ? '' : ''}
`).join('')}
` : ''; const composer = selectionMode ? selectionBar : `${replyBar}${pendingHtml}`; view.querySelector('.card').innerHTML = `
${esc(selected?.name || '消息')}${selected ? `${messageQuery ? '' : ''}` : ''}${groupTools}${selected?.type === 'direct' ? '' : ''}
${selected ? items.map((m) => messageHtml(selected, m, all)).join('') || '

暂无匹配的消息

' : '

请从左侧选择群聊或联系人

'}
${selected ? composer : ''}
`; view.querySelector('[data-search="submit"]')?.addEventListener('click', () => { messageQuery = view.querySelector('[data-conversation-search]').value.trim(); render(); }); view.querySelector('[data-search="clear"]')?.addEventListener('click', () => { messageQuery = ''; render(); }); view.querySelector('[data-conversation-search]')?.addEventListener('keydown', (event) => { if (event.key === 'Enter') { messageQuery = event.target.value.trim(); render(); } }); view.querySelectorAll('.rebuild-session').forEach((b) => b.onclick = async () => { selectedKey = b.dataset.key; replyTo = null; selectionMode = false; selectedIds.clear(); const next = sessions.find((s) => s.key === selectedKey); if (next?.type === 'group') { await api('/api/conversation-state', { method: 'POST', body: JSON.stringify({ key: selectedKey, action: 'mark_read' }) }); await load({ forceBottom: true }); } else render({ forceBottom: true }); }); view.querySelector('[data-new-group]')?.addEventListener('click', async () => { try { const contacts = await api('/api/contacts'); const agents = user?.role === '管理员' ? await api('/api/agents') : { items: [] }; const contactRows = contacts.items.map((item) => ``).join('') || '

暂无联系人

'; const agentRows = agents.items.map((item) => ``).join(''); const content = `

联系人

${contactRows}${user?.role === '管理员' ? `

智能体

${agentRows}` : ''}`; const wrap = closeableDialog('新建群聊', content, ''); wrap.querySelector('[data-group-name]').focus(); wrap.querySelector('[data-confirm]').onclick = async () => { const name = wrap.querySelector('[data-group-name]').value.trim(); if (!name) return alert('请输入群名称'); const members = [...wrap.querySelectorAll('[data-member-choice]:checked')].map((input) => input.value); try { const group = await api('/api/groups', { method: 'POST', body: JSON.stringify({ name, members }) }); wrap.remove(); selectedKey = `group:${group.id}`; await load(); } catch (e) { alert(e.message); } }; } catch (e) { alert(e.message); } }); view.querySelectorAll('.message-row').forEach((row) => row.addEventListener('contextmenu', (event) => { event.preventDefault(); event.stopPropagation(); const message = all.find((item) => item.id === row.dataset.messageId); if (!message) return; document.querySelector('.message-context-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'message-context-menu'; menu.innerHTML = ''; menu.style.cssText = `position:fixed;left:${Math.min(event.clientX, innerWidth - 150)}px;top:${Math.min(event.clientY, innerHeight - 250)}px;z-index:200;background:#fff;border:1px solid #dce3ea;border-radius:6px;box-shadow:0 8px 24px #17212b33;padding:5px;width:140px`; menu.querySelectorAll('button').forEach((button) => { button.style.cssText = 'display:block;width:100%;padding:9px;border:0;background:#fff;text-align:left;cursor:pointer'; button.onclick = async () => { const action = button.dataset.action; menu.remove(); if (action === 'copy') { await navigator.clipboard.writeText(message.text || message.fileName || '消息'); alert('已复制'); } if (action === 'forward') await forwardMessages([message.id]); if (action === 'select') { selectionMode = true; selectedIds.add(message.id); render(); } if (action === 'favorite') { await api('/api/conversation-state', { method: 'POST', body: JSON.stringify({ key: selected.key, action: 'favorite_messages', messageIds: [message.id] }) }); alert('已收藏'); } if (action === 'quote') { replyTo = message; render(); view.querySelector('#rebuildInput')?.focus(); } if (action === 'delete') confirmSelection('删除消息', '只从你本人的当前会话中隐藏这条消息,其他人仍然可以看到。', async () => { await api('/api/conversation-state', { method: 'POST', body: JSON.stringify({ key: selected.key, action: 'hide_messages', messageIds: [message.id] }) }); await load(); }); }; }); document.body.appendChild(menu); })); view.querySelectorAll('.message-actions').forEach((actions) => actions.onclick = async (event) => { const message = all.find((m) => m.id === actions.closest('.message-row').dataset.messageId); if (event.target.dataset.action === 'quote') { replyTo = message; render(); view.querySelector('#rebuildInput')?.focus(); } if (event.target.dataset.action === 'forward') await forwardMessages([message.id], 'individual'); if (event.target.dataset.action === 'select') { selectionMode = true; selectedIds.add(message.id); render(); } }); view.querySelectorAll('.message-selector input').forEach((input) => input.onchange = () => { const id = input.closest('.message-row').dataset.messageId; input.checked ? selectedIds.add(id) : selectedIds.delete(id); render(); }); view.querySelector('.selection-bar')?.addEventListener('click', async (event) => { const action = event.target.dataset.bulk; if (!action) return; const ids = [...selectedIds], chosen = all.filter((message) => selectedIds.has(message.id)); if (action === 'cancel') { selectionMode = false; selectedIds.clear(); return render(); } if (!ids.length) return alert('请先选择消息'); if (action === 'individual' || action === 'merged') return forwardMessages(ids, action); if (action === 'copy') { await navigator.clipboard.writeText(chosen.map((message) => `${message.sender || message.from}:${message.text || message.fileName || '消息'}`).join('\n')); return alert('已复制所选消息'); } if (action === 'favorite') { await api('/api/conversation-state', { method: 'POST', body: JSON.stringify({ key: selected.key, action: 'favorite_messages', messageIds: ids }) }); return alert(`已收藏 ${ids.length} 条消息`); } if (action === 'save') { const files = chosen.filter((message) => message.fileName); if (!files.length) return alert('所选消息中没有可保存的图片或文件'); for (const message of files) { const blobUrl = URL.createObjectURL(await fileBlob(urlFor(selected, message))); const anchor = document.createElement('a'); anchor.href = blobUrl; anchor.download = message.fileName; anchor.click(); setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } return; } if (action === 'delete') confirmSelection('删除所选消息', `只从你本人的当前会话中隐藏 ${ids.length} 条消息,其他人仍然可以看到。`, async () => { await api('/api/conversation-state', { method: 'POST', body: JSON.stringify({ key: selected.key, action: 'hide_messages', messageIds: ids }) }); selectionMode = false; selectedIds.clear(); await load(); }); }); view.querySelector('[data-cancel-reply]')?.addEventListener('click', () => { replyTo = null; render(); }); const input = view.querySelector('#rebuildInput'), send = view.querySelector('#rebuildSend'); if (restoreInputFocus && input) { input.focus(); if (oldSelectionStart !== null && oldSelectionEnd !== null) input.setSelectionRange(oldSelectionStart, oldSelectionEnd); } const uploadPending = async (item, batchId) => { item.status = 'sending'; render(); const headers = { Authorization: `Bearer ${token}`, 'X-File-Name-Encoded': encodeURIComponent(item.file.name), 'X-Batch-Id': batchId, 'Content-Type': item.file.type || 'application/octet-stream' }; headers[selected.type === 'group' ? 'X-Group-Id' : 'X-To'] = selected.id; const response = await fetch(selected.type === 'group' ? '/api/group-files' : '/api/direct-files', { method: 'POST', headers, body: item.file }); const result = await response.json(); if (!response.ok) throw new Error(result.error || '文件发送失败'); if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); pendingByConversation.set(selected.key, pendingFor(selected.key).filter((entry) => entry.id !== item.id)); }; const sendText = async () => { const text = input.value.trim(), pendingItems = [...pendingFor(selected.key)]; if (!text && !pendingItems.length) return; send.disabled = true; const batchId = crypto.randomUUID(); try { if (text) { const members = selected.type === 'group' ? (groupMemberCache.get(selected.id) || await api(`/api/groups/${selected.id}/members`)) : null; if (members) groupMemberCache.set(selected.id, members); const mentions = members ? members.contacts.map((x) => x.name).filter((name) => text.includes(`@${name}`)) : []; await api(selected.type === 'group' ? '/api/messages' : '/api/direct-messages', { method: 'POST', body: JSON.stringify(selected.type === 'group' ? { groupId: selected.id, text, replyTo: replyTo?.id, mentions, mentionAll: text.includes('@全体'), batchId } : { to: selected.id, text, replyTo: replyTo?.id }) }); // Clear the live input before rerendering; render() snapshots the old DOM value. input.value = ''; drafts.set(selected.key, ''); replyTo = null; } for (const item of pendingItems) { try { await uploadPending(item, batchId); } catch (error) { item.status = 'failed'; item.error = error.message; } } await load({ forceBottom: true }); } catch (e) { alert(e.message); } finally { send.disabled = false; } }; if (send) send.onclick = sendText; if (input) { input.oninput = () => { drafts.set(selected.key, input.value); if (selected.type === 'group' && /@$/.test(input.value)) showMentionPicker(); }; input.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendText(); } }; } const choose = view.querySelector('#rebuildChoose'), fileInput = view.querySelector('#rebuildFile'); if (choose) choose.onclick = () => fileInput.click(); if (fileInput) fileInput.onchange = () => { for (const file of fileInput.files || []) { const error = validAttachment(file); pendingFor(selected.key).push({ id: crypto.randomUUID(), file, status: error ? 'failed' : 'pending', error, previewUrl: file.type.startsWith('image/') && !error ? URL.createObjectURL(file) : '' }); } fileInput.value = ''; render(); }; view.querySelectorAll('[data-pending-id]').forEach((row) => { const item = pendingFor(selected.key).find((entry) => entry.id === row.dataset.pendingId); row.querySelector('[data-remove]').onclick = () => { if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); pendingByConversation.set(selected.key, pendingFor(selected.key).filter((entry) => entry.id !== item.id)); render(); }; row.querySelector('[data-retry]')?.addEventListener('click', async () => { try { await uploadPending(item, crypto.randomUUID()); await load({ forceBottom: true }); } catch (error) { item.status = 'failed'; item.error = error.message; render(); } }); }); const showMentionPicker = async () => { const picker = view.querySelector('.mention-picker'); const members = groupMemberCache.get(selected.id) || await api(`/api/groups/${selected.id}/members`); groupMemberCache.set(selected.id, members); picker.innerHTML = `${members.contacts.filter((x) => x.name !== user.name).map((x) => ``).join('')}`; picker.hidden = false; picker.querySelectorAll('[data-mention-name]').forEach((button) => button.onclick = () => { input.value = input.value.replace(/@$/, '') + `@${button.dataset.mentionName} `; drafts.set(selected.key, input.value); picker.hidden = true; input.focus(); }); }; view.querySelector('[data-mention]')?.addEventListener('click', showMentionPicker); view.querySelector('.group-tools')?.addEventListener('click', async (event) => { const action = event.target.dataset.tool; if (!action) return; if (action === 'members') return memberDialog(selected, 'view'); if (action === 'rename') { const name = prompt('请输入新的群名称', selected.name); if (name?.trim()) { await api(`/api/groups/${selected.id}`, { method: 'PATCH', body: JSON.stringify({ name: name.trim() }) }); await load(); } } if (action === 'dissolve' && confirm('确定解散当前群聊?历史消息会保留。')) { await api(`/api/groups/${selected.id}`, { method: 'PATCH', body: JSON.stringify({ dissolved: true }) }); selectedKey = ''; await load(); } if (action === 'clear') cleanupDialog(selected, all); }); hydrateFiles(); const history = view.querySelector('.rebuild-msg main>section'); if (history && selected) { const saved = scrollByConversation.get(selected.key); if (options.forceBottom || !saved || saved.nearBottom) history.scrollTop = history.scrollHeight; else history.scrollTop = saved.top; } } async function load(options = {}) { const [direct, groups, messages, pins] = await Promise.all([api('/api/direct-messages'), api('/api/groups'), api('/api/messages'), api('/api/pins')]); cache = { direct: direct.items || [], groups: groups.items || [], messages: messages.items || [], pins: pins.items || [] }; rebuildSessions(); render(options); } const startPolling = () => { if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => { if (user && nav.classList.contains('active') && !document.hidden) load({ polling: true }).catch(() => {}); }, 3000); }; function setupAvatar() { const box = document.querySelector('.account .avatar'); if (!box || !user) return; let input = document.getElementById('profileAvatarInput'); if (!input) { input = document.createElement('input'); input.id = 'profileAvatarInput'; input.type = 'file'; input.accept = 'image/*'; input.hidden = true; document.body.appendChild(input); } const apply = () => { box.textContent = ''; box.style.background = `center / cover no-repeat url('/api/profile/avatar/${encodeURIComponent(user.name)}?v=${avatarVersion}')`; }; box.title = '点击更换头像'; box.style.cursor = 'pointer'; fetch(`/api/profile/avatar/${encodeURIComponent(user.name)}?v=${avatarVersion}`).then((r) => r.ok && apply()); box.onclick = () => input.click(); input.onchange = async () => { const file = input.files?.[0]; if (!file || !file.type.startsWith('image/')) return; try { const response = await fetch('/api/profile/avatar', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'X-File-Name-Encoded': encodeURIComponent(file.name) }, body: file }); if (!response.ok) throw new Error('头像上传失败'); avatarVersion = Date.now(); apply(); render(); } catch (e) { alert(e.message); } finally { input.value = ''; } }; } const style = document.createElement('style'); style.textContent = ` .rebuild-msg{display:grid;grid-template-columns:300px minmax(0,1fr);height:calc(100vh - 140px);min-height:560px;max-height:760px;border:1px solid #dfe6ed;border-radius:8px;overflow:hidden;background:#fff}.rebuild-msg aside{background:#f3f5f7;overflow:auto;border-right:1px solid #dfe6ed}.message-list-title{height:52px;padding:10px 16px;font-size:16px;font-weight:700;border-bottom:1px solid #dde4eb;display:flex;align-items:center;justify-content:space-between}.new-group-button{border:0;background:transparent;color:#2f78df;font-size:22px;cursor:pointer}.empty-list{padding:16px}.rebuild-session{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;width:100%;padding:12px;border:0;border-bottom:1px solid #e1e6eb;background:transparent;text-align:left;cursor:pointer}.rebuild-session:hover{background:#e8edf2}.rebuild-session.active{background:#d8eaff}.rebuild-session b,.rebuild-session small{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.rebuild-session small{grid-column:2/4;color:#7e8a97;margin-top:3px}.rebuild-session time{color:#87929d;font-size:11px}.pin-mark{position:absolute;right:9px;bottom:7px;color:#67809a;font-size:10px;font-style:normal}.unread-badge,.mention-badge{position:absolute;right:10px;top:28px;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:#e44848;color:#fff;display:grid;place-items:center;font-size:11px;font-style:normal}.mention-badge{font-weight:700} .rebuild-msg main{display:flex;flex-direction:column;min-width:0;min-height:0}.rebuild-msg main>header{min-height:52px;padding:10px 16px;border-bottom:1px solid #e1e6eb;display:flex;justify-content:space-between;align-items:center}.group-tools{display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end}.group-tools button{border:1px solid #d5dfe9;background:#fff;border-radius:4px;padding:5px 8px;cursor:pointer;color:#2f78df}.group-tools .danger-text{color:#b94343}.rebuild-msg main>section{flex:1;min-height:0;overflow:auto;padding:16px;background:#f6f7f8}.empty-chat{text-align:center;margin-top:80px}.mention{background:#dcecff;color:#2468b3;padding:0 2px} .user-avatar{position:relative;width:30px;height:30px;border-radius:4px;background:#2f78df;color:#fff;display:inline-flex;align-items:center;justify-content:center;overflow:hidden;flex:none}.user-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:center}.avatar-list{width:38px;height:38px;grid-row:1/3;margin-right:10px}.avatar-message{width:34px;height:34px}.avatar-small{width:32px;height:32px}.message-row{display:flex;align-items:flex-start;gap:9px;margin:14px 0}.message-row.me{flex-direction:row-reverse}.message-content{min-width:0;max-width:min(72%,540px)}.message-content>b{display:block;color:#718092;font-size:12px;margin-bottom:4px}.message-row.me .message-content{text-align:right}.message-bubble{display:inline-block;background:#fff;border:1px solid #dfe5eb;border-radius:6px;padding:9px 12px;line-height:1.5;text-align:left;word-break:break-word}.message-row.me .message-bubble{background:#95ec69;border-color:#86d95c}.message-content time{display:block;color:#929eaa;font-size:11px;margin-top:4px}.quoted-message{border-left:3px solid #aeb9c4;background:#edf0f3;color:#667482;font-size:12px;padding:5px 7px;margin-bottom:4px;text-align:left}.forwarded-label{color:#8a96a2;font-size:11px;margin-bottom:3px}.message-actions{display:flex;gap:8px;margin-top:3px;opacity:.68}.message-row:hover .message-actions{opacity:1}.message-row.me .message-actions{justify-content:flex-end}.message-actions button{border:0;background:transparent;color:#62788d;font-size:11px;padding:0;cursor:pointer} .chat-image{display:block;border:0;background:transparent;padding:0;cursor:pointer}.chat-image img{display:block;max-width:260px;max-height:260px;border-radius:4px;object-fit:contain;background:#fff}.chat-image span{display:block;padding:20px;background:#e8edf2;color:#647485;border-radius:4px}.file-card{display:grid;grid-template-columns:42px minmax(0,1fr) auto;gap:10px;align-items:center;width:min(340px,60vw);padding:12px;border:1px solid #dfe5eb;border-radius:6px;background:#fff;text-align:left;cursor:pointer}.file-card:hover{border-color:#9fb7cf}.file-card i{width:38px;height:38px;border-radius:4px;background:#2f9b62;color:#fff;display:grid;place-items:center;font-weight:700;font-style:normal}.file-card b,.file-card small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-card small{color:#7d8996;margin-top:4px}.file-card em{color:#2f78df;font-size:12px;font-style:normal}.reply-bar{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;background:#eef3f7;border-top:1px solid #dfe6ed;color:#5f7080;font-size:12px}.reply-bar button{border:0;background:transparent;font-size:18px}.rebuild-msg footer{display:flex;gap:8px;padding:10px 12px;border-top:1px solid #e1e6eb}.rebuild-msg footer .input{margin:0;flex:1}.attach-button,.mention-button{width:40px;border:1px solid #cbd6e1;border-radius:6px;background:#fff;color:#53687d;font-size:22px;cursor:pointer}.pending-files{display:flex;gap:8px;overflow-x:auto;padding:9px 12px;border-top:1px solid #e1e6eb}.pending-file{display:grid;grid-template-columns:42px minmax(120px,1fr) auto auto;gap:8px;align-items:center;min-width:260px;border:1px solid #dbe3ea;border-radius:6px;padding:7px;background:#fff}.pending-file.failed{border-color:#dc7777;background:#fff7f7}.pending-file img,.pending-file i{width:40px;height:40px;object-fit:cover;border-radius:4px}.pending-file i{display:grid;place-items:center;background:#eef3f7;font-size:10px;font-style:normal}.pending-file span b,.pending-file span small{display:block;max-width:150px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pending-file span small{color:#768594}.pending-file button{border:0;background:transparent;color:#2f78df;cursor:pointer}.mention-picker{position:absolute;z-index:15;bottom:58px;left:14px;width:280px;max-height:280px;overflow:auto;background:#fff;border:1px solid #d5dfe8;border-radius:6px;box-shadow:0 8px 24px #17212b33;padding:6px}.rebuild-msg main{position:relative}.mention-picker button{display:flex;align-items:center;gap:9px;width:100%;border:0;background:#fff;padding:8px;text-align:left;cursor:pointer}.mention-picker button:hover{background:#f0f5f9}.mention-picker small{display:block;color:#778693} .invite-modal{position:fixed;inset:0;background:#17212b66;display:grid;place-items:center;z-index:20}.invite-dialog{width:430px;max-width:calc(100vw - 32px);background:#fff;border-radius:8px;overflow:hidden}.invite-dialog header,.invite-dialog footer{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid #e5ebf1}.invite-dialog header button{border:0;background:transparent;font-size:24px}.invite-dialog section{max-height:380px;overflow:auto;padding:8px 18px}.invite-person{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid #edf1f5}.invite-person span:last-child{display:flex;flex-direction:column}.invite-person small{color:#7b8794}.invite-dialog footer{justify-content:flex-end;gap:8px;border:0;border-top:1px solid #e5ebf1}.secondary{background:#eef3f8;color:#2f78df}@media(max-width:850px){.rebuild-msg{grid-template-columns:240px minmax(0,1fr)}.group-tools button{font-size:12px}.message-content{max-width:82%}} .action-modal .invite-dialog{width:520px}.action-modal section{max-height:520px}.target-list h4{margin:10px 0 6px;color:#677687}.target-list label{display:grid;grid-template-columns:auto auto 1fr auto;gap:10px;align-items:center;padding:9px;border-bottom:1px solid #edf1f5;cursor:pointer}.target-list label:hover{background:#f5f8fb}.target-list small{color:#7b8794}.confirm-copy{line-height:1.7;color:#536271}.cleanup-filter>label{display:flex;align-items:center;gap:12px}.cleanup-filter select{width:180px;margin:0}.custom-dates{display:flex;align-items:center;gap:8px;margin-top:12px}.custom-dates[hidden]{display:none}.custom-dates .input{margin:0}.cleanup-summary{display:flex;justify-content:space-between;align-items:center;margin:14px 0}.cleanup-list{border:1px solid #e1e7ee;border-radius:6px;max-height:290px;overflow:auto}.cleanup-item{display:flex;gap:10px;padding:10px;border-bottom:1px solid #edf1f5;cursor:pointer}.cleanup-item:last-child{border-bottom:0}.cleanup-item>span{display:grid;grid-template-columns:1fr auto;gap:3px;min-width:0;flex:1}.cleanup-item small{color:#84909d}.cleanup-item em{grid-column:1/3;color:#4f5f6f;font-style:normal;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cleanup-note{color:#7b8794;font-size:12px}.message-selector{align-self:center}.message-selector input{width:18px;height:18px}.selected-message{background:#dcebe255;border-radius:6px}.selection-bar{display:flex;align-items:center;justify-content:center;gap:18px;min-height:76px;padding:10px 16px;border-top:1px solid #dfe6ed;background:#fff;flex-wrap:wrap}.selection-bar>span{color:#677687;font-size:12px}.selection-bar button{border:0;background:transparent;color:#44596e;cursor:pointer;padding:7px}.selection-bar button:hover{color:#2f78df}.selection-bar .danger-text{color:#b94343}@media(max-width:850px){.rebuild-msg{grid-template-columns:240px minmax(0,1fr)}.group-tools button{font-size:12px}.message-content{max-width:82%}.selection-bar{gap:8px}} `; document.head.appendChild(style); document.addEventListener('contextmenu', (event) => { const button = event.target.closest?.('.rebuild-session'); if (!button) return; event.preventDefault(); const key = button.dataset.key; document.querySelector('.wechat-context-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'wechat-context-menu'; menu.innerHTML = ``; menu.style.cssText = `position:fixed;left:${Math.min(event.clientX, innerWidth - 170)}px;top:${Math.min(event.clientY, innerHeight - 80)}px;z-index:100;background:#fff;border:1px solid #dce3ea;border-radius:6px;box-shadow:0 8px 24px #17212b33;padding:5px;width:150px`; menu.firstChild.style.cssText = 'width:100%;padding:9px;border:0;background:#fff;text-align:left;cursor:pointer'; menu.firstChild.onclick = async () => { await api('/api/pins', { method: 'POST', body: JSON.stringify({ key }) }); menu.remove(); await load(); }; document.body.appendChild(menu); }, true); document.addEventListener('click', () => { document.querySelector('.wechat-context-menu')?.remove(); document.querySelector('.message-context-menu')?.remove(); }); login.addEventListener('click', () => setTimeout(async () => { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: document.getElementById('loginName').value.trim(), password: document.getElementById('loginPass').value }) }); const result = await response.json(); if (!result.token) return; token = result.token; user = result.user; window.__manyiToken = token; window.__manyiUser = user; setupAvatar(); startPolling(); if (nav.classList.contains('active')) load(); }, 50)); nav.addEventListener('click', (event) => { event.stopImmediatePropagation(); document.querySelectorAll('.nav button').forEach((b) => b.classList.remove('active')); nav.classList.add('active'); document.querySelectorAll('.view').forEach((s) => s.classList.remove('active')); view.classList.add('active'); document.getElementById('pageTitle').textContent = '消息'; const open = () => { startPolling(); load().catch((e) => { view.querySelector('.card').innerHTML = `

${esc(e.message)}

`; }); }; if (user) open(); else setTimeout(() => user && open(), 120); }, true); })();