From c9635a99db43feb997e32de3aec46de714ce0160 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Tue, 13 Oct 2026 18:23:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(Talk):=20=E7=BE=A4=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=88=90=E5=91=98=E4=B9=9D=E5=AE=AB=E6=A0=BC?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=B9=B6=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=88=90=E5=91=98=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=AE=A1=E7=90=86=E5=91=98=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/talk.ts | 25 +++++ src/views/talk/Talk.vue | 182 ++++++++++++++++++++++++++++++++-- src/views/talk/TalkAvatar.vue | 135 ++++++++++++++++++++----- 3 files changed, 308 insertions(+), 34 deletions(-) diff --git a/src/api/talk.ts b/src/api/talk.ts index 3423fb1..e68609c 100644 --- a/src/api/talk.ts +++ b/src/api/talk.ts @@ -93,6 +93,8 @@ export interface TalkConversation { Announcement?: string /** 群公告更新时间 */ AnnouncementTime?: string + /** 群头像资源 SHA1(空串=使用成员九宫格默认头像) */ + AvatarSHA1?: string } /** 会话列表返回 */ @@ -240,6 +242,17 @@ export function setTalkAnnouncement(data: { ) } +/** 设置群头像(空串即恢复默认九宫格;群主/群管理员) */ +export function setTalkConversationAvatar(data: { + ConversationUUID: string + AvatarSHA1: string +}): Promise<{ ConversationUUID: string; AvatarSHA1: string }> { + return post<{ ConversationUUID: string; AvatarSHA1: string }>( + `${TALK_BASE}/Conversation/Avatar`, + data, + ) +} + /** 消息分页(BeforeTime 取更早一页) */ export function getTalkMessages(params: { ConversationUUID: string @@ -313,3 +326,15 @@ export function buildTalkAvatarUrl(UserUUID: string): string { if (scope.admin_verify) params.set('admin_verify', scope.admin_verify) return `/Query/GetELTUserAvatar?${params.toString()}` } + +/** + * 群头像(自定义)资源 URL —— 与附件同一套规则 + * + * SHA1 由 /API/Talk/Upload 上传图片得到,后端存 FileAsset; + * 生产环境绝对化到统一 API 域名,开发环境保留相对路径走 Vite 代理。 + */ +export function buildTalkAssetUrl(SHA1: string): string { + if (!SHA1) return '' + if (import.meta.env.PROD) return `${ASSET_DOWNLOAD_BASE}/${SHA1}` + return `/API/Asset/Download/${SHA1}` +} diff --git a/src/views/talk/Talk.vue b/src/views/talk/Talk.vue index f55d7c7..03255fb 100644 --- a/src/views/talk/Talk.vue +++ b/src/views/talk/Talk.vue @@ -31,9 +31,11 @@ import { removeTalkMembers, transferTalkOwner, setTalkAnnouncement, + setTalkConversationAvatar, searchTalkMembers, resolveTalkAttachmentUrl, resolveTalkAttachmentDownloadUrl, + buildTalkAssetUrl, TalkConversationType, TalkMsgType, TalkMemberRole, @@ -60,6 +62,8 @@ const TALK_PATH = '/talk' const MESSAGE_PAGE_SIZE = 30 /** 上传大小上限:10MB */ const UPLOAD_MAX_SIZE = 10 * 1024 * 1024 +/** 群头像大小上限:2MB */ +const AVATAR_MAX_SIZE = 2 * 1024 * 1024 /** 输入中节流:2s 内只发一次 talk:typing */ const TYPING_THROTTLE = 2000 /** 相邻消息间隔小于该值不重复显示时间(5 分钟) */ @@ -101,6 +105,13 @@ const RenameSubmitting = ref(false) const AnnouncementVisible = ref(false) const AnnouncementText = ref('') const AnnouncementSubmitting = ref(false) +const AvatarVisible = ref(false) +/** 待提交的群头像 SHA1(空串=恢复默认九宫格) */ +const AvatarSHA1 = ref('') +/** 群头像预览地址 */ +const AvatarPreview = ref('') +const AvatarUploading = ref(false) +const AvatarSubmitting = ref(false) // === 实时状态 === const SelfUUID = ref('') @@ -111,6 +122,7 @@ const TypingUsers = ref>({}) const ScrollbarRef = ref() const ImageInput = ref(null) const FileInput = ref(null) +const AvatarInput = ref(null) let TypingSentAt = 0 const TypingTimers = new Map>() const ReadMap = new Map() @@ -155,6 +167,11 @@ const CanManageAnnouncement = computed( () => Entry.value?.IsAdmin === true || ActiveConversation.value?.CanManage === true, ) +/** 群头像预览用九宫格成员(当前会话前 9 名成员) */ +const AvatarPreviewGrid = computed(() => + (ActiveConversation.value?.Members || []).map((m) => m.UserUUID).slice(0, 9), +) + /** 是否有权转让群主:仅当前群主或 Talk 管理员 */ const CanTransferOwner = computed( () => Entry.value?.IsAdmin === true || ActiveConversation.value?.IsOwner === true, @@ -219,13 +236,26 @@ function ConversationTitle(conversation: TalkConversation): string { return other ? MemberName(other) : '会话' } -/** 会话头像对应的用户:单聊取对方(用其 GERP 系统头像),群聊返回空(用群名首字色块) */ +/** 会话头像对应的用户:单聊取对方(用其 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 || '' } +/** 会话自定义群头像图片地址(群聊且已设置自定义头像时) */ +function ConversationAvatarSrc(conversation: TalkConversation): string { + if (conversation.ConversationType !== TalkConversationType.Group) return '' + return buildTalkAssetUrl(conversation.AvatarSHA1 || '') +} + +/** 会话默认群头像九宫格成员(群聊且无自定义头像时取前 9 名成员) */ +function ConversationAvatarGrid(conversation: TalkConversation): string[] { + if (conversation.ConversationType !== TalkConversationType.Group) return [] + if (conversation.AvatarSHA1) return [] + return (conversation.Members || []).map((m) => m.UserUUID).slice(0, 9) +} + /** 时间字符串 → 毫秒(无效返回 0) */ function TimeValue(time: string): number { if (!time) return 0 @@ -580,6 +610,7 @@ async function UploadAndSend(file: File): Promise { /** 下拉命令分发 */ function HandleGroupCommand(command: string): void { if (command === 'rename') OpenRename() + else if (command === 'avatar') OpenAvatar() else if (command === 'invite') OpenInvite() else if (command === 'announcement') OpenAnnouncement() else if (command === 'leave') void LeaveGroup() @@ -701,6 +732,75 @@ async function SubmitAnnouncement(): Promise { } } +/** 打开群头像编辑 */ +function OpenAvatar(): void { + if (!ActiveConversation.value) return + if (!CanManageGroup.value) { + ElMessage.warning('仅群主或管理员可修改群头像') + return + } + AvatarSHA1.value = ActiveConversation.value.AvatarSHA1 || '' + AvatarPreview.value = AvatarSHA1.value ? buildTalkAssetUrl(AvatarSHA1.value) : '' + AvatarVisible.value = true +} + +/** 选择群头像图片 */ +function PickAvatar(): void { + AvatarInput.value?.click() +} + +/** 群头像图片选中:校验后上传取 SHA1 */ +async function OnAvatarPicked(event: Event): Promise { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + if (!file.type.startsWith('image/')) { + ElMessage.warning('请选择图片文件') + return + } + if (file.size > AVATAR_MAX_SIZE) { + ElMessage.warning('群头像图片不能超过 2MB') + return + } + AvatarUploading.value = true + try { + const result = await uploadTalkAttachment(file) + AvatarSHA1.value = result.SHA1 + AvatarPreview.value = buildTalkAssetUrl(result.SHA1) + } catch { + // 业务错误已由响应拦截器统一提示 + } finally { + AvatarUploading.value = false + } +} + +/** 群头像预览切回默认九宫格 */ +function ResetAvatar(): void { + AvatarSHA1.value = '' + AvatarPreview.value = '' +} + +/** 提交群头像(空串即恢复默认九宫格) */ +async function SubmitAvatar(): Promise { + const conversation = ActiveConversation.value + if (!conversation) return + AvatarSubmitting.value = true + try { + const result = await setTalkConversationAvatar({ + ConversationUUID: conversation.ConversationUUID, + AvatarSHA1: AvatarSHA1.value, + }) + conversation.AvatarSHA1 = result?.AvatarSHA1 ?? '' + ElMessage.success(conversation.AvatarSHA1 ? '群头像已更新' : '已恢复默认群头像') + AvatarVisible.value = false + } catch { + // 业务错误已由响应拦截器统一提示 + } finally { + AvatarSubmitting.value = false + } +} + /** 打开改群名 */ function OpenRename(): void { if (!ActiveConversation.value) return @@ -1007,6 +1107,8 @@ onBeforeUnmount(() => { > @@ -1049,6 +1151,9 @@ onBeforeUnmount(() => { 修改群名 + + 修改群头像 + 群公告 @@ -1189,7 +1294,6 @@ onBeforeUnmount(() => {