(() => {
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 ? `
` : ''}`; };
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 = ``;
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('选择性清除聊天记录', '', '');
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 = `${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);
})();