Files
IM-web-new/src/components/chat/ChatPanel.vue
T
2026-10-09 22:49:53 +07:00

1068 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>