822 lines
25 KiB
Vue
822 lines
25 KiB
Vue
<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="imageDisplayUrl(message.attachment.url)"
|
||
:alt="message.attachment.name || '图片'"
|
||
loading="lazy"
|
||
decoding="async"
|
||
@error="retryImageLoad"
|
||
>
|
||
</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>
|
||
<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="imageDisplayUrl(imageViewer.url)"
|
||
:alt="imageViewer.name || '图片'"
|
||
:style="imageViewerStyle"
|
||
decoding="async"
|
||
@error="retryImageLoad"
|
||
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';
|
||
import { imageDisplayUrl } from '@/services/mediaProxy';
|
||
|
||
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 messageTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
||
});
|
||
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(() => {
|
||
requestAnimationFrame(() => {
|
||
if (listEl.value) {
|
||
listEl.value.scrollTop = listEl.value.scrollHeight;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function isNearBottom() {
|
||
const element = listEl.value;
|
||
if (!element) {
|
||
return true;
|
||
}
|
||
return element.scrollHeight - element.scrollTop - element.clientHeight < 120;
|
||
}
|
||
|
||
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(messageIdentity).join('|'),
|
||
(nextKeys, previousKeys) => {
|
||
if (!previousKeys || (nextKeys !== previousKeys && isNearBottom())) {
|
||
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;
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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 messageTimeFormatter.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>
|