✨ feat(im): 初始化群申请 v0.1:第二把 review
parent
3be0daf115
commit
8fc5273a88
|
|
@ -10,13 +10,12 @@ export interface ImGroupRespVO {
|
|||
notice?: string // 群公告
|
||||
banned?: boolean // 是否封禁
|
||||
mutedAll?: boolean // 是否全群禁言
|
||||
joinType?: number // 加群方式;参见 ImGroupJoinTypeEnum
|
||||
joinApproval?: boolean // 进群是否需群主 / 管理员审批
|
||||
bannedTime?: string // 封禁时间
|
||||
status: number // 群状态(0=正常,1=已解散)
|
||||
dissolvedTime?: string // 解散时间
|
||||
createTime?: string // 创建时间
|
||||
pinnedMessages?: ImGroupMessageRespVO[] // 群置顶消息列表(后端关联回填,仅当登录用户是群成员时非空)
|
||||
pendingRequestCount?: number // 未处理加群申请数;后端关联回填,仅当登录用户是该群群主 / 管理员时非空 TODO @AI:看看这里,是不是可以不返回?
|
||||
}
|
||||
|
||||
// 群消息置顶 / 取消置顶 Request VO
|
||||
|
|
@ -29,7 +28,7 @@ export interface ImGroupMessagePinReqVO {
|
|||
export interface ImGroupCreateReqVO {
|
||||
name: string // 群名称
|
||||
memberUserIds?: number[] // 初始成员用户编号列表(建群同时邀请的好友,不含创建者自己)
|
||||
joinType?: number // 加群方式;不传默认 0 自由进群 TODO @AI:不要写 0,而是写对应的枚举
|
||||
joinApproval?: boolean // 进群是否需审批;不传默认 false 自由进群
|
||||
}
|
||||
|
||||
// 群更新 Request VO
|
||||
|
|
@ -38,7 +37,7 @@ export interface ImGroupUpdateReqVO {
|
|||
name?: string // 群名称
|
||||
avatar?: string // 群头像
|
||||
notice?: string // 群公告
|
||||
joinType?: number // 加群方式
|
||||
joinApproval?: boolean // 进群是否需审批
|
||||
}
|
||||
|
||||
// 添加 / 撤销群管理员 Request VO
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const refuseGroupRequest = (id: number | string, handleContent?: string)
|
|||
}
|
||||
|
||||
// 查询「我相关」的加群申请列表(含我主动申请、我被邀请待审);游标分页
|
||||
// TODO @AI:这个 list 接口,改成传递 groupId,查询这个群下,所有的申请。然后,group size 增加一个:「群申请列表」,里面可以看到所有的。
|
||||
export const getMyGroupRequestList = (limit: number, lastRequestId?: number) => {
|
||||
const params: Record<string, number> = { limit }
|
||||
if (lastRequestId != null) {
|
||||
|
|
@ -59,23 +60,14 @@ export const getMyGroupRequestList = (limit: number, lastRequestId?: number) =>
|
|||
})
|
||||
}
|
||||
|
||||
// 查询指定群的未处理加群申请(仅群主或管理员可调);游标分页
|
||||
export const getPendingGroupRequestList = (
|
||||
groupId: number | string,
|
||||
limit: number,
|
||||
lastRequestId?: number
|
||||
) => {
|
||||
const params: Record<string, number | string> = { groupId, limit }
|
||||
if (lastRequestId != null) {
|
||||
params.lastRequestId = lastRequestId
|
||||
}
|
||||
// 查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表
|
||||
export const getUnhandledRequestList = () => {
|
||||
return request.get<ImGroupRequestRespVO[]>({
|
||||
url: '/im/group-request/list-pending',
|
||||
params
|
||||
url: '/im/group-request/unhandled-list'
|
||||
})
|
||||
}
|
||||
|
||||
// 按 id 单查「我相关」的申请记录(带越权过滤;WebSocket 通知到达后用)
|
||||
// 按 id 单查申请记录(带越权过滤;WebSocket 通知到达后用)
|
||||
export const getMyGroupRequest = (id: number) => {
|
||||
return request.get<ImGroupRequestRespVO | null>({
|
||||
url: '/im/group-request/get',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@
|
|||
<div class="flex flex-col gap-3">
|
||||
<el-input v-model="groupName" placeholder="请输入群名称" maxlength="20" show-word-limit />
|
||||
|
||||
<!-- TODO @AI:暂时不用这个入口;对齐微信; -->
|
||||
<div class="flex items-center gap-2 text-13px text-[var(--el-text-color-secondary)]">
|
||||
<span class="shrink-0">进群需要群主 / 群管理确认</span>
|
||||
<el-switch v-model="joinApproval" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
|
||||
|
|
@ -131,6 +137,7 @@ const visible = computed({
|
|||
})
|
||||
|
||||
const groupName = ref('')
|
||||
const joinApproval = ref<boolean>(false) // 默认不需审批,自由进群
|
||||
const searchText = ref('')
|
||||
const submitting = ref(false)
|
||||
const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 checked / disabled 标记),与 prop 隔离
|
||||
|
|
@ -142,6 +149,7 @@ watch(
|
|||
return
|
||||
}
|
||||
groupName.value = ''
|
||||
joinApproval.value = false
|
||||
searchText.value = ''
|
||||
workingFriends.value = props.friends
|
||||
.filter((friend) => !friend.deleted)
|
||||
|
|
@ -205,7 +213,7 @@ async function handleOk() {
|
|||
submitting.value = true
|
||||
try {
|
||||
// 1. 新建群聊
|
||||
const group = await createGroup({ name, memberUserIds })
|
||||
const group = await createGroup({ name, memberUserIds, joinApproval: joinApproval.value })
|
||||
if (!group?.id) {
|
||||
throw new Error('创建群失败:未返回群编号')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { useConversationStore } from './store/conversationStore'
|
|||
import { useImWebSocketStore } from './store/websocketStore'
|
||||
import { useFriendStore } from './store/friendStore'
|
||||
import { useGroupStore } from './store/groupStore'
|
||||
import { useGroupRequestStore } from './store/groupRequestStore'
|
||||
import { useDraftStore } from './store/draftStore'
|
||||
import { useMessagePuller } from './composables/useMessagePuller'
|
||||
import { useMessageSender } from './composables/useMessageSender'
|
||||
|
|
@ -47,6 +48,7 @@ const conversationStore = useConversationStore()
|
|||
const webSocketStore = useImWebSocketStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const draftStore = useDraftStore()
|
||||
const { pullOnce } = useMessagePuller()
|
||||
const { readActive, syncPrivateReadStatus } = useMessageSender()
|
||||
|
|
@ -85,6 +87,11 @@ onMounted(async () => {
|
|||
// 3. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位)
|
||||
webSocketStore.connect()
|
||||
await pullOnce()
|
||||
// 3.1 我管理的群下未处理加群申请,给顶部横幅 / Drawer 派生 count 与 list;失败不阻断主流程
|
||||
// TODO @AI:可以挪到 1.2 那么?不阻塞就行呀。
|
||||
void groupRequestStore.fetchUnhandledList().catch((e) =>
|
||||
console.warn('[IM] 拉取未处理加群申请失败', e)
|
||||
)
|
||||
|
||||
// 4. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠
|
||||
const sorted = conversationStore.getSortedConversations
|
||||
|
|
|
|||
|
|
@ -272,6 +272,11 @@
|
|||
<span class="im-conversation-group-side__label">全群禁言</span>
|
||||
<el-switch :model-value="!!currentMutedAll" @change="onMuteAllChange" />
|
||||
</div>
|
||||
<!-- 进群审批:仅群主可操作;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 -->
|
||||
<div v-if="isOwner" class="im-conversation-group-side__row">
|
||||
<span class="im-conversation-group-side__label">进群需要群主 / 群管理确认</span>
|
||||
<el-switch :model-value="!!group.joinApproval" @change="onJoinApprovalChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 群主操作 ==================== -->
|
||||
|
|
@ -387,7 +392,11 @@ import {
|
|||
import { quitGroup, removeGroupMember, updateGroupMember } from '@/api/im/group/member'
|
||||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { GROUP_ADMIN_MAX_COUNT, ImConversationType, ImGroupMemberRole } from '@/views/im/utils/constants'
|
||||
import {
|
||||
GROUP_ADMIN_MAX_COUNT,
|
||||
ImConversationType,
|
||||
ImGroupMemberRole
|
||||
} from '@/views/im/utils/constants'
|
||||
import GroupMemberGrid from '../../../../components/group/GroupMemberGrid.vue'
|
||||
import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDialog.vue'
|
||||
import GroupMemberSelector, {
|
||||
|
|
@ -485,7 +494,6 @@ const isOwnerOrAdmin = computed(
|
|||
() => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
|
||||
)
|
||||
|
||||
|
||||
// 排除已退群成员 + 关键字过滤;按角色排序:群主→管理员→普通成员(同角色按 userId 稳定)
|
||||
const visibleMembers = computed(() => {
|
||||
return props.members
|
||||
|
|
@ -536,6 +544,17 @@ async function saveNotice() {
|
|||
emit('reload')
|
||||
}
|
||||
|
||||
/** 群主:切换「进群审批」开关;开启后所有「申请」「邀请」路径都需群主 / 管理员同意 */
|
||||
// TODO @AI:应该是 handleXXX;别的方法也看看,是不是统一调整过来。
|
||||
async function onJoinApprovalChange(value: boolean | string | number) {
|
||||
if (!props.group) {
|
||||
return
|
||||
}
|
||||
await updateGroup({ id: props.group.id, joinApproval: !!value })
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
/** 任何成员:保存群备注(仅自己可见,会替换会话列表 / 顶部群名展示) */
|
||||
async function saveGroupRemark() {
|
||||
if (!props.group) {
|
||||
|
|
@ -696,8 +715,10 @@ async function handleRemoveComplete(members: GroupMemberFlag[]) {
|
|||
/** 当前管理员的 userId 列表,作为 Selector 默认勾选;过滤已退群成员,避免 maxSize 名额被隐藏成员占用导致无法新增管理员 */
|
||||
const adminCheckedIds = computed(() =>
|
||||
props.members
|
||||
.filter((member) => member.role === ImGroupMemberRole.ADMIN
|
||||
&& member.status !== CommonStatusEnum.DISABLE)
|
||||
.filter(
|
||||
(member) =>
|
||||
member.role === ImGroupMemberRole.ADMIN && member.status !== CommonStatusEnum.DISABLE
|
||||
)
|
||||
.map((member) => member.userId)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
<template>
|
||||
<!--
|
||||
群顶部「待处理加群申请」横幅
|
||||
- 仅当登录用户是该群 owner / admin 且该群下未处理申请数 > 0 时显示
|
||||
- count 与 list 都从 groupRequestStore 派生(全局存);本端处理 / WS 通知到达后 store 自动更新
|
||||
- 单条胶囊一行;点击展开下拉,每条带「同意 / 拒绝」按钮
|
||||
-->
|
||||
<!-- TODO @AI:还不是新建一个 components/group;这个改成 GroupRequestPending;然后 GroupPinnedMessages ?这样风格更一致一点; -->
|
||||
<div v-if="canManage && pendingCount > 0" class="im-conversation-group-request">
|
||||
<div
|
||||
class="im-conversation-group-request__row im-conversation-group-request__row--clickable"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<Icon
|
||||
icon="ant-design:user-add-outlined"
|
||||
:size="14"
|
||||
class="im-conversation-group-request__icon"
|
||||
/>
|
||||
<span class="im-conversation-group-request__text">
|
||||
{{ pendingCount }} 条新的入群申请待处理
|
||||
</span>
|
||||
<Icon
|
||||
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
||||
:size="11"
|
||||
class="im-conversation-group-request__chevron"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 展开列表面板:浅色面板 + 每条独立卡片,行内含「同意 / 拒绝」按钮 -->
|
||||
<div v-if="expanded" class="im-conversation-group-request__list">
|
||||
<div v-for="item in list" :key="item.id" class="im-conversation-group-request__item">
|
||||
<UserAvatar
|
||||
:url="item.userAvatar"
|
||||
:name="item.userNickname"
|
||||
:size="32"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-conversation-group-request__item-body">
|
||||
<div class="im-conversation-group-request__item-name truncate">
|
||||
{{ item.userNickname || `用户 ${item.userId}` }}
|
||||
<span v-if="item.inviterUserId" class="im-conversation-group-request__item-inviter">
|
||||
← {{ item.inviterNickname || `用户 ${item.inviterUserId}` }} 邀请
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.applyContent" class="im-conversation-group-request__item-msg truncate">
|
||||
{{ item.applyContent }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="im-conversation-group-request__item-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="actingId === item.id"
|
||||
@click="handleAgree(item)"
|
||||
>
|
||||
同意
|
||||
</el-button>
|
||||
<el-button size="small" :loading="actingId === item.id" @click="handleRefuse(item)">
|
||||
拒绝
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
import type { ImGroupRequestRespVO } from '@/api/im/group/request'
|
||||
import { ImGroupMemberRole } from '@/views/im/utils/constants'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
||||
|
||||
defineOptions({ name: 'ImConversationGroupRequestPending' })
|
||||
|
||||
const props = defineProps<{
|
||||
groupId: number
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const message = useMessage()
|
||||
|
||||
const expanded = ref(false)
|
||||
const actingId = ref<number | null>(null)
|
||||
|
||||
/** 当前群(含 ownerUserId / members) */
|
||||
const group = computed(() => groupStore.getGroup(props.groupId))
|
||||
|
||||
/** 当前用户在群里的角色;优先用 group.members,懒加载未到时回退到 ownerUserId 直判 */
|
||||
const myRole = computed(() => {
|
||||
const myId = Number(userStore.getUser?.id) || 0
|
||||
if (group.value?.ownerUserId === myId) {
|
||||
return ImGroupMemberRole.OWNER
|
||||
}
|
||||
return group.value?.members?.find((m) => m.userId === myId)?.role
|
||||
})
|
||||
|
||||
/** 仅群主 / 管理员可见 */
|
||||
const canManage = computed(
|
||||
() => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
|
||||
)
|
||||
|
||||
/** 当前群未处理申请数;从 store 派生 */
|
||||
const pendingCount = computed(() => groupRequestStore.getUnhandledCountByGroupId(props.groupId))
|
||||
|
||||
/** 当前群未处理申请列表;Drawer 内容 */
|
||||
const list = computed<ImGroupRequestRespVO[]>(() =>
|
||||
groupRequestStore.getUnhandledListByGroupId(props.groupId)
|
||||
)
|
||||
|
||||
/** 切群时收起 */
|
||||
watch(
|
||||
() => props.groupId,
|
||||
() => {
|
||||
expanded.value = false
|
||||
}
|
||||
)
|
||||
|
||||
/** 同意申请 */
|
||||
async function handleAgree(item: ImGroupRequestRespVO) {
|
||||
if (actingId.value !== null) return
|
||||
actingId.value = item.id
|
||||
try {
|
||||
await groupRequestStore.agreeRequest(item.id)
|
||||
message.success('已同意')
|
||||
if (list.value.length === 0) {
|
||||
expanded.value = false
|
||||
}
|
||||
} finally {
|
||||
actingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 拒绝申请 */
|
||||
async function handleRefuse(item: ImGroupRequestRespVO) {
|
||||
if (actingId.value !== null) return
|
||||
let handleContent = ''
|
||||
try {
|
||||
const result = await message.prompt('请输入拒绝理由(可选)', '拒绝申请')
|
||||
handleContent = result.value || ''
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
actingId.value = item.id
|
||||
try {
|
||||
await groupRequestStore.refuseRequest(item.id, handleContent || undefined)
|
||||
message.success('已拒绝')
|
||||
if (list.value.length === 0) {
|
||||
expanded.value = false
|
||||
}
|
||||
} finally {
|
||||
actingId.value = null
|
||||
}
|
||||
}
|
||||
// todo @AI:下面的 style 可以换成 unocss 么?
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-conversation-group-request {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 6px 16px 8px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 360px;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.im-conversation-group-request__row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.im-conversation-group-request__row--clickable:hover {
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.im-conversation-group-request__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: -1px;
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 380px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--el-bg-color);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.im-conversation-group-request__item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.im-conversation-group-request__item-name {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__item-inviter {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.im-conversation-group-request__item-msg {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.im-conversation-group-request__item-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { store } from '@/store'
|
||||
|
||||
import {
|
||||
agreeGroupRequest as apiAgreeGroupRequest,
|
||||
getMyGroupRequest as apiGetMyGroupRequest,
|
||||
getUnhandledRequestList as apiGetUnhandledRequestList,
|
||||
refuseGroupRequest as apiRefuseGroupRequest,
|
||||
type ImGroupRequestRespVO
|
||||
} from '@/api/im/group/request'
|
||||
|
||||
/**
|
||||
* IM 加群申请 Store
|
||||
*
|
||||
* 仅维护「我管理的所有群」下未处理的申请列表(unhandledList);
|
||||
* 横幅 / Drawer 都从这里派生 count 和分组列表,避免给 ImGroupRespVO 挂 pendingRequestCount 字段
|
||||
*
|
||||
* 数据生命周期:
|
||||
* - 进 IM 后调一次 fetchUnhandledList 拉首页全量
|
||||
* - WebSocket 1503 → 调 fetchOne(requestId) 拉单条 + push 到 unhandledList 头部
|
||||
* - WebSocket 1505 / 1506 → 按 requestId 从 unhandledList 移除
|
||||
* - WebSocket 1517 GROUP_ADMIN_ADD(自己被加为 admin)→ 重新调 fetchUnhandledList
|
||||
* - 本端 agree / refuse 处理后 → 本地按 requestId 移除
|
||||
*/
|
||||
export const useGroupRequestStore = defineStore('imGroupRequestStore', {
|
||||
state: () => ({
|
||||
/** 我管理的所有群下未处理申请列表(按 id 倒序) */
|
||||
unhandledList: [] as ImGroupRequestRespVO[],
|
||||
/** fetchUnhandledList 是否成功执行过;避免横幅显示 0 然后跳数字的闪烁 */
|
||||
loaded: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/** 指定群下的未处理申请数;横幅红点 */
|
||||
getUnhandledCountByGroupId:
|
||||
(state) =>
|
||||
(groupId: number): number =>
|
||||
state.unhandledList.filter((r) => r.groupId === groupId).length,
|
||||
/** 指定群下的未处理申请列表;Drawer 内容 */
|
||||
getUnhandledListByGroupId:
|
||||
(state) =>
|
||||
(groupId: number): ImGroupRequestRespVO[] =>
|
||||
state.unhandledList.filter((r) => r.groupId === groupId)
|
||||
},
|
||||
|
||||
actions: {
|
||||
/** 拉取我管理的所有群下未处理申请;进 IM 后 / 升级 admin 后 / WS 推送有冲突时调用 */
|
||||
async fetchUnhandledList() {
|
||||
const list = await apiGetUnhandledRequestList()
|
||||
this.unhandledList = list || []
|
||||
this.loaded = true
|
||||
},
|
||||
|
||||
/** WS 收到 1503:按 requestId 单查 + push 进列表头;payload 已带申请方昵称 / 头像可减一次回查 */
|
||||
async addByRequestId(requestId: number) {
|
||||
const exists = this.unhandledList.some((r) => r.id === requestId)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
const request = await apiGetMyGroupRequest(requestId)
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
this.unhandledList.unshift(request)
|
||||
},
|
||||
|
||||
/** WS 收到 1505 / 1506 或本端处理完一条:按 requestId 从列表移除 */
|
||||
removeByRequestId(requestId: number) {
|
||||
this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
|
||||
},
|
||||
|
||||
/** 同意申请;本端处理后立即从列表移除,避免被反复点击 */
|
||||
async agreeRequest(requestId: number) {
|
||||
await apiAgreeGroupRequest(requestId)
|
||||
this.removeByRequestId(requestId)
|
||||
},
|
||||
|
||||
/** 拒绝申请 */
|
||||
async refuseRequest(requestId: number, handleContent?: string) {
|
||||
await apiRefuseGroupRequest(requestId, handleContent)
|
||||
this.removeByRequestId(requestId)
|
||||
},
|
||||
|
||||
/** 退出 IM / 切账号时清理 */
|
||||
reset() {
|
||||
this.unhandledList = []
|
||||
this.loaded = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const useGroupRequestStoreWithOut = () => useGroupRequestStore(store)
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useGroupRequestStore, import.meta.hot))
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type ImGroupMemberRespVO
|
||||
} from '@/api/im/group/member'
|
||||
import { useConversationStore } from './conversationStore'
|
||||
import { useGroupRequestStore } from './groupRequestStore'
|
||||
import { ImConversationType, ImGroupMemberRole, ImMessageType } from '../../utils/constants'
|
||||
import {
|
||||
getCurrentUserId,
|
||||
|
|
@ -523,6 +524,9 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
case ImMessageType.GROUP_MEMBER_INVITE:
|
||||
this.applyGroupMemberInviteNotification(groupId, payload)
|
||||
break
|
||||
case ImMessageType.GROUP_MEMBER_ENTER:
|
||||
this.applyGroupMemberEnterNotification(groupId, payload)
|
||||
break
|
||||
case ImMessageType.GROUP_MEMBER_QUIT:
|
||||
this.applyGroupMemberQuitNotification(groupId, payload)
|
||||
break
|
||||
|
|
@ -534,6 +538,10 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
break
|
||||
case ImMessageType.GROUP_ADMIN_ADD:
|
||||
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
|
||||
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
||||
if (isSelfInPayloadMembers(payload)) {
|
||||
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
|
||||
}
|
||||
break
|
||||
case ImMessageType.GROUP_ADMIN_REMOVE:
|
||||
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.NORMAL)
|
||||
|
|
@ -610,6 +618,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
this.fetchGroupMembers(groupId, true).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** 自由进群:进群者本端 group 未就位先 fetchGroupInfo bootstrap;所有人都刷成员列表 */
|
||||
applyGroupMemberEnterNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
const selfUserId = getCurrentUserId()
|
||||
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
|
||||
this.fetchGroupInfo(groupId).catch(() => undefined)
|
||||
}
|
||||
this.fetchGroupMembers(groupId, true).catch(() => undefined)
|
||||
},
|
||||
|
||||
/** 成员退群:退群者本人多端同步走 removeGroup;其他成员从本地列表移除 quitter */
|
||||
applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
const selfUserId = getCurrentUserId()
|
||||
|
|
@ -641,11 +658,16 @@ export const useGroupStore = defineStore('imGroupStore', {
|
|||
}
|
||||
},
|
||||
|
||||
/** 群主转让:旧群主 → NORMAL,新群主 → OWNER */
|
||||
/** 群主转让:旧群主 → NORMAL,新群主 → OWNER;新群主自己侧重新拉申请列表 */
|
||||
applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) {
|
||||
if (payload.operatorUserId && payload.newOwnerUserId) {
|
||||
this.transferOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
|
||||
}
|
||||
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
|
||||
const selfUserId = getCurrentUserId()
|
||||
if (selfUserId && payload.newOwnerUserId === selfUserId) {
|
||||
useGroupRequestStore().fetchUnhandledList().catch(() => undefined)
|
||||
}
|
||||
},
|
||||
|
||||
/** 群消息置顶:从 payload 直接拿完整消息对象 push 到 pinnedMessages */
|
||||
|
|
@ -735,8 +757,7 @@ function convertGroup(group: ImGroupRespVO): Group {
|
|||
pinnedMessages: group.pinnedMessages?.map(convertGroupMessageVO),
|
||||
mutedAll: group.mutedAll,
|
||||
banned: group.banned,
|
||||
joinType: group.joinType,
|
||||
pendingRequestCount: group.pendingRequestCount
|
||||
joinApproval: group.joinApproval
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { store } from '@/store'
|
||||
import { getRefreshToken } from '@/utils/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
ImConversationType,
|
||||
isFriendChatTip,
|
||||
isFriendNotification,
|
||||
isGroupRequestNotification,
|
||||
isNormalMessage
|
||||
} from '../../utils/constants'
|
||||
import { playAudioTip } from '../../utils/message'
|
||||
|
|
@ -16,6 +18,7 @@ import { useConversationStore } from './conversationStore'
|
|||
import { useFriendStore, type FriendNotificationPayload } from './friendStore'
|
||||
import { getFriendDisplayName } from '../../utils/user'
|
||||
import { useGroupStore } from './groupStore'
|
||||
import { useGroupRequestStore } from './groupRequestStore'
|
||||
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
|
||||
import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group'
|
||||
import type {
|
||||
|
|
@ -247,6 +250,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
) {
|
||||
this.handlePrivateMessage(websocketMessage)
|
||||
}
|
||||
} else if (isGroupRequestNotification(websocketMessage.type)) {
|
||||
// 加群申请通知(1503 / 1505 / 1506)走私聊通道,与好友通知同段位但分开 dispatcher
|
||||
// TODO @AI:改成走群聊通道。不然消息不好拉到!!!
|
||||
this.handleGroupRequestNotification(websocketMessage)
|
||||
} else {
|
||||
// TEXT / IMAGE / FILE / VOICE / VIDEO 等普通消息
|
||||
this.handlePrivateMessage(websocketMessage)
|
||||
|
|
@ -551,6 +558,52 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
|
|||
}
|
||||
},
|
||||
|
||||
// ==================== 加群申请通知(1503 / 1505 / 1506,承载于私聊通道) ====================
|
||||
|
||||
/**
|
||||
* 加群申请通知统一入口:分发到 groupRequestStore,驱动横幅 + Drawer 同步
|
||||
*
|
||||
* 对应后端 ImPrivateMessageDTO.ofGroupNotification 系列:
|
||||
* - 1503:admin 侧拉单条 push 进 unhandledList;申请人侧不收
|
||||
* - 1505 / 1506:双端都收 — admin 侧从 unhandledList 移除;申请人侧弹 toast
|
||||
*/
|
||||
handleGroupRequestNotification(websocketMessage: ImPrivateMessageDTO) {
|
||||
const payload = JSON.parse(websocketMessage.content || '{}') as {
|
||||
requestId?: number
|
||||
groupId?: number
|
||||
userId?: number
|
||||
handleContent?: string
|
||||
}
|
||||
if (!payload.requestId) {
|
||||
return
|
||||
}
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const userStore = useUserStore()
|
||||
const myId = Number(userStore.getUser?.id) || 0
|
||||
switch (websocketMessage.type) {
|
||||
case ImMessageType.GROUP_REQUEST_RECEIVED:
|
||||
groupRequestStore.addByRequestId(payload.requestId).catch(() => undefined)
|
||||
break
|
||||
case ImMessageType.GROUP_REQUEST_APPROVED:
|
||||
groupRequestStore.removeByRequestId(payload.requestId)
|
||||
if (payload.userId === myId) {
|
||||
ElNotification.success({ title: '入群申请已通过', message: '可以开始群聊了' })
|
||||
}
|
||||
break
|
||||
case ImMessageType.GROUP_REQUEST_REJECTED:
|
||||
groupRequestStore.removeByRequestId(payload.requestId)
|
||||
if (payload.userId === myId) {
|
||||
ElNotification.warning({
|
||||
title: '入群申请被拒绝',
|
||||
message: payload.handleContent || ''
|
||||
})
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 群关系事件(承载于群聊通道,按 inner type 分流) ====================
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -119,8 +119,7 @@ export interface Group {
|
|||
pinnedMessages?: Message[] // 群置顶消息列表
|
||||
mutedAll?: boolean // 是否全群禁言
|
||||
banned?: boolean // 是否被管理员封禁
|
||||
joinType?: number // 加群方式;参见 ImGroupJoinType
|
||||
pendingRequestCount?: number // 未处理加群申请数;后端关联回填,仅当登录用户是该群群主 / 管理员时非空
|
||||
joinApproval?: boolean // 进群是否需群主 / 管理员审批
|
||||
|
||||
// ========== 前端扩展字段(user-per-group 维度) ==========
|
||||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
|
|
@ -232,5 +231,5 @@ export interface GroupLite {
|
|||
showImageThumb?: string
|
||||
memberCount?: number
|
||||
ownerId?: number
|
||||
joinType?: number // 加群方式;参见 ImGroupJoinType
|
||||
joinApproval?: boolean // 进群是否需群主 / 管理员审批
|
||||
}
|
||||
|
|
@ -157,20 +157,6 @@ export const ImGroupMemberRole = {
|
|||
NORMAL: 3 // 普通成员
|
||||
} as const
|
||||
|
||||
/** 加群方式(对齐后端 ImGroupJoinTypeEnum) */
|
||||
export const ImGroupJoinType = {
|
||||
FREE: 0, // 自由进群
|
||||
APPLY: 1, // 申请需审批,邀请直进
|
||||
APPLY_AND_NORMAL_INVITE: 2 // 申请、及普通成员邀请均需审批
|
||||
} as const
|
||||
|
||||
/** 加群方式文案 */
|
||||
export const IM_GROUP_JOIN_TYPE_LABELS: Record<number, string> = {
|
||||
[ImGroupJoinType.FREE]: '自由进群',
|
||||
[ImGroupJoinType.APPLY]: '申请需审批',
|
||||
[ImGroupJoinType.APPLY_AND_NORMAL_INVITE]: '申请、及普通成员邀请均需审批'
|
||||
}
|
||||
|
||||
/** 加群来源(对齐后端 ImGroupAddSourceEnum) */
|
||||
export const ImGroupAddSource = {
|
||||
SEARCH: 1, // 搜索
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ export type GroupNotificationPayload = {
|
|||
mutedUserId?: number // 禁言目标用户
|
||||
muteEndTime?: string // 禁言到期时间
|
||||
banned?: boolean // 封禁状态
|
||||
entrantUserId?: number // 自由进群事件:进群者用户编号
|
||||
addSource?: number // 自由进群事件:来源(搜索 / 二维码 / 分享链接)
|
||||
/** PIN 事件携带的完整被置顶消息对象 */
|
||||
message?: {
|
||||
id: number
|
||||
|
|
@ -223,6 +225,11 @@ export function resolveGroupNotificationText(
|
|||
return `${operatorName} 解散了群聊`
|
||||
case ImMessageType.GROUP_MEMBER_INVITE:
|
||||
return `${operatorName} 邀请 ${memberNames} 加入群聊`
|
||||
case ImMessageType.GROUP_MEMBER_ENTER: {
|
||||
// 自由进群 / 主动申请通过:操作人 = 进群者;文案统一展示「XX 加入了群聊」
|
||||
const entrantName = payload.entrantUserId ? resolve(payload.entrantUserId) : operatorName
|
||||
return `${entrantName} 加入了群聊`
|
||||
}
|
||||
case ImMessageType.GROUP_MEMBER_QUIT:
|
||||
return `${operatorName} 退出了群聊`
|
||||
case ImMessageType.GROUP_MEMBER_KICK:
|
||||
|
|
|
|||
Loading…
Reference in New Issue