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)"
|
|
|
|
|
|
>
|
2026-07-28 19:43:14 +08:00
|
|
|
|
<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>
|
2026-07-28 19:43:14 +08:00
|
|
|
|
<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"
|
2026-07-28 19:43:14 +08:00
|
|
|
|
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) {
|
2026-07-28 19:43:14 +08:00
|
|
|
|
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 || '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 19:43:14 +08:00
|
|
|
|
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` : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 19:43:14 +08:00
|
|
|
|
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>
|