Initial Web IM deployment
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
<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 || '图片'">
|
||||
</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="message.text">{{ message.text }}</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="imageViewer.url"
|
||||
:alt="imageViewer.name || '图片'"
|
||||
:style="imageViewerStyle"
|
||||
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) {
|
||||
listEl.value.scrollTop = listEl.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 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 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>
|
||||
Reference in New Issue
Block a user