Files
IM-web-new/src/components/chat/MessageList.vue
T

815 lines
25 KiB
Vue
Raw Normal View History

2026-06-30 10:13:48 +07:00
<template>
<div ref="listEl" class="message-list">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="[message.direction, { selected: isSelected(message), attention: isAttention(message), recalled: message.recalled || message.status === 'recalled' }]"
:data-message-key="messageIdentity(message)"
@click="handleMessageClick(message)"
>
<label v-if="selectionMode" class="message-select">
<input
type="checkbox"
:checked="isSelected(message)"
@click.stop
@change="emit('toggle-select', message)"
>
</label>
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-avatar"
:title="groupSenderName(message)"
@click.stop="openSenderProfile(message)"
>
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="">
<span v-else>{{ groupSenderAvatarText(message) }}</span>
</button>
<div class="message-stack">
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-name"
@click.stop="openSenderProfile(message)"
>
{{ groupSenderName(message) }}
<span v-if="senderRoleLabel(message)" class="message-sender-role">{{ senderRoleLabel(message) }}</span>
</button>
<div
class="message-bubble"
:class="roleBubbleClass(message)"
@contextmenu.prevent="openMenu($event, message)"
>
<button
v-if="isImageMessage(message)"
type="button"
class="message-image"
@click.stop="handleImageClick(message)"
>
<img
:src="message.attachment.url"
:alt="message.attachment.name || '图片'"
loading="lazy"
decoding="async"
@error="retryImageLoad"
>
2026-06-30 10:13:48 +07:00
</button>
<a
v-else-if="isFileMessage(message)"
class="message-file"
:href="message.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ message.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="displayMessageText(message)">{{ displayMessageText(message) }}</p>
2026-06-30 10:13:48 +07:00
<time>
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
<span v-if="message.editedAt" class="edit-mark">【edit {{ formatTime(message.editedAt) }}】</span>
<span v-if="isFavorite(message)" class="favorite-mark" title="已收藏" aria-label="已收藏">★</span>
<span
v-if="deliveryState(message)"
class="delivery-mark"
:class="`delivery-mark-${deliveryState(message)}`"
:title="deliveryTitle(message)"
:aria-label="deliveryTitle(message)"
>
<span class="delivery-check">✓</span>
<span v-if="deliveryState(message) !== 'sent'" class="delivery-check delivery-check-second">✓</span>
</span>
<span v-if="message.status === 'failed'" class="delivery-error">{{ failureText(message) }}</span>
</time>
</div>
</div>
</article>
<div
v-if="contextMenu.visible"
class="message-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button
v-for="item in menuItems"
:key="item.action"
type="button"
@click="chooseMenuAction(item.action)"
>
{{ item.label }}
</button>
</div>
<Teleport to="body">
<div
v-if="imageViewer.visible"
class="image-viewer-backdrop"
@click="closeImageViewer"
>
<div
class="image-viewer-dialog"
@click.stop
>
<div class="image-viewer-toolbar">
<span class="image-viewer-title">{{ imageViewer.name || '图片' }}</span>
<button type="button" title="缩小" aria-label="缩小" @click="zoomImage(-0.25)">−</button>
<button type="button" title="放大" aria-label="放大" @click="zoomImage(0.25)">+</button>
<button type="button" title="还原" aria-label="还原" @click="resetImageViewer">1:1</button>
<button type="button" title="旋转" aria-label="旋转" @click="rotateImage">⟳</button>
<button type="button" title="保存" aria-label="保存" @click="saveViewerImage">⇩</button>
<button type="button" title="关闭" aria-label="关闭" @click="closeImageViewer">×</button>
</div>
<div
ref="viewerStage"
class="image-viewer-stage"
:class="{ dragging: imageViewer.dragging, zoomed: imageViewer.scale > 1 }"
@wheel.prevent="handleImageWheel"
@pointerdown.prevent="startImageDrag"
>
<img
class="image-viewer-img"
:src="imageViewer.url"
:alt="imageViewer.name || '图片'"
:style="imageViewerStyle"
decoding="async"
@error="retryImageLoad"
2026-06-30 10:13:48 +07:00
draggable="false"
>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="senderProfile.visible"
class="sender-profile-backdrop"
@click="closeSenderProfile"
>
<section class="sender-profile-dialog" @click.stop>
<header>
<strong>用户资料</strong>
<button type="button" class="icon-button" title="关闭" @click="closeSenderProfile">×</button>
</header>
<div class="sender-profile-main">
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="">
<div v-else class="profile-avatar">{{ senderProfile.avatarText }}</div>
<strong>{{ senderProfile.displayName || senderProfile.uid }}</strong>
<span v-if="senderProfile.roleLabel">{{ senderProfile.roleLabel }}</span>
</div>
<dl class="sender-profile-fields">
<div>
<dt>用户ID</dt>
<dd>{{ senderProfile.uid || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ senderProfile.rawName || senderProfile.displayName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ senderProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ senderProfile.address || '未知' }}</dd>
</div>
</dl>
<label class="sender-profile-alias">
<span>备注名称</span>
<input
v-model.trim="senderProfile.aliasDraft"
maxlength="32"
autocomplete="off"
placeholder="可给群员设置备注"
:disabled="senderProfile.saving || senderProfile.adding"
>
</label>
<footer class="sender-profile-actions">
<span v-if="senderProfile.notice" class="inline-save-status">{{ senderProfile.notice }}</span>
<span v-else-if="senderProfile.error" class="inline-save-status error">{{ senderProfile.error }}</span>
<button
v-if="!senderProfile.isContact"
type="button"
class="ghost-inline-action"
:disabled="senderProfile.adding || senderProfile.saving || !senderProfile.uid"
@click="addSenderAsContact"
>
{{ senderProfile.adding ? '添加中...' : '添加为好友' }}
</button>
<button
type="button"
class="primary-inline-action"
:disabled="senderProfile.saving || senderProfile.adding || !senderProfile.uid"
@click="saveSenderAlias"
>
{{ senderProfile.saving ? '保存中...' : '保存备注' }}
</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const props = defineProps({
messages: {
type: Array,
default: () => []
},
selectionMode: {
type: Boolean,
default: false
},
selectedMessageIds: {
type: Array,
default: () => []
},
favoriteMessageIds: {
type: Array,
default: () => []
},
attentionMessageId: {
type: String,
default: ''
},
canRecallOthers: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['message-action', 'toggle-select', 'sender-profile-updated']);
const contacts = useContactStore();
const listEl = ref(null);
const viewerStage = ref(null);
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
message: null
});
const IMAGE_MIN_SCALE = 0.5;
const IMAGE_MAX_SCALE = 6;
const imageViewer = reactive({
visible: false,
url: '',
name: '',
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const senderProfile = reactive({
visible: false,
uid: '',
displayName: '',
rawName: '',
mobile: '',
address: '',
portrait: '',
avatarText: '',
role: '',
roleLabel: '',
contactAlias: '',
groupAlias: '',
isContact: false,
aliasDraft: '',
saving: false,
adding: false,
notice: '',
error: ''
});
const imageViewerStyle = computed(() => ({
transform: `translate3d(${imageViewer.offsetX}px, ${imageViewer.offsetY}px, 0) scale(${imageViewer.scale}) rotate(${imageViewer.rotation}deg)`
}));
const menuItems = computed(() => {
if (!contextMenu.message) {
return [];
}
const isOwnMessage = contextMenu.message.direction === 'out';
if (isRecalledMessage(contextMenu.message)) {
return [
...(isOwnMessage ? [{ action: 'edit', label: '编辑' }] : []),
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'detail', label: '详情' }
];
}
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
const items = [
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'forward', label: '转发' },
{ action: 'favorite', label: favoriteLabel },
{ action: 'quote', label: '引用' },
{ action: 'select', label: '多选' },
{ action: 'detail', label: '详情' }
];
if (isOwnMessage) {
items.unshift({ action: 'edit', label: '编辑' });
items.push({ action: 'recall', label: '撤回' });
} else if (props.canRecallOthers) {
items.push({ action: 'recall', label: '撤回' });
}
return items;
});
function scrollToBottom() {
nextTick(() => {
if (listEl.value) {
const scroll = () => {
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight;
}
};
scroll();
requestAnimationFrame(() => {
scroll();
requestAnimationFrame(scroll);
});
2026-06-30 10:13:48 +07:00
}
});
}
function scrollToMessage(messageId) {
nextTick(() => {
if (!listEl.value || !messageId) {
return;
}
const row = Array.from(listEl.value.querySelectorAll('.message-row'))
.find(element => element.dataset.messageKey === messageId);
if (row) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
watch(
() => props.messages.map(message => `${message.id}:${message.status}:${message.deliveryStatus || ''}:${message.messageUid || ''}:${message.recalled ? '1' : ''}`).join('|'),
scrollToBottom,
{ immediate: true }
);
watch(
() => props.attentionMessageId,
scrollToMessage
);
onMounted(() => {
document.addEventListener('click', closeMenu);
document.addEventListener('keydown', closeMenuOnEscape);
document.addEventListener('pointermove', moveImageDrag);
document.addEventListener('pointerup', stopImageDrag);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu);
document.removeEventListener('keydown', closeMenuOnEscape);
document.removeEventListener('pointermove', moveImageDrag);
document.removeEventListener('pointerup', stopImageDrag);
});
function messageIdentity(message) {
return String(message.messageUid || message.id || '');
}
function displayMessageText(message) {
const text = String(message?.text || '').replace(/^引用:.*(?:\r?\n|$)/, '').trim();
const contentType = Number(message?.contentType || 0);
if (!text) {
return '';
}
if (contentType !== 1 && text === '[暂不支持的消息类型]') {
return '';
}
if ((contentType === 3 || message?.type === 'image') && (text === '[图片]' || text === '图片')) {
return '';
}
return text;
}
2026-06-30 10:13:48 +07:00
function canOpenMessageMenu(message) {
return message && message.status !== 'failed' && message.status !== 'sending';
}
function isSelected(message) {
return props.selectedMessageIds.includes(messageIdentity(message));
}
function isFavorite(message) {
return props.favoriteMessageIds.includes(messageIdentity(message));
}
function isAttention(message) {
return props.attentionMessageId === messageIdentity(message);
}
function isRecallNotice(message) {
return message.direction === 'out' && (message.recalled || message.status === 'recalled');
}
function isRecalledMessage(message) {
return message.recalled || message.status === 'recalled';
}
function isImageMessage(message) {
return message?.type === 'image' && message?.attachment?.url;
}
function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function isGroupMessage(message) {
return ['group', 'channel'].includes(message?.conversationType || '');
}
function showGroupSender(message) {
return isGroupMessage(message) && message?.direction === 'in';
}
function senderRoleLabel(message) {
const role = message?.senderProfile?.role || message?.senderRole || '';
if (role === 'owner') {
return '群主';
}
if (role === 'admin') {
return '管理员';
}
return '';
}
function groupSenderName(message) {
return message?.senderName || message?.senderProfile?.displayName || message?.from || '未知用户';
}
function groupSenderAvatarText(message) {
return String(groupSenderName(message) || '?').slice(0, 1).toUpperCase();
}
function openSenderProfile(message) {
const profile = message?.senderProfile || {};
const uid = profile.uid || message?.from || '';
const cachedContact = contacts.contacts.find(contact => String(contact.uid || contact.id || '') === uid) || null;
const cachedAlias = String(cachedContact?.alias || '').trim();
const contactAlias = String(profile.contactAlias || cachedAlias || profile.remarkAlias || '').trim();
const groupAlias = String(profile.groupAlias || '').trim();
const rawName = profile.rawName
|| cachedContact?.rawUser?.displayName
|| cachedContact?.rawUser?.name
|| cachedContact?.title
|| groupSenderName(message);
const displayName = contactAlias || groupAlias || rawName || uid;
senderProfile.visible = true;
senderProfile.uid = uid;
senderProfile.displayName = displayName;
senderProfile.rawName = rawName || '';
senderProfile.mobile = profile.mobile || cachedContact?.rawUser?.mobile || '';
senderProfile.address = profile.address || cachedContact?.address || cachedContact?.rawUser?.address || '';
senderProfile.portrait = profile.portrait || cachedContact?.portrait || message?.senderPortrait || '';
senderProfile.avatarText = String(displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.role = profile.role || message?.senderRole || '';
senderProfile.roleLabel = senderRoleLabel(message);
senderProfile.contactAlias = contactAlias;
senderProfile.groupAlias = groupAlias;
senderProfile.isContact = Boolean(profile.isContact || cachedContact);
senderProfile.aliasDraft = contactAlias;
senderProfile.saving = false;
senderProfile.adding = false;
senderProfile.notice = '';
senderProfile.error = '';
closeMenu();
}
function closeSenderProfile() {
if (senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.visible = false;
}
async function addSenderAsContact() {
if (!senderProfile.uid || senderProfile.adding || senderProfile.saving) {
return;
}
senderProfile.adding = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.addContact(senderProfile.uid);
if (ok || String(contacts.actionError || '').includes('已经是联系人')) {
senderProfile.isContact = true;
const alias = senderProfile.aliasDraft.trim();
let aliasSaved = false;
if (alias) {
aliasSaved = await contacts.updateContactAlias(senderProfile.uid, alias);
if (aliasSaved) {
senderProfile.contactAlias = alias;
senderProfile.displayName = alias;
senderProfile.avatarText = String(alias || senderProfile.uid || '?').slice(0, 1).toUpperCase();
}
}
await contacts.refreshContacts({ force: true }).catch(() => {});
if (alias && !aliasSaved) {
senderProfile.notice = '';
senderProfile.error = contacts.actionError
? `已添加为好友,但${contacts.actionError}`
: '已添加为好友,但备注名同步失败';
} else {
senderProfile.notice = alias ? '已添加为好友,备注名已保存' : (ok ? '已添加为好友' : '已是好友');
}
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '添加好友失败';
}
senderProfile.adding = false;
}
async function saveSenderAlias() {
if (!senderProfile.uid || senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.saving = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.updateContactAlias(senderProfile.uid, senderProfile.aliasDraft);
if (ok) {
await contacts.refreshContacts({ force: true }).catch(() => {});
const alias = senderProfile.aliasDraft.trim();
senderProfile.contactAlias = alias;
senderProfile.displayName = alias || senderProfile.groupAlias || senderProfile.rawName || senderProfile.uid;
senderProfile.avatarText = String(senderProfile.displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.notice = '备注名已保存';
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '备注名修改失败';
}
senderProfile.saving = false;
}
function roleBubbleClass(message) {
const role = message?.senderRole || '';
if (role === 'owner') {
return 'role-bubble-owner';
}
if (role === 'admin') {
return 'role-bubble-admin';
}
return '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function retryImageLoad(event) {
const image = event.currentTarget;
if (!(image instanceof HTMLImageElement)) {
return;
}
const attempts = Number(image.dataset.loadRetries || 0);
if (attempts >= 2) {
return;
}
const originalUrl = image.dataset.originalUrl || image.getAttribute('src') || '';
if (!originalUrl) {
return;
}
image.dataset.originalUrl = originalUrl;
image.dataset.loadRetries = String(attempts + 1);
window.setTimeout(() => {
if (!image.isConnected) {
return;
}
const separator = originalUrl.includes('?') ? '&' : '?';
image.src = `${originalUrl}${separator}_image_retry=${attempts + 1}`;
}, 500 * (attempts + 1));
}
2026-06-30 10:13:48 +07:00
function handleMessageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
}
}
function handleImageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
return;
}
openImageViewer(message);
}
function openImageViewer(message) {
const attachment = message?.attachment || {};
if (!attachment.url) {
return;
}
closeMenu();
imageViewer.visible = true;
imageViewer.url = attachment.url;
imageViewer.name = attachment.name || 'image';
resetImageViewer();
}
function closeImageViewer() {
imageViewer.visible = false;
imageViewer.dragging = false;
}
function resetImageViewer() {
imageViewer.scale = 1;
imageViewer.rotation = 0;
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
function clampImageScale(value) {
return Math.min(IMAGE_MAX_SCALE, Math.max(IMAGE_MIN_SCALE, Number(value.toFixed(2))));
}
function setImageScale(value, originX = null, originY = null) {
const previousScale = imageViewer.scale;
const nextScale = clampImageScale(value);
if (nextScale === previousScale) {
return;
}
if (originX !== null && originY !== null && viewerStage.value) {
const rect = viewerStage.value.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const ratio = nextScale / previousScale - 1;
imageViewer.offsetX -= (originX - centerX - imageViewer.offsetX) * ratio;
imageViewer.offsetY -= (originY - centerY - imageViewer.offsetY) * ratio;
}
imageViewer.scale = nextScale;
if (nextScale <= 1) {
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
}
function zoomImage(delta) {
setImageScale(imageViewer.scale + delta);
}
function rotateImage() {
imageViewer.rotation = (imageViewer.rotation + 90) % 360;
}
function handleImageWheel(event) {
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
setImageScale(imageViewer.scale * factor, event.clientX, event.clientY);
}
function startImageDrag(event) {
if (!imageViewer.visible || imageViewer.scale <= 1) {
return;
}
imageViewer.dragging = true;
imageViewer.dragStartX = event.clientX;
imageViewer.dragStartY = event.clientY;
imageViewer.dragOffsetX = imageViewer.offsetX;
imageViewer.dragOffsetY = imageViewer.offsetY;
}
function moveImageDrag(event) {
if (!imageViewer.dragging) {
return;
}
imageViewer.offsetX = imageViewer.dragOffsetX + event.clientX - imageViewer.dragStartX;
imageViewer.offsetY = imageViewer.dragOffsetY + event.clientY - imageViewer.dragStartY;
}
function stopImageDrag() {
imageViewer.dragging = false;
}
function saveViewerImage() {
if (!imageViewer.url) {
return;
}
const downloadUrl = new URL('/api/media_download.php', window.location.origin);
downloadUrl.searchParams.set('url', imageViewer.url);
downloadUrl.searchParams.set('name', imageViewer.name || 'image');
const link = document.createElement('a');
link.href = downloadUrl.toString();
link.download = imageViewer.name || 'image';
document.body.appendChild(link);
link.click();
link.remove();
}
function openMenu(event, message) {
if (!canOpenMessageMenu(message)) {
closeMenu();
return;
}
contextMenu.visible = true;
contextMenu.message = message;
contextMenu.x = Math.min(event.clientX, window.innerWidth - 148);
contextMenu.y = Math.min(event.clientY, window.innerHeight - 352);
}
function closeMenu() {
contextMenu.visible = false;
contextMenu.message = null;
}
function closeMenuOnEscape(event) {
if (event.key === 'Escape') {
if (senderProfile.visible) {
closeSenderProfile();
}
if (imageViewer.visible) {
closeImageViewer();
}
closeMenu();
}
}
function chooseMenuAction(action) {
if (contextMenu.message) {
emit('message-action', { action, message: contextMenu.message });
}
closeMenu();
}
function formatTime(timestamp) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
}).format(timestamp);
}
function deliveryState(message) {
if (message.direction !== 'out' || message.status === 'failed' || message.status === 'recalled' || message.recalled) {
return '';
}
const state = message.deliveryStatus || message.status;
if (state === 'read') {
return 'read';
}
if (state === 'delivered') {
return 'delivered';
}
if (state === 'sent') {
return 'sent';
}
return '';
}
function deliveryTitle(message) {
const state = deliveryState(message);
if (state === 'read') {
return '对方已读';
}
if (state === 'delivered') {
return '已送达对方';
}
if (state === 'sent') {
return '已送出';
}
return '';
}
function failureText(message) {
if (message.status === 'failed') {
if (message.errorCode) {
return message.error ? `失败:${message.error}(${message.errorCode})` : `失败:${message.errorCode}`;
}
return message.error ? `失败:${message.error}` : '失败';
}
return '';
}
</script>