feat(Talk): 成员支持移出群聊/转让群主,新增群公告展示与编辑

成员「更多」菜单按权限显示:设为/取消管理员(群主/Talk 管理员)、转让群主(群主/Talk 管理员,二次确认)、移出群聊(群主可移出任意成员,群管理员仅普通成员,二次确认)。

群聊会话顶部展示群公告横幅(群主/管理员可直接编辑),群菜单新增「群公告」入口,抽屉内可编辑或清空。
This commit is contained in:
TermiNexus committed 2026-10-13 16:38:52 +08:00
1 parent 899dfbc8e9
commit 04535d2dc0
2 files changed
+253 -6

No files matched your search

+34
View File
@@ -89,6 +89,10 @@ export interface TalkConversation {
AdminCount?: number
/** 当前用户是否可管理群信息(改群名等):群主或群管理员 */
CanManage?: boolean
/** 群公告(仅群聊,空串表示未设置) */
Announcement?: string
/** 群公告更新时间 */
AnnouncementTime?: string
}
/** 会话列表返回 */
@@ -206,6 +210,36 @@ export function dissolveTalkConversation(data: { ConversationUUID: string }): Pr
return post<void>(`${TALK_BASE}/Conversation/Dissolve`, data)
}
/** 移出群成员(群主可移出任何人,群管理员仅可移出普通成员) */
export function removeTalkMembers(data: {
ConversationUUID: string
MemberUUIDs: string[]
}): Promise<{ Removed: string[]; MemberCount: number }> {
return post<{ Removed: string[]; MemberCount: number }>(
`${TALK_BASE}/Conversation/Member/Remove`,
data,
)
}
/** 转让群主(原群主转为群管理员;仅群主或 Talk 管理员) */
export function transferTalkOwner(data: {
ConversationUUID: string
UserUUID: string
}): Promise<{ OwnerUUID: string; OwnerName: string }> {
return post<{ OwnerUUID: string; OwnerName: string }>(`${TALK_BASE}/Conversation/Transfer`, data)
}
/** 设置群公告(群主/群管理员;传空串即清空) */
export function setTalkAnnouncement(data: {
ConversationUUID: string
Announcement: string
}): Promise<{ Announcement: string; AnnouncementTime: string | null }> {
return post<{ Announcement: string; AnnouncementTime: string | null }>(
`${TALK_BASE}/Conversation/Announcement`,
data,
)
}
/** 消息分页(BeforeTime 取更早一页) */
export function getTalkMessages(params: {
ConversationUUID: string
+219 -6
View File
@@ -28,6 +28,9 @@ import {
renameTalkConversation,
dissolveTalkConversation,
setTalkMemberRole,
removeTalkMembers,
transferTalkOwner,
setTalkAnnouncement,
searchTalkMembers,
resolveTalkAttachmentUrl,
resolveTalkAttachmentDownloadUrl,
@@ -95,6 +98,9 @@ const InviteSubmitting = ref(false)
const RenameVisible = ref(false)
const RenameTitle = ref('')
const RenameSubmitting = ref(false)
const AnnouncementVisible = ref(false)
const AnnouncementText = ref('')
const AnnouncementSubmitting = ref(false)
// === 实时状态 ===
const SelfUUID = ref('')
@@ -144,6 +150,34 @@ const AdminLimit = computed(() => Entry.value?.GroupAdminLimit || 10)
/** 该群当前管理员数量 */
const AdminCount = computed(() => ActiveConversation.value?.AdminCount || 0)
/** 是否有权编辑群公告:Talk 管理员,或该群群主/群管理员 */
const CanManageAnnouncement = computed(
() => Entry.value?.IsAdmin === true || ActiveConversation.value?.CanManage === true,
)
/** 是否有权转让群主:仅当前群主或 Talk 管理员 */
const CanTransferOwner = computed(
() => Entry.value?.IsAdmin === true || ActiveConversation.value?.IsOwner === true,
)
/** 是否有权移出该成员:群主/Talk 管理员可移出任何人(群主除外);群管理员仅可移出普通成员 */
function CanRemoveMember(member: TalkMemberBrief): boolean {
const conversation = ActiveConversation.value
if (!conversation || conversation.ConversationType !== TalkConversationType.Group) return false
if (member.UserUUID === MyUUID.value) return false
if (member.MemberRole === TalkMemberRole.Owner) return false
if (Entry.value?.IsAdmin === true) return true
if (conversation.IsOwner === true) return true
return conversation.IsGroupAdmin === true && member.MemberRole !== TalkMemberRole.Admin
}
/** 是否展示该成员的「更多」操作入口(管理动作任一可用即可) */
function CanShowMemberActions(member: TalkMemberBrief): boolean {
if (member.UserUUID === MyUUID.value) return false
if (member.MemberRole === TalkMemberRole.Owner) return false
return CanManageMembers.value || CanTransferOwner.value || CanRemoveMember(member)
}
/** 过滤后的会话列表 */
const FilteredConversations = computed(() => {
const keyword = ConversationKeyword.value.trim().toLowerCase()
@@ -547,14 +581,23 @@ async function UploadAndSend(file: File): Promise<void> {
function HandleGroupCommand(command: string): void {
if (command === 'rename') OpenRename()
else if (command === 'invite') OpenInvite()
else if (command === 'announcement') OpenAnnouncement()
else if (command === 'leave') void LeaveGroup()
else if (command === 'dissolve') void DissolveGroup()
}
/** 成员操作:设为/取消群管理员(仅群主或 Talk 管理员,单群上限由后端下发) */
/** 成员操作分发:设为/取消管理员、移出群聊、转让群主 */
async function HandleMemberCommand(command: string, member: TalkMemberBrief): Promise<void> {
const conversation = ActiveConversation.value
if (!conversation) return
if (command === 'remove') {
await RemoveMember(conversation, member)
return
}
if (command === 'transfer') {
await TransferOwner(conversation, member)
return
}
const role = command === 'setAdmin' ? TalkMemberRole.Admin : TalkMemberRole.Member
if (role === TalkMemberRole.Admin && AdminCount.value >= AdminLimit.value) {
ElMessage.warning(`每个群最多设置 ${AdminLimit.value} 个管理员`)
@@ -574,6 +617,90 @@ async function HandleMemberCommand(command: string, member: TalkMemberBrief): Pr
}
}
/** 移出群成员(二次确认) */
async function RemoveMember(
conversation: TalkConversation,
member: TalkMemberBrief,
): Promise<void> {
try {
await ElMessageBox.confirm(
`确定将「${MemberName(member)}」移出群聊吗?`,
'移出群聊',
{ type: 'warning', confirmButtonText: '移出', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await removeTalkMembers({
ConversationUUID: conversation.ConversationUUID,
MemberUUIDs: [member.UserUUID],
})
ElMessage.success('已移出群聊')
await LoadConversations()
} catch {
// 业务错误已由响应拦截器统一提示
}
}
/** 转让群主(二次确认;转让后本人转为群管理员) */
async function TransferOwner(
conversation: TalkConversation,
member: TalkMemberBrief,
): Promise<void> {
try {
await ElMessageBox.confirm(
`确定将群主转让给「${MemberName(member)}」吗?转让后你将成为群管理员。`,
'转让群主',
{ type: 'warning', confirmButtonText: '转让', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await transferTalkOwner({
ConversationUUID: conversation.ConversationUUID,
UserUUID: member.UserUUID,
})
ElMessage.success('群主已转让')
await LoadConversations()
} catch {
// 业务错误已由响应拦截器统一提示
}
}
/** 打开群公告编辑 */
function OpenAnnouncement(): void {
if (!ActiveConversation.value) return
if (!CanManageAnnouncement.value) {
ElMessage.warning('仅群主或管理员可编辑群公告')
return
}
AnnouncementText.value = ActiveConversation.value.Announcement || ''
AnnouncementVisible.value = true
}
/** 提交群公告(清空即取消公告) */
async function SubmitAnnouncement(): Promise<void> {
const conversation = ActiveConversation.value
if (!conversation) return
AnnouncementSubmitting.value = true
try {
const result = await setTalkAnnouncement({
ConversationUUID: conversation.ConversationUUID,
Announcement: AnnouncementText.value.trim(),
})
conversation.Announcement = result?.Announcement || ''
conversation.AnnouncementTime = result?.AnnouncementTime || ''
ElMessage.success(conversation.Announcement ? '群公告已更新' : '群公告已清空')
AnnouncementVisible.value = false
} catch {
// 业务错误已由响应拦截器统一提示
} finally {
AnnouncementSubmitting.value = false
}
}
/** 打开改群名 */
function OpenRename(): void {
if (!ActiveConversation.value) return
@@ -899,6 +1026,9 @@ onBeforeUnmount(() => {
<el-dropdown-item v-if="CanManageGroup" command="rename">
<el-icon><EditPen /></el-icon>修改群名
</el-dropdown-item>
<el-dropdown-item v-if="CanManageAnnouncement" command="announcement">
<el-icon><Bell /></el-icon>群公告
</el-dropdown-item>
<el-dropdown-item command="invite">
<el-icon><Plus /></el-icon>邀请成员
</el-dropdown-item>
@@ -914,6 +1044,18 @@ onBeforeUnmount(() => {
</div>
</header>
<!-- 群公告(群聊且有公告时展示;群主/管理员可直接编辑) -->
<div v-if="IsGroupChat && ActiveConversation.Announcement" class="talk-announcement">
<el-icon class="talk-announcement-icon"><Bell /></el-icon>
<div class="talk-announcement-body">
<div class="talk-announcement-title">群公告</div>
<div class="talk-announcement-text">{{ ActiveConversation.Announcement }}</div>
</div>
<el-button v-if="CanManageAnnouncement" text size="small" @click="OpenAnnouncement">
<el-icon><EditPen /></el-icon>
</el-button>
</div>
<div class="talk-messages-wrap">
<el-scrollbar ref="ScrollbarRef" @scroll="OnScrollbarScroll">
<div v-loading="MessagesLoading" class="talk-messages">
@@ -1035,7 +1177,7 @@ onBeforeUnmount(() => {
</el-tag>
<el-tag v-if="member.UserUUID === MyUUID" size="small" type="info">我</el-tag>
<el-dropdown
v-if="CanManageMembers && member.MemberRole !== TalkMemberRole.Owner && member.UserUUID !== MyUUID"
v-if="CanShowMemberActions(member)"
trigger="click"
@command="(command: string) => HandleMemberCommand(command, member)"
>
@@ -1044,11 +1186,21 @@ onBeforeUnmount(() => {
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="member.MemberRole !== TalkMemberRole.Admin" command="setAdmin">
<el-icon><Star /></el-icon>设为管理员
<el-dropdown-item
v-if="CanManageMembers"
:command="member.MemberRole !== TalkMemberRole.Admin ? 'setAdmin' : 'unsetAdmin'"
>
<el-icon>
<StarFilled v-if="member.MemberRole === TalkMemberRole.Admin" />
<Star v-else />
</el-icon>
{{ member.MemberRole === TalkMemberRole.Admin ? '取消管理员' : '设为管理员' }}
</el-dropdown-item>
<el-dropdown-item v-else command="unsetAdmin">
<el-icon><StarFilled /></el-icon>取消管理员
<el-dropdown-item v-if="CanTransferOwner" command="transfer">
<el-icon><Switch /></el-icon>转让群主
</el-dropdown-item>
<el-dropdown-item v-if="CanRemoveMember(member)" divided command="remove">
<el-icon><CircleClose /></el-icon>移出群聊
</el-dropdown-item>
</el-dropdown-menu>
</template>
@@ -1095,6 +1247,30 @@ onBeforeUnmount(() => {
<el-button type="primary" :loading="RenameSubmitting" @click="SubmitRename">保存</el-button>
</template>
</el-drawer>
<!-- 群公告 -->
<el-drawer
v-model="AnnouncementVisible"
title="群公告"
direction="rtl"
:size="460"
:close-on-click-modal="false"
>
<el-input
v-model="AnnouncementText"
type="textarea"
:rows="8"
maxlength="2000"
show-word-limit
placeholder="填写群公告,群内成员将在会话顶部看到;清空并保存即取消公告"
/>
<template #footer>
<el-button @click="AnnouncementVisible = false">取消</el-button>
<el-button type="primary" :loading="AnnouncementSubmitting" @click="SubmitAnnouncement">
保存
</el-button>
</template>
</el-drawer>
</div>
</template>
@@ -1281,6 +1457,43 @@ onBeforeUnmount(() => {
background: var(--el-bg-color-page);
}
/* ---------- 群公告 ---------- */
.talk-announcement {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 16px;
flex-shrink: 0;
background: var(--el-color-warning-light-9);
border-bottom: 1px solid var(--el-color-warning-light-7);
color: var(--el-text-color-regular);
}
.talk-announcement-icon {
margin-top: 2px;
color: var(--el-color-warning);
flex-shrink: 0;
}
.talk-announcement-body {
flex: 1;
min-width: 0;
}
.talk-announcement-title {
font-size: 12px;
font-weight: 600;
color: var(--el-color-warning-dark-2);
}
.talk-announcement-text {
margin-top: 2px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.talk-messages {
padding: 12px 16px;
}