1068 lines
42 KiB
Vue
1068 lines
42 KiB
Vue
<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>
|