feat(Talk): 用户头像改用 GERP 系统头像,成员面板支持设置群管理员
新增 TalkAvatar 组件:优先取 /Query/GetELTUserAvatar(带 server/admin_verify 作用域参数),加载失败或群聊无对应用户时回退首字母色块;会话列表/消息/成员面板统一改用它,移除本地首字母色块逻辑。 成员面板显示群主/管理员标签与「管理员 x/上限」,群主可对成员设为/取消管理员(上限取 Entry.GroupAdminLimit,与后端一致);改群名入口与操作加权限判定(群主/管理员/Talk 管理员)。
This commit is contained in:
1 parent
65aa99a505
commit
899dfbc8e9
3 files changed
+249
-45
No files matched your search
+45
-1
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user