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

1068 lines
42 KiB
Vue
Raw Normal View History

2026-10-09 22:49:53 +07:00
<template>
<section class="chat-panel">
<header class="chat-header">
<div>
<button
v-if="isGroupConversation"
type="button"
class="chat-title-button"
@click="openGroupOverview"
>
<img v-if="activeConversation?.portrait" class="chat-title-avatar image-avatar" :src="activeConversation.portrait" alt="">
<span v-else class="chat-title-avatar">{{ activeConversation?.avatarText || '?' }}</span>
<span class="chat-title-copy">
<h2>{{ activeConversation?.title || '未选择会话' }}</h2>
</span>
</button>
<button
v-else-if="activeConversation"
type="button"
class="chat-title-button"
@click="openSingleProfile"
>
<img v-if="singleProfile.portrait" class="chat-title-avatar image-avatar" :src="singleProfile.portrait" alt="">
<span v-else class="chat-title-avatar">{{ singleProfile.avatarText }}</span>
<span class="chat-title-copy">
<h2>{{ activeConversation?.title || '未选择会话' }}</h2>
</span>
</button>
<h2 v-else>未选择会话</h2>
<p>{{ statusText }}</p>
</div>
<div class="header-actions chat-header-actions">
<button class="icon-button" title="搜索">⌕</button>
<button class="icon-button" title="更多" @click.stop="toggleGroupMenu">⋯</button>
<div v-if="groupMenuOpen && isGroupConversation" class="quick-action-menu chat-more-menu" @click.stop>
<button type="button" @click="openGroupMembers">查看群员</button>
<button type="button" @click="openGroupAlias">我在本群的昵称</button>
<button v-if="canManageGroup" type="button" @click="openGroupManage">群组管理</button>
</div>
</div>
</header>
<div v-if="!activeConversation" class="empty-chat">
<strong>选择一个会话</strong>
<span>真实会话同步完成后,可以在左侧打开聊天</span>
</div>
<MessageList
v-else
:messages="conversations.activeMessages"
:selection-mode="conversations.selectionMode"
:selected-message-ids="conversations.selectedMessageIds"
:favorite-message-ids="conversations.favoriteMessageIds"
:attention-message-id="conversations.attentionMessageId"
:can-recall-others="canManageGroup && isGroupConversation"
@message-action="conversations.handleMessageAction"
@toggle-select="conversations.toggleSelectedMessage"
@sender-profile-updated="handleSenderProfileUpdated"
/>
<div v-if="conversations.selectionMode" class="selection-toolbar">
<span>已选 {{ conversations.selectedMessageIds.length }} 条</span>
<button type="button" @click="conversations.copySelectedMessages">复制</button>
<button type="button" @click="conversations.deleteSelectedMessages">删除</button>
<button type="button" @click="conversations.startForwardSelectedMessages">转发</button>
<button type="button" @click="conversations.clearSelection">取消</button>
</div>
<MessageComposer
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
:quote-message="conversations.quotedMessage"
@cancel-quote="conversations.cancelQuote"
@send="conversations.sendText"
@send-attachment="conversations.sendAttachment"
/>
<div v-if="conversations.forwardingMessages.length" class="modal-backdrop" @click.self="conversations.cancelForward">
<section class="forward-dialog">
<header>
<h3>转发给</h3>
<button type="button" class="icon-button" title="关闭" @click="conversations.cancelForward">×</button>
</header>
<button
v-for="item in conversations.conversations"
:key="item.id"
type="button"
class="forward-target"
@click="conversations.completeForward(item.id)"
>
<span class="conversation-avatar">{{ item.avatarText }}</span>
<span>{{ item.title }}</span>
</button>
</section>
</div>
<div v-if="conversations.editingMessage" class="modal-backdrop" @click.self="conversations.cancelEditMessage">
<form class="edit-dialog" @submit.prevent="conversations.completeEditMessage">
<header>
<h3>{{ conversations.editingMode === 'resend' ? '重新编辑' : '编辑消息' }}</h3>
<button type="button" class="icon-button" title="关闭" @click="conversations.cancelEditMessage">×</button>
</header>
<textarea
v-model="conversations.editingText"
maxlength="5000"
rows="5"
autofocus
></textarea>
<footer>
<button type="button" @click="conversations.cancelEditMessage">取消</button>
<button type="submit">{{ conversations.editingMode === 'resend' ? '发送' : '保存' }}</button>
</footer>
</form>
</div>
<div v-if="conversations.messageDetailOpen" class="modal-backdrop" @click.self="conversations.closeMessageDetail">
<section class="group-dialog message-detail-dialog">
<header>
<h3>消息详情</h3>
<button type="button" class="icon-button" title="关闭" @click="conversations.closeMessageDetail">×</button>
</header>
<div v-if="conversations.messageDetailLoading" class="group-loading">正在加载</div>
<template v-else>
<dl class="group-profile message-detail-meta">
<div>
<dt>发送时间</dt>
<dd>{{ formatDetailTime(messageDetail?.sentAt) }}</dd>
</div>
<div v-if="messageDetail?.editedAt">
<dt>编辑时间</dt>
<dd>{{ formatDetailTime(messageDetail.editedAt) }}</dd>
</div>
<div>
<dt>发送人</dt>
<dd>{{ detailMemberName(messageDetail?.sender) }}</dd>
</div>
<template v-if="!isMessageDetailGroup">
<div>
<dt>接收人</dt>
<dd>{{ detailMemberName(messageDetail?.receiver) }}</dd>
</div>
<div>
<dt>送达时间</dt>
<dd :class="{ 'message-detail-muted': !messageDetail?.deliveredAt }">
{{ messageDetail?.deliveredAt ? formatDetailTime(messageDetail.deliveredAt) : '未送达' }}
</dd>
</div>
<div>
<dt>已读时间</dt>
<dd :class="{ 'message-detail-muted': !messageDetail?.readAt }">
{{ messageDetail?.readAt ? formatDetailTime(messageDetail.readAt) : '未读' }}
</dd>
</div>
</template>
</dl>
<span v-if="conversations.messageDetailError" class="inline-save-status error">{{ conversations.messageDetailError }}</span>
<template v-if="isMessageDetailGroup">
<div class="message-detail-summary">
<span>已读 <strong>{{ messageDetailCounts.read || 0 }}</strong></span>
<span>未读 <strong>{{ messageDetailCounts.unread || 0 }}</strong></span>
<span>未送达 <strong>{{ messageDetailCounts.undelivered || 0 }}</strong></span>
<span>成员 <strong>{{ messageDetailCounts.total || 0 }}</strong></span>
</div>
<div class="message-detail-groups">
<section class="message-detail-section">
<header>
<strong>已读</strong>
<span>{{ messageDetailCounts.read || 0 }} 人</span>
</header>
<div v-if="messageDetailGroup?.read?.length" class="message-detail-member-list">
<div v-for="member in messageDetailGroup.read" :key="`read-${member.uid}`" class="message-detail-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
<span class="message-detail-member-main">
<strong>{{ detailMemberName(member) }}</strong>
<small>{{ member.uid }}</small>
</span>
<span class="message-detail-member-time">{{ formatDetailTime(member.time) }}</span>
<span v-if="detailRoleLabel(member.role)" class="message-detail-role">{{ detailRoleLabel(member.role) }}</span>
</div>
</div>
<div v-else class="contact-picker-empty">暂无已读成员</div>
</section>
<section class="message-detail-section">
<header>
<strong>未读</strong>
<span>{{ messageDetailCounts.unread || 0 }} 人</span>
</header>
<div v-if="messageDetailGroup?.unread?.length" class="message-detail-member-list">
<div v-for="member in messageDetailGroup.unread" :key="`unread-${member.uid}`" class="message-detail-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
<span class="message-detail-member-main">
<strong>{{ detailMemberName(member) }}</strong>
<small>{{ member.uid }}</small>
</span>
<span class="message-detail-member-time">送达 {{ formatDetailTime(member.time) }}</span>
<span v-if="detailRoleLabel(member.role)" class="message-detail-role">{{ detailRoleLabel(member.role) }}</span>
</div>
</div>
<div v-else class="contact-picker-empty">暂无未读成员</div>
</section>
<section class="message-detail-section">
<header>
<strong>未送达</strong>
<span>{{ messageDetailCounts.undelivered || 0 }} 人</span>
</header>
<div v-if="messageDetailGroup?.undelivered?.length" class="message-detail-member-list">
<div v-for="member in messageDetailGroup.undelivered" :key="`undelivered-${member.uid}`" class="message-detail-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ detailMemberAvatar(member) }}</span>
<span class="message-detail-member-main">
<strong>{{ detailMemberName(member) }}</strong>
<small>{{ member.uid }}</small>
</span>
<span class="message-detail-member-time message-detail-muted">未送达</span>
<span v-if="detailRoleLabel(member.role)" class="message-detail-role">{{ detailRoleLabel(member.role) }}</span>
</div>
</div>
<div v-else class="contact-picker-empty">暂无未送达成员</div>
</section>
</div>
</template>
</template>
</section>
</div>
<div v-if="singleProfileOpen" class="modal-backdrop" @click.self="closeSingleProfile">
<section class="group-dialog single-profile-dialog">
<header>
<h3>联系人资料</h3>
<button type="button" class="icon-button" title="关闭" @click="closeSingleProfile">×</button>
</header>
<div class="single-profile-summary">
<img v-if="singleProfile.portrait" class="profile-avatar image-avatar" :src="singleProfile.portrait" alt="">
<div v-else class="profile-avatar">{{ singleProfile.avatarText }}</div>
<strong>{{ singleProfile.displayName }}</strong>
</div>
<dl class="group-profile">
<div>
<dt>用户ID</dt>
<dd>{{ singleProfile.uid || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ singleProfile.rawName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ singleProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ singleProfile.address || '未知' }}</dd>
</div>
</dl>
<label>
<span>备注名称</span>
<input v-model.trim="singleAliasDraft" maxlength="32" autocomplete="off" placeholder="留空使用对方昵称">
</label>
<footer class="single-profile-footer">
<span v-if="singleProfileNotice" class="inline-save-status">{{ singleProfileNotice }}</span>
<span v-else-if="singleProfileError" class="inline-save-status error">{{ singleProfileError }}</span>
<button type="button" class="ghost-inline-action" :disabled="singleProfileSaving" @click="closeSingleProfile">取消</button>
<button type="button" class="primary-inline-action" :disabled="singleProfileSaving || !singleProfile.uid" @click="saveSingleAlias">
{{ singleProfileSaving ? '保存中...' : '保存备注' }}
</button>
</footer>
</section>
</div>
<div v-if="groupPanel" class="modal-backdrop" @click.self="closeGroupPanel">
<section v-if="groupPanel === 'overview'" class="group-dialog">
<header>
<h3>群概况</h3>
<button type="button" class="icon-button" title="关闭" @click="closeGroupPanel">×</button>
</header>
<div v-if="groupBusy" class="group-loading">正在加载</div>
<dl v-else class="group-profile">
<div>
<dt>群名</dt>
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
</div>
<div>
<dt>群ID</dt>
<dd>{{ groupDetail?.groupId || activeConversation?.target || '-' }}</dd>
</div>
<div>
<dt>群说明</dt>
<dd>{{ groupDetail?.announcement || '未设置' }}</dd>
</div>
<div>
<dt>群主昵称</dt>
<dd>{{ groupDetail?.ownerName || '-' }}</dd>
</div>
</dl>
<footer>
<template v-if="isGroupOwner">
<button type="button" class="danger-inline-action" :disabled="groupBusy" @click="dismissCurrentGroup">解散群组</button>
<button type="button" class="primary-inline-action" :disabled="groupBusy" @click="openTransferGroup">转让群组</button>
</template>
<button v-else type="button" class="danger-inline-action" :disabled="groupBusy" @click="leaveCurrentGroup">退出群组</button>
</footer>
</section>
<section v-else-if="groupPanel === 'members'" class="group-dialog">
<header>
<h3>群员</h3>
<button type="button" class="icon-button" title="关闭" @click="closeGroupPanel">×</button>
</header>
<div v-if="groupBusy" class="group-loading">正在加载</div>
<div v-else class="group-member-list">
<div v-for="member in groupMembers" :key="member.uid" class="group-member-row">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
<small>{{ member.role === 'owner' ? '群主' : member.rawDisplayName }}</small>
</span>
</div>
<div v-if="!groupMembers.length" class="contact-picker-empty">暂无群员</div>
</div>
</section>
<form v-else-if="groupPanel === 'alias'" class="group-dialog" @submit.prevent="saveGroupAlias">
<header>
<h3>我在本群的昵称</h3>
<button type="button" class="icon-button" title="关闭" @click="closeGroupPanel">×</button>
</header>
<label>
<span>昵称</span>
<input v-model.trim="groupAliasDraft" maxlength="64" autocomplete="off" placeholder="留空使用个人昵称">
</label>
<footer>
<button type="button" class="ghost-inline-action" @click="closeGroupPanel">取消</button>
<button class="primary-inline-action" :disabled="groupBusy">{{ groupBusy ? '保存中...' : '保存' }}</button>
</footer>
</form>
<section v-else-if="groupPanel === 'transfer'" class="group-dialog">
<header>
<h3>转让群组</h3>
<button type="button" class="icon-button" title="关闭" @click="closeGroupPanel">×</button>
</header>
<div v-if="groupBusy" class="group-loading">正在加载</div>
<div v-else class="group-member-list">
<button
v-for="member in transferableMembers"
:key="member.uid"
type="button"
class="group-member-row selectable"
:class="{ selected: transferTargetId === member.uid }"
@click="transferTargetId = member.uid"
>
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
<small>{{ member.rawDisplayName }}</small>
</span>
</button>
<div v-if="!transferableMembers.length" class="contact-picker-empty">暂无可转让成员</div>
</div>
<footer>
<button type="button" class="ghost-inline-action" @click="closeGroupPanel">取消</button>
<button type="button" class="primary-inline-action" :disabled="groupBusy || !transferTargetId" @click="transferCurrentGroup">确认转让</button>
</footer>
</section>
<section v-else-if="groupPanel === 'manage'" class="group-dialog group-management-dialog">
<header>
<h3>群组管理</h3>
<button type="button" class="icon-button" title="关闭" @click="closeGroupPanel">×</button>
</header>
<nav class="group-management-tabs">
<button v-if="isGroupOwner" type="button" :class="{ active: manageSection === 'admins' }" @click="manageSection = 'admins'">管理员设置</button>
<button type="button" :class="{ active: manageSection === 'profile' }" @click="manageSection = 'profile'">修改群资料</button>
<button type="button" :class="{ active: manageSection === 'kick' }" @click="manageSection = 'kick'">踢人</button>
<button type="button" :class="{ active: manageSection === 'mute' }" @click="manageSection = 'mute'">禁言设置</button>
</nav>
<div v-if="groupBusy" class="group-loading">正在加载</div>
<section v-else-if="manageSection === 'admins'" class="group-manage-section">
<div class="group-member-list">
<label
v-for="member in adminCandidateMembers"
:key="member.uid"
class="group-member-row selectable with-check"
>
<input type="checkbox" :checked="selectedAdminIds.includes(member.uid)" @change="toggleAdmin(member.uid)">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
<small>{{ member.rawDisplayName }}</small>
</span>
</label>
<div v-if="!adminCandidateMembers.length" class="contact-picker-empty">暂无可设置成员</div>
</div>
<footer>
<button type="button" class="primary-inline-action" @click="saveGroupAdmins">保存管理员</button>
</footer>
</section>
<section v-else-if="manageSection === 'profile'" class="group-manage-section">
<label>
<span>群名称</span>
<input v-model.trim="profileDraft.name" maxlength="128" autocomplete="off">
</label>
<label>
<span>群说明</span>
<textarea v-model.trim="profileDraft.announcement" maxlength="1000" rows="3"></textarea>
</label>
<label>
<span>欢迎词</span>
<textarea v-model.trim="profileDraft.welcomeText" maxlength="1000" rows="3"></textarea>
</label>
<footer>
<button type="button" class="primary-inline-action" @click="saveGroupProfile">保存资料</button>
</footer>
</section>
<section v-else-if="manageSection === 'kick'" class="group-manage-section">
<div class="group-member-list">
<label
v-for="member in kickCandidateMembers"
:key="member.uid"
class="group-member-row selectable with-check"
>
<input type="checkbox" :checked="selectedKickIds.includes(member.uid)" @change="toggleKick(member.uid)">
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
<span>
<strong>{{ member.displayName }}</strong>
<small>{{ member.role === 'admin' ? '管理员' : member.rawDisplayName }}</small>
</span>
</label>
<div v-if="!kickCandidateMembers.length" class="contact-picker-empty">暂无可移除成员</div>
</div>
<footer>
<button type="button" class="danger-inline-action" :disabled="!selectedKickIds.length" @click="kickSelectedMembers">移出群组</button>
</footer>
</section>
<section v-else-if="manageSection === 'mute'" class="group-manage-section">
<label>
<span>禁言模式</span>
<select v-model="muteDraft.mode" @change="clearMuteSaveFeedback">
<option value="off">关闭禁言</option>
<option value="all_include_admins">全体禁言包含管理员</option>
<option value="all_except_admins">除管理员外全体禁言</option>
</select>
</label>
<label>
<span>时段类型</span>
<select v-model="muteDraft.scheduleType" @change="clearMuteSaveFeedback">
<option value="manual">立即禁言直到管理解除</option>
<option value="once">单次</option>
<option value="daily">每天固定时段</option>
</select>
</label>
<div v-if="muteDraft.mode !== 'off' && muteDraft.scheduleType === 'once'" class="mute-time-grid">
<label>
<span>开始</span>
<input v-model="muteDraft.startsAtLocal" type="datetime-local" @input="clearMuteSaveFeedback">
</label>
<label>
<span>结束</span>
<input v-model="muteDraft.endsAtLocal" type="datetime-local" @input="clearMuteSaveFeedback">
</label>
</div>
<div v-else-if="muteDraft.mode !== 'off' && muteDraft.scheduleType === 'daily'" class="mute-time-grid">
<label>
<span>开始</span>
<input v-model="muteDraft.dailyStart" type="time" @input="clearMuteSaveFeedback">
</label>
<label>
<span>结束</span>
<input v-model="muteDraft.dailyEnd" type="time" @input="clearMuteSaveFeedback">
</label>
</div>
<footer class="mute-save-footer">
<span v-if="muteSaveNotice" class="inline-save-status">{{ muteSaveNotice }}</span>
<span v-else-if="muteSaveError" class="inline-save-status error">{{ muteSaveError }}</span>
<button type="button" class="primary-inline-action" :disabled="groupBusy" @click="saveMuteSetting">保存禁言</button>
</footer>
</section>
</section>
</div>
<div v-if="conversations.actionNotice || conversations.actionError" class="chat-toast" :class="{ error: conversations.actionError }">
{{ conversations.actionError || conversations.actionNotice }}
</div>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import MessageList from './MessageList.vue';
import MessageComposer from './MessageComposer.vue';
import { imClient } from '@/services/imClient';
import { useConversationStore } from '@/stores/conversations';
import { useContactStore } from '@/stores/contacts';
import { useSessionStore } from '@/stores/session';
const conversations = useConversationStore();
const contacts = useContactStore();
const session = useSessionStore();
const activeConversation = computed(() => conversations.activeConversation);
const groupMenuOpen = ref(false);
const singleProfileOpen = ref(false);
const singleAliasDraft = ref('');
const singleProfileSaving = ref(false);
const singleProfileNotice = ref('');
const singleProfileError = ref('');
const groupPanel = ref('');
const groupBusy = ref(false);
const groupDetail = ref(null);
const groupMembers = ref([]);
const groupAliasDraft = ref('');
const manageSection = ref('');
const selectedAdminIds = ref([]);
const selectedKickIds = ref([]);
const transferTargetId = ref('');
const profileDraft = ref({
name: '',
announcement: '',
welcomeText: ''
});
const muteDraft = ref({
mode: 'off',
scheduleType: 'once',
startsAtLocal: '',
endsAtLocal: '',
dailyStart: '09:00',
dailyEnd: '18:00'
});
const muteSaveNotice = ref('');
const muteSaveError = ref('');
const singlePeerId = computed(() => {
if (!activeConversation.value || isGroupConversation.value) {
return '';
}
return String(activeConversation.value.target || activeConversation.value.raw?.target || activeConversation.value.id?.replace(/^single-/, '') || '');
});
const singleContact = computed(() => {
const peerId = singlePeerId.value;
if (!peerId) {
return null;
}
return contacts.contacts.find(contact => String(contact.uid || contact.id || '') === peerId) || null;
});
const singleProfile = computed(() => {
const contact = singleContact.value;
const conversation = activeConversation.value || {};
const rawUser = contact?.rawUser || {};
const displayName = contact?.alias
|| rawUser.displayName
|| rawUser.name
|| contact?.title
|| conversation.title
|| singlePeerId.value
|| '未知用户';
const rawName = rawUser.displayName || rawUser.name || contact?.title || conversation.title || '';
return {
uid: singlePeerId.value,
displayName,
rawName,
mobile: rawUser.mobile || conversation.raw?.mobile || '',
address: rawUser.address || conversation.raw?.address || '',
portrait: contact?.portrait || conversation.portrait || '',
avatarText: String(displayName || '?').slice(0, 1).toUpperCase(),
alias: contact?.alias || ''
};
});
const isGroupConversation = computed(() => {
return ['group', 'channel'].includes(activeConversation.value?.type)
|| activeConversation.value?.raw?.type === 'group';
});
const currentGroupRole = computed(() => {
return groupDetail.value?.myRole || activeConversation.value?.role || activeConversation.value?.raw?.role || '';
});
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
const messageDetail = computed(() => conversations.messageDetail || null);
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
const isMessageDetailGroup = computed(() => {
return messageDetail.value?.conversationType === 'group' || Boolean(messageDetail.value?.group);
});
const messageDetailCounts = computed(() => {
return messageDetailGroup.value?.counts || {
read: 0,
unread: 0,
undelivered: 0,
total: 0
};
});
const kickCandidateMembers = computed(() => {
return groupMembers.value.filter(member => {
if (member.uid === session.userId || member.role === 'owner') {
return false;
}
if (currentGroupRole.value === 'admin') {
return member.role === 'member';
}
return currentGroupRole.value === 'owner';
});
});
const statusText = computed(() => {
if (activeConversation.value?.type === 'channel' && !conversations.canSendActiveConversation) {
return '公告群,仅管理员可发言';
}
return session.connectionStatusText || (session.connected ? '已连接' : '未连接');
});
function formatDetailTime(value) {
const timestamp = Number(value || 0);
if (!timestamp) {
return '暂无';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
}).format(timestamp);
}
function detailMemberName(member) {
return member?.name || member?.displayName || member?.rawName || member?.uid || '-';
}
function detailMemberAvatar(member) {
return String(detailMemberName(member) || '?').slice(0, 1).toUpperCase();
}
function detailRoleLabel(role) {
if (role === 'owner') {
return '群主';
}
if (role === 'admin') {
return '管理员';
}
return '';
}
function closeGroupMenu() {
groupMenuOpen.value = false;
}
function toggleGroupMenu() {
if (!isGroupConversation.value) {
return;
}
groupMenuOpen.value = !groupMenuOpen.value;
}
async function openSingleProfile() {
if (!activeConversation.value || isGroupConversation.value) {
return;
}
closeGroupMenu();
singleProfileNotice.value = '';
singleProfileError.value = '';
singleAliasDraft.value = singleProfile.value.alias || '';
singleProfileOpen.value = true;
contacts.refreshContacts().then(() => {
if (singleProfileOpen.value) {
singleAliasDraft.value = singleProfile.value.alias || '';
}
}).catch(() => {});
}
function closeSingleProfile() {
if (singleProfileSaving.value) {
return;
}
singleProfileOpen.value = false;
singleProfileNotice.value = '';
singleProfileError.value = '';
}
async function saveSingleAlias() {
const peerId = singleProfile.value.uid;
if (!peerId || singleProfileSaving.value) {
return;
}
singleProfileSaving.value = true;
singleProfileNotice.value = '';
singleProfileError.value = '';
try {
const ok = await contacts.updateContactAlias(peerId, singleAliasDraft.value);
if (!ok) {
singleProfileError.value = contacts.actionError || '备注名修改失败';
return;
}
await contacts.refreshContacts({ force: true }).catch(() => {});
const refreshedContact = contacts.contacts.find(contact => String(contact.uid || contact.id || '') === peerId) || singleContact.value;
const nextTitle = refreshedContact?.title
|| (singleAliasDraft.value || singleProfile.value.rawName || singleProfile.value.displayName || peerId);
conversations.upsertConversation({
...activeConversation.value,
title: nextTitle,
portrait: refreshedContact?.portrait || activeConversation.value?.portrait || '',
avatarText: String(nextTitle || '?').slice(0, 1).toUpperCase(),
raw: {
...(activeConversation.value?.raw || {}),
target: peerId,
mobile: refreshedContact?.rawUser?.mobile || activeConversation.value?.raw?.mobile || '',
address: refreshedContact?.rawUser?.address || activeConversation.value?.raw?.address || '',
alias: refreshedContact?.alias || singleAliasDraft.value
}
});
singleAliasDraft.value = refreshedContact?.alias || singleAliasDraft.value;
singleProfileNotice.value = '备注名已保存';
} catch (error) {
singleProfileError.value = error.message || '备注名修改失败';
} finally {
singleProfileSaving.value = false;
}
}
function handleSenderProfileUpdated() {
contacts.refreshContacts({ force: true }).catch(() => {});
conversations.syncActiveMessages({ allowReadReceipt: true });
if (groupPanel.value === 'members') {
loadGroupMembers();
}
}
function closeGroupPanel() {
groupPanel.value = '';
groupBusy.value = false;
clearMuteSaveFeedback();
}
async function loadGroupDetail() {
if (!session.userId || !activeConversation.value?.target) {
return null;
}
groupBusy.value = true;
try {
groupDetail.value = await imClient.getGroupDetail(session.userId, activeConversation.value.target);
return groupDetail.value;
} catch (error) {
conversations.setActionError(error.message || '群资料加载失败');
return null;
} finally {
groupBusy.value = false;
}
}
async function openGroupOverview() {
if (!isGroupConversation.value) {
return;
}
closeGroupMenu();
groupPanel.value = 'overview';
await loadGroupDetail();
}
async function openGroupMembers() {
closeGroupMenu();
groupPanel.value = 'members';
await loadGroupMembers();
}
async function loadGroupMembers() {
groupBusy.value = true;
try {
groupMembers.value = await imClient.getGroupMembers(session.userId, activeConversation.value.target);
selectedAdminIds.value = groupMembers.value
.filter(member => member.role === 'admin')
.map(member => member.uid);
return groupMembers.value;
} catch (error) {
conversations.setActionError(error.message || '群员加载失败');
return [];
} finally {
groupBusy.value = false;
}
}
async function openGroupAlias() {
closeGroupMenu();
groupPanel.value = 'alias';
const detail = await loadGroupDetail();
groupAliasDraft.value = detail?.myAlias || '';
}
function msToLocalInput(value) {
const timestamp = Number(value || 0);
if (!timestamp) {
return '';
}
const date = new Date(timestamp);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
}
function localInputToMs(value) {
return value ? new Date(value).getTime() : 0;
}
function prepareManagementDrafts() {
clearMuteSaveFeedback();
profileDraft.value = {
name: groupDetail.value?.name || activeConversation.value?.title || '',
announcement: groupDetail.value?.announcement || '',
welcomeText: groupDetail.value?.welcomeText || ''
};
const setting = groupDetail.value?.muteSetting || {};
muteDraft.value = {
mode: setting.mode || 'off',
scheduleType: setting.scheduleType || 'once',
startsAtLocal: msToLocalInput(setting.startsAtMs),
endsAtLocal: msToLocalInput(setting.endsAtMs),
dailyStart: setting.dailyStart || '09:00',
dailyEnd: setting.dailyEnd || '18:00'
};
}
function clearMuteSaveFeedback() {
muteSaveNotice.value = '';
muteSaveError.value = '';
}
async function openGroupManage() {
if (!canManageGroup.value) {
return;
}
closeGroupMenu();
groupPanel.value = 'manage';
manageSection.value = isGroupOwner.value ? 'admins' : 'profile';
await loadGroupDetail();
prepareManagementDrafts();
await loadGroupMembers();
}
async function openTransferGroup() {
groupPanel.value = 'transfer';
transferTargetId.value = '';
await loadGroupMembers();
}
async function saveGroupAlias() {
if (!activeConversation.value?.target || groupBusy.value) {
return;
}
groupBusy.value = true;
try {
const alias = await imClient.updateGroupAlias(session.userId, activeConversation.value.target, groupAliasDraft.value);
groupDetail.value = {
...(groupDetail.value || {}),
myAlias: alias
};
conversations.setActionNotice('本群昵称已保存');
closeGroupPanel();
} catch (error) {
conversations.setActionError(error.message || '本群昵称保存失败');
} finally {
groupBusy.value = false;
}
}
function toggleAdmin(userId) {
const selected = new Set(selectedAdminIds.value);
if (selected.has(userId)) {
selected.delete(userId);
} else {
selected.add(userId);
}
selectedAdminIds.value = Array.from(selected);
}
function toggleKick(userId) {
const selected = new Set(selectedKickIds.value);
if (selected.has(userId)) {
selected.delete(userId);
} else {
selected.add(userId);
}
selectedKickIds.value = Array.from(selected);
}
async function saveGroupAdmins() {
if (!activeConversation.value?.target || groupBusy.value) {
return;
}
groupBusy.value = true;
try {
await imClient.setGroupAdmins(session.userId, activeConversation.value.target, selectedAdminIds.value);
groupMembers.value = groupMembers.value.map(member => {
if (member.role === 'owner') {
return member;
}
return {
...member,
role: selectedAdminIds.value.includes(member.uid) ? 'admin' : 'member'
};
});
conversations.setActionNotice('管理员已更新');
} catch (error) {
conversations.setActionError(error.message || '管理员设置失败');
} finally {
groupBusy.value = false;
}
}
async function saveGroupProfile() {
if (!activeConversation.value?.target || groupBusy.value) {
return;
}
groupBusy.value = true;
try {
const result = await imClient.updateGroupProfile(session.userId, activeConversation.value.target, profileDraft.value);
groupDetail.value = {
...(groupDetail.value || {}),
name: result.name,
announcement: result.announcement,
welcomeText: result.welcomeText
};
conversations.upsertConversation({
...activeConversation.value,
title: result.name,
avatarText: String(result.name || '?').slice(0, 1).toUpperCase(),
updatedAt: Number(result.updatedAt || Date.now())
});
conversations.setActionNotice('群资料已保存');
} catch (error) {
conversations.setActionError(error.message || '群资料保存失败');
} finally {
groupBusy.value = false;
}
}
async function kickSelectedMembers() {
if (!selectedKickIds.value.length || groupBusy.value) {
return;
}
if (!window.confirm('确认将所选成员移出群组?')) {
return;
}
groupBusy.value = true;
try {
const result = await imClient.kickGroupMembers(session.userId, activeConversation.value.target, selectedKickIds.value);
const removed = new Set(result.removedIds || selectedKickIds.value);
groupMembers.value = groupMembers.value.filter(member => !removed.has(member.uid));
selectedKickIds.value = [];
conversations.setActionNotice('已移出群组');
} catch (error) {
conversations.setActionError(error.message || '踢人失败');
} finally {
groupBusy.value = false;
}
}
async function saveMuteSetting() {
if (!activeConversation.value?.target || groupBusy.value) {
return;
}
clearMuteSaveFeedback();
groupBusy.value = true;
try {
const setting = await imClient.updateGroupMuteSetting(session.userId, activeConversation.value.target, {
mode: muteDraft.value.mode,
scheduleType: muteDraft.value.scheduleType,
startsAtMs: localInputToMs(muteDraft.value.startsAtLocal),
endsAtMs: localInputToMs(muteDraft.value.endsAtLocal),
dailyStart: muteDraft.value.dailyStart,
dailyEnd: muteDraft.value.dailyEnd
});
groupDetail.value = {
...(groupDetail.value || {}),
muteSetting: setting
};
muteSaveNotice.value = '禁言设置已保存';
} catch (error) {
muteSaveError.value = error.message || '禁言设置保存失败';
} finally {
groupBusy.value = false;
}
}
async function leaveCurrentGroup() {
if (!activeConversation.value || groupBusy.value) {
return;
}
if (!window.confirm('确认退出该群组?')) {
return;
}
groupBusy.value = true;
const ok = await conversations.leaveGroup(activeConversation.value);
groupBusy.value = false;
if (ok) {
closeGroupPanel();
}
}
async function dismissCurrentGroup() {
if (!activeConversation.value || groupBusy.value) {
return;
}
if (!window.confirm('确认解散该群组?所有成员都将不可继续使用该群。')) {
return;
}
groupBusy.value = true;
const ok = await conversations.dismissGroup(activeConversation.value);
groupBusy.value = false;
if (ok) {
closeGroupPanel();
}
}
async function transferCurrentGroup() {
if (!activeConversation.value?.target || !transferTargetId.value || groupBusy.value) {
return;
}
if (!window.confirm('确认将群主转让给该成员?')) {
return;
}
groupBusy.value = true;
try {
await imClient.transferGroup(session.userId, activeConversation.value.target, transferTargetId.value);
groupDetail.value = {
...(groupDetail.value || {}),
myRole: 'member'
};
activeConversation.value.role = 'member';
if (activeConversation.value.raw) {
activeConversation.value.raw.role = 'member';
}
conversations.setActionNotice('群组已转让');
closeGroupPanel();
conversations.refreshRemoteConversations();
} catch (error) {
conversations.setActionError(error.message || '转让群组失败');
} finally {
groupBusy.value = false;
}
}
function memberAvatar(member) {
return String(member.displayName || member.uid || '?').slice(0, 1).toUpperCase();
}
document.addEventListener('click', closeGroupMenu);
onBeforeUnmount(() => {
document.removeEventListener('click', closeGroupMenu);
});
watch(
() => activeConversation.value?.id,
() => {
closeGroupMenu();
closeSingleProfile();
closeGroupPanel();
groupDetail.value = null;
groupMembers.value = [];
groupAliasDraft.value = '';
selectedAdminIds.value = [];
selectedKickIds.value = [];
transferTargetId.value = '';
manageSection.value = '';
}
);
</script>