feat(Talk): 用户头像改用 GERP 系统头像,成员面板支持设置群管理员

新增 TalkAvatar 组件:优先取 /Query/GetELTUserAvatar(带 server/admin_verify 作用域参数),加载失败或群聊无对应用户时回退首字母色块;会话列表/消息/成员面板统一改用它,移除本地首字母色块逻辑。

成员面板显示群主/管理员标签与「管理员 x/上限」,群主可对成员设为/取消管理员(上限取 Entry.GroupAdminLimit,与后端一致);改群名入口与操作加权限判定(群主/管理员/Talk 管理员)。
This commit is contained in:
TermiNexus committed 2026-10-13 16:08:24 +08:00
1 parent 65aa99a505
commit 899dfbc8e9
3 files changed
+249 -45

No files matched your search

+45 -1
View File
@@ -7,6 +7,7 @@
* Socket.IO 实时通道见 @/utils/talkSocket(本文件仅提供 SocketToken 获取)。
*/
import { get, post, upload } from './request'
import { getServerScopeParams } from '@/utils/serverScope'
/** Talk 接口前缀 */
const TALK_BASE = '/API/Talk'
@@ -20,6 +21,9 @@ export const TalkConversationType = { Single: 0, Group: 1 } as const
/** 消息类型:0=文本 1=图片 2=文件(后端契约未显式约定,按此实现,见汇总说明) */
export const TalkMsgType = { Text: 0, Image: 1, File: 2 } as const
/** 群成员角色:0=成员 1=群主 2=管理员(上限由 Entry.GroupAdminLimit 下发) */
export const TalkMemberRole = { Member: 0, Owner: 1, Admin: 2 } as const
/** Talk 使用配额上限 */
export interface TalkQuotas {
/** 公司可创建的群聊上限 */
@@ -45,6 +49,8 @@ export interface TalkEntry {
CanUse: boolean
/** 当前用户是否管理员(用于解散群聊等特权) */
IsAdmin: boolean
/** 单个群的管理员数量上限(与后端 manager.MAX_GROUP_ADMINS 一致) */
GroupAdminLimit: number
CompanyUUID: string
Quotas: TalkQuotas
Usage: TalkUsage
@@ -55,6 +61,8 @@ export interface TalkMemberBrief {
UserUUID: string
UserName: string
DisplayName: string
/** 群内角色:0=成员 1=群主 2=管理员 */
MemberRole?: number
}
/** 会话项 */
@@ -73,6 +81,14 @@ export interface TalkConversation {
IsOwner?: boolean
/** 群主(创建者)UserUUID */
OwnerUUID?: string
/** 当前用户是否为该群管理员 */
IsGroupAdmin?: boolean
/** 当前用户在该群的角色:0=成员 1=群主 2=管理员 */
MyRole?: number
/** 该群当前管理员数量(不含群主) */
AdminCount?: number
/** 当前用户是否可管理群信息(改群名等):群主或群管理员 */
CanManage?: boolean
}
/** 会话列表返回 */
@@ -168,11 +184,23 @@ export function leaveTalkConversation(data: { ConversationUUID: string }): Promi
return post<void>(`${TALK_BASE}/Conversation/Leave`, data)
}
/** 修改群名 */
/** 修改群名(群聊仅群主/管理员) */
export function renameTalkConversation(data: { ConversationUUID: string; Title: string }): Promise<void> {
return post<void>(`${TALK_BASE}/Conversation/Rename`, data)
}
/** 设置/取消群管理员(Role:0=成员 2=管理员;仅群主或 Talk 管理员,上限见 Entry.GroupAdminLimit) */
export function setTalkMemberRole(data: {
ConversationUUID: string
UserUUID: string
Role: number
}): Promise<{ UserUUID: string; MemberRole: number; AdminCount: number; MaxAdmins: number }> {
return post<{ UserUUID: string; MemberRole: number; AdminCount: number; MaxAdmins: number }>(
`${TALK_BASE}/Conversation/Member/Role`,
data,
)
}
/** 解散群聊(群主/管理员) */
export function dissolveTalkConversation(data: { ConversationUUID: string }): Promise<void> {
return post<void>(`${TALK_BASE}/Conversation/Dissolve`, data)
@@ -235,3 +263,19 @@ export function resolveTalkAttachmentDownloadUrl(attachment: TalkAttachment | nu
if (!base) return ''
return base.includes('?') ? `${base}&download=1` : `${base}?download=1`
}
/**
* GERP 系统头像 URL —— Talk 的用户头像统一取系统头像
*
* /Query/GetELTUserAvatar 由 <img> 直接请求(不走 axios 拦截器),
* 故手动附加 server / admin_verify 作用域参数,与 UserDetailPanel.vue 一致。
*/
export function buildTalkAvatarUrl(UserUUID: string): string {
if (!UserUUID) return ''
const params = new URLSearchParams()
params.set('UserUUID', UserUUID)
const scope = getServerScopeParams()
if (scope.server) params.set('server', scope.server)
if (scope.admin_verify) params.set('admin_verify', scope.admin_verify)
return `/Query/GetELTUserAvatar?${params.toString()}`
}
+110 -44
View File
@@ -27,11 +27,13 @@ import {
leaveTalkConversation,
renameTalkConversation,
dissolveTalkConversation,
setTalkMemberRole,
searchTalkMembers,
resolveTalkAttachmentUrl,
resolveTalkAttachmentDownloadUrl,
TalkConversationType,
TalkMsgType,
TalkMemberRole,
type TalkEntry,
type TalkConversation,
type TalkMessage,
@@ -44,6 +46,7 @@ import {
emitTalkEvent,
} from '@/utils/talkSocket'
import GroupCreateDialog from './GroupCreateDialog.vue'
import TalkAvatar from './TalkAvatar.vue'
const route = useRoute()
const userStore = useUserStore()
@@ -125,6 +128,22 @@ const CanDissolve = computed(
() => Entry.value?.IsAdmin === true || ActiveConversation.value?.IsOwner === true,
)
/** 是否有权修改群信息(群名等):Talk 管理员,或该群群主/群管理员(后端 CanManage) */
const CanManageGroup = computed(
() => Entry.value?.IsAdmin === true || ActiveConversation.value?.CanManage === true,
)
/** 是否有权设置/取消群管理员:仅群主或 Talk 管理员(与后端一致) */
const CanManageMembers = computed(
() => Entry.value?.IsAdmin === true || ActiveConversation.value?.IsOwner === true,
)
/** 单群管理员上限(后端 Entry.GroupAdminLimit 下发,兜底 10) */
const AdminLimit = computed(() => Entry.value?.GroupAdminLimit || 10)
/** 该群当前管理员数量 */
const AdminCount = computed(() => ActiveConversation.value?.AdminCount || 0)
/** 过滤后的会话列表 */
const FilteredConversations = computed(() => {
const keyword = ConversationKeyword.value.trim().toLowerCase()
@@ -132,6 +151,18 @@ const FilteredConversations = computed(() => {
return Conversations.value.filter((c) => ConversationTitle(c).toLowerCase().includes(keyword))
})
/** 群成员排序:群主 → 管理员 → 成员(同级保持后端顺序) */
const SortedMembers = computed(() => {
const rank = (role?: number): number => {
if (role === TalkMemberRole.Owner) return 0
if (role === TalkMemberRole.Admin) return 1
return 2
}
return (ActiveConversation.value?.Members || [])
.slice()
.sort((a, b) => rank(a.MemberRole) - rank(b.MemberRole))
})
/** 正在输入提示文案 */
const TypingText = computed(() => {
const names = Object.values(TypingUsers.value)
@@ -154,19 +185,11 @@ function ConversationTitle(conversation: TalkConversation): string {
return other ? MemberName(other) : '会话'
}
/** 头像首字母 */
function AvatarText(name: string): string {
const text = (name || '').trim()
return text ? text.slice(0, 1).toUpperCase() : '?'
}
/** 头像底色:由 UUID 稳定散列到克制色板 */
function AvatarColor(seed: string): string {
const palette = ['#2563eb', '#0891b2', '#16a34a', '#ca8a04', '#dc2626', '#7c3aed', '#db2777', '#475569']
let hash = 0
const text = seed || ''
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0
return palette[hash % palette.length]
/** 会话头像对应的用户:单聊取对方(用其 GERP 系统头像),群聊返回空(用群名首字色块) */
function ConversationAvatarUUID(conversation: TalkConversation): string {
if (conversation.ConversationType === TalkConversationType.Group) return ''
const other = conversation.Members?.find((m) => m.UserUUID !== MyUUID.value)
return other?.UserUUID || ''
}
/** 时间字符串 → 毫秒(无效返回 0) */
@@ -528,9 +551,36 @@ function HandleGroupCommand(command: string): void {
else if (command === 'dissolve') void DissolveGroup()
}
/** 成员操作:设为/取消群管理员(仅群主或 Talk 管理员,单群上限由后端下发) */
async function HandleMemberCommand(command: string, member: TalkMemberBrief): Promise<void> {
const conversation = ActiveConversation.value
if (!conversation) return
const role = command === 'setAdmin' ? TalkMemberRole.Admin : TalkMemberRole.Member
if (role === TalkMemberRole.Admin && AdminCount.value >= AdminLimit.value) {
ElMessage.warning(`每个群最多设置 ${AdminLimit.value} 个管理员`)
return
}
try {
await setTalkMemberRole({
ConversationUUID: conversation.ConversationUUID,
UserUUID: member.UserUUID,
Role: role,
})
ElMessage.success(role === TalkMemberRole.Admin ? '已设为管理员' : '已取消管理员')
// 重新拉取会话,同步成员角色与管理员数量
await LoadConversations()
} catch {
// 业务错误已由响应拦截器统一提示
}
}
/** 打开改群名 */
function OpenRename(): void {
if (!ActiveConversation.value) return
if (!CanManageGroup.value) {
ElMessage.warning('仅群主或管理员可修改群名')
return
}
RenameTitle.value = ActiveConversation.value.Title || ''
RenameVisible.value = true
}
@@ -805,9 +855,11 @@ onBeforeUnmount(() => {
:class="{ active: conversation.ConversationUUID === ActiveUUID }"
@click="OpenConversation(conversation)"
>
<div class="talk-avatar" :style="{ background: AvatarColor(conversation.ConversationUUID) }">
{{ AvatarText(ConversationTitle(conversation)) }}
</div>
<TalkAvatar
:user-uuid="ConversationAvatarUUID(conversation)"
:name="ConversationTitle(conversation)"
:seed="conversation.ConversationUUID"
/>
<div class="talk-conv-main">
<div class="talk-conv-top">
<span class="talk-conv-title">{{ ConversationTitle(conversation) }}</span>
@@ -844,7 +896,7 @@ onBeforeUnmount(() => {
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="rename">
<el-dropdown-item v-if="CanManageGroup" command="rename">
<el-icon><EditPen /></el-icon>修改群名
</el-dropdown-item>
<el-dropdown-item command="invite">
@@ -874,9 +926,7 @@ onBeforeUnmount(() => {
{{ FormatMessageTime(message.CreateTime) }}
</div>
<div class="talk-msg-row" :class="{ self: IsSelf(message) }">
<div class="talk-avatar small" :style="{ background: AvatarColor(message.SenderUUID) }">
{{ AvatarText(message.SenderName) }}
</div>
<TalkAvatar :user-uuid="message.SenderUUID" :name="message.SenderName" size="small" />
<div class="talk-msg-body">
<div v-if="IsGroupChat && !IsSelf(message)" class="talk-msg-sender">
{{ message.SenderName }}
@@ -970,17 +1020,39 @@ onBeforeUnmount(() => {
<aside v-if="IsGroupChat && !MembersPanelCollapsed" class="talk-members">
<div class="talk-members-header">
<span>群成员({{ ActiveConversation?.MemberCount || 0 }})</span>
<span class="talk-members-admin-count">管理员 {{ AdminCount }}/{{ AdminLimit }}</span>
<el-button text size="small" @click="MembersPanelCollapsed = true">
<el-icon><Close /></el-icon>
</el-button>
</div>
<el-scrollbar class="talk-members-scroll">
<div v-for="member in ActiveConversation?.Members || []" :key="member.UserUUID" class="talk-member-item">
<div class="talk-avatar small" :style="{ background: AvatarColor(member.UserUUID) }">
{{ AvatarText(MemberName(member)) }}
</div>
<div v-for="member in SortedMembers" :key="member.UserUUID" class="talk-member-item">
<TalkAvatar :user-uuid="member.UserUUID" :name="MemberName(member)" size="small" />
<span class="talk-member-name">{{ MemberName(member) }}</span>
<el-tag v-if="member.MemberRole === TalkMemberRole.Owner" size="small" type="warning">群主</el-tag>
<el-tag v-else-if="member.MemberRole === TalkMemberRole.Admin" size="small" type="success">
管理员
</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"
trigger="click"
@command="(command: string) => HandleMemberCommand(command, member)"
>
<el-button text size="small" class="talk-member-more">
<el-icon><MoreFilled /></el-icon>
</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>
<el-dropdown-item v-else command="unsetAdmin">
<el-icon><StarFilled /></el-icon>取消管理员
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-scrollbar>
</aside>
@@ -1154,26 +1226,7 @@ onBeforeUnmount(() => {
}
/* ---------- 头像 ---------- */
.talk-avatar {
width: 38px;
height: 38px;
border-radius: var(--gerp-radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 15px;
font-weight: 600;
flex-shrink: 0;
user-select: none;
&.small {
width: 30px;
height: 30px;
font-size: 13px;
border-radius: 50%;
}
}
/* 头像样式见 TalkAvatar.vue(统一使用 GERP 系统头像,失败回退首字母色块) */
/* ---------- 中:聊天区 ---------- */
.talk-chat {
@@ -1460,4 +1513,17 @@ onBeforeUnmount(() => {
overflow: hidden;
text-overflow: ellipsis;
}
.talk-members-admin-count {
margin-left: auto;
margin-right: 8px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
.talk-member-more {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
/**
* Talk 头像 —— 统一使用 GERP 系统头像
*
* 传入 UserUUID 时以系统头像(/Query/GetELTUserAvatar)呈现;
* 群聊等无对应用户的场景(UserUUID 为空)或头像加载失败时,回退为首字母色块。
*/
import { computed, ref, watch } from 'vue'
import { buildTalkAvatarUrl } from '@/api/talk'
const props = withDefaults(
defineProps<{
/** 对应用户 UUID;为空时直接使用首字母回退(如群聊头像) */
UserUUID?: string
/** 显示名(首字母回退与 img alt 用) */
Name?: string
/** 尺寸:normal=会话列表/大号,small=消息与成员列表 */
Size?: 'normal' | 'small'
/** 首字母回退的配色种子(缺省用 UserUUID / Name) */
Seed?: string
}>(),
{ UserUUID: '', Name: '', Size: 'normal', Seed: '' },
)
/** 系统头像是否加载失败(失败即回退首字母,避免破图) */
const LoadFailed = ref(false)
const Src = computed(() => (LoadFailed.value ? '' : buildTalkAvatarUrl(props.UserUUID || '')))
/** 头像首字母 */
const FallbackText = computed(() => {
const text = (props.Name || '').trim()
return text ? text.slice(0, 1).toUpperCase() : '?'
})
/** 回退底色:由种子稳定散列到克制色板 */
const FallbackColor = computed(() => {
const palette = ['#2563eb', '#0891b2', '#16a34a', '#ca8a04', '#dc2626', '#7c3aed', '#db2777', '#475569']
const seed = props.Seed || props.UserUUID || props.Name || ''
let hash = 0
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
return palette[hash % palette.length]
})
// 复用同一组件实例渲染不同用户(如切换会话)时重置失败标记
watch(
() => props.UserUUID,
() => {
LoadFailed.value = false
},
)
</script>
<template>
<div
class="talk-avatar"
:class="{ small: Size === 'small' }"
:style="Src ? undefined : { background: FallbackColor }"
>
<img v-if="Src" :src="Src" :alt="Name" @error="LoadFailed = true" />
<template v-else>{{ FallbackText }}</template>
</div>
</template>
<style scoped lang="scss">
.talk-avatar {
width: 38px;
height: 38px;
border-radius: var(--gerp-radius-sm);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #fff;
font-size: 15px;
font-weight: 600;
flex-shrink: 0;
user-select: none;
&.small {
width: 30px;
height: 30px;
font-size: 13px;
border-radius: 50%;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
</style>