✨ feat(im): 初始化群申请 v0.3:第四把 review(优化界面,进一步对齐微信界面)
parent
0eca952c6a
commit
f746aebe08
|
|
@ -47,19 +47,6 @@ 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) {
|
||||
params.lastRequestId = lastRequestId
|
||||
}
|
||||
return request.get<ImGroupRequestRespVO[]>({
|
||||
url: '/im/group-request/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表
|
||||
export const getUnhandledRequestList = () => {
|
||||
return request.get<ImGroupRequestRespVO[]>({
|
||||
|
|
@ -67,6 +54,14 @@ export const getUnhandledRequestList = () => {
|
|||
})
|
||||
}
|
||||
|
||||
// 查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查
|
||||
export const getGroupRequestListByGroupId = (groupId: number) => {
|
||||
return request.get<ImGroupRequestRespVO[]>({
|
||||
url: '/im/group-request/list-by-group',
|
||||
params: { groupId }
|
||||
})
|
||||
}
|
||||
|
||||
// 按 id 单查申请记录(带越权过滤;WebSocket 通知到达后用)
|
||||
export const getMyGroupRequest = (id: number) => {
|
||||
return request.get<ImGroupRequestRespVO | null>({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
<template>
|
||||
<!--
|
||||
群「进群申请」列表对话框
|
||||
- 仅群主 / 管理员入口可达;展示当前群下全部申请(含已处理)
|
||||
- 顶部最新一条卡片化突出(带申请理由),其余按 id 倒序紧凑列表
|
||||
- 同意 / 拒绝走 groupRequestStore 的 action,处理后本地更新 handleResult 让按钮转灰态
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="进群申请"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
class="im-group-request-list__dialog"
|
||||
>
|
||||
<div v-loading="loading" class="im-group-request-list__body">
|
||||
<!-- 空态 -->
|
||||
<el-empty v-if="!loading && list.length === 0" description="暂无进群申请" />
|
||||
|
||||
<!-- 顶部卡片:最新一条 -->
|
||||
<div v-if="latest" class="im-group-request-list__card">
|
||||
<div class="im-group-request-list__row">
|
||||
<UserAvatar
|
||||
:url="latest.userAvatar"
|
||||
:name="latest.userNickname"
|
||||
:size="44"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-group-request-list__main">
|
||||
<div class="im-group-request-list__name truncate">
|
||||
{{ latest.userNickname || `用户 ${latest.userId}` }}
|
||||
</div>
|
||||
<div class="im-group-request-list__source truncate">
|
||||
<template v-if="latest.inviterUserId">
|
||||
通过
|
||||
<span class="im-group-request-list__inviter">
|
||||
{{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
|
||||
</span>
|
||||
的邀请进群
|
||||
</template>
|
||||
<template v-else>申请加入</template>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
|
||||
class="im-group-request-list__done"
|
||||
>
|
||||
已同意
|
||||
</span>
|
||||
<span
|
||||
v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
|
||||
class="im-group-request-list__done"
|
||||
>
|
||||
已拒绝
|
||||
</span>
|
||||
<div v-else class="im-group-request-list__actions">
|
||||
<button
|
||||
class="im-group-request-list__btn im-group-request-list__btn--primary"
|
||||
:disabled="actingId === latest.id"
|
||||
@click="handleAgree(latest)"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
class="im-group-request-list__btn im-group-request-list__btn--ghost"
|
||||
:disabled="actingId === latest.id"
|
||||
@click="handleRefuse(latest)"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 申请理由:邀请场景显示邀请人 + 留言;主动申请显示申请人 + 留言 -->
|
||||
<div v-if="latest.applyContent" class="im-group-request-list__quote">
|
||||
<span class="im-group-request-list__quote-name">
|
||||
{{
|
||||
latest.inviterUserId
|
||||
? latest.inviterNickname || `用户 ${latest.inviterUserId}`
|
||||
: latest.userNickname || `用户 ${latest.userId}`
|
||||
}}:
|
||||
</span>
|
||||
{{ latest.applyContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线:仅在有更早申请时出现 -->
|
||||
<div v-if="histories.length > 0" class="im-group-request-list__divider">
|
||||
<span>以下为更早的申请</span>
|
||||
</div>
|
||||
|
||||
<!-- 历史申请列表 -->
|
||||
<div
|
||||
v-for="item in histories"
|
||||
:key="item.id"
|
||||
class="im-group-request-list__card im-group-request-list__card--compact"
|
||||
>
|
||||
<div class="im-group-request-list__row">
|
||||
<UserAvatar
|
||||
:url="item.userAvatar"
|
||||
:name="item.userNickname"
|
||||
:size="40"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-group-request-list__main">
|
||||
<div class="im-group-request-list__name truncate">
|
||||
{{ item.userNickname || `用户 ${item.userId}` }}
|
||||
</div>
|
||||
<div class="im-group-request-list__source truncate">
|
||||
<template v-if="item.inviterUserId">
|
||||
通过
|
||||
<span class="im-group-request-list__inviter">
|
||||
{{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
|
||||
</span>
|
||||
的邀请进群
|
||||
</template>
|
||||
<template v-else>申请加入</template>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
|
||||
class="im-group-request-list__done"
|
||||
>
|
||||
已同意
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
|
||||
class="im-group-request-list__done"
|
||||
>
|
||||
已拒绝
|
||||
</span>
|
||||
<div v-else class="im-group-request-list__actions">
|
||||
<button
|
||||
class="im-group-request-list__btn im-group-request-list__btn--primary"
|
||||
:disabled="actingId === item.id"
|
||||
@click="handleAgree(item)"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
class="im-group-request-list__btn im-group-request-list__btn--ghost"
|
||||
:disabled="actingId === item.id"
|
||||
@click="handleRefuse(item)"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import { getGroupRequestListByGroupId, type ImGroupRequestRespVO } from '@/api/im/group/request'
|
||||
import { ImGroupRequestHandleResult } from '@/views/im/utils/constants'
|
||||
import { useGroupRequestStore } from '../../store/groupRequestStore'
|
||||
import UserAvatar from '../user/UserAvatar.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupRequestListDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
groupId?: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const groupList = ref<ImGroupRequestRespVO[]>([])
|
||||
const actingId = ref<number | null>(null)
|
||||
|
||||
/** 数据源:单群模式用 fetch 回来的 groupList;全局模式直接读 store.unhandledList,处理后 store 自动 reactive 同步 */
|
||||
const list = computed<ImGroupRequestRespVO[]>(() =>
|
||||
props.groupId ? groupList.value : groupRequestStore.unhandledList
|
||||
)
|
||||
|
||||
/** 顶部卡片:最新一条;空数组时为 null */
|
||||
const latest = computed(() => list.value[0] || null)
|
||||
/** 历史列表:除最新一条外的其余 */
|
||||
const histories = computed(() => list.value.slice(1))
|
||||
|
||||
/** 打开 dialog 时拉数据:单群拉 API;全局直接读 store;关闭时清掉单群缓存 */
|
||||
watch(
|
||||
() => [visible.value, props.groupId] as const,
|
||||
([open, groupId]) => {
|
||||
if (open && groupId) {
|
||||
void fetchList(groupId)
|
||||
} else if (!open) {
|
||||
groupList.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function fetchList(groupId: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
groupList.value = (await getGroupRequestListByGroupId(groupId)) || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 同意:走 store 同步全局未处理列表 + 本地更新 handleResult 让按钮变灰 */
|
||||
async function handleAgree(item: ImGroupRequestRespVO) {
|
||||
if (actingId.value !== null) return
|
||||
actingId.value = item.id
|
||||
try {
|
||||
await groupRequestStore.agreeRequest(item.id)
|
||||
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
|
||||
message.success('已同意')
|
||||
} finally {
|
||||
actingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 拒绝:弹理由输入框;为空则不带 handleContent */
|
||||
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)
|
||||
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
|
||||
message.success('已拒绝')
|
||||
} finally {
|
||||
actingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 单群模式下处理后更新 groupList 里的 handleResult,按钮转「已同意 / 已拒绝」灰态;全局模式 store 直接移除该项无需更新 */
|
||||
function updateLocalResult(id: number, handleResult: number) {
|
||||
const target = groupList.value.find((r) => r.id === id)
|
||||
if (target) {
|
||||
target.handleResult = handleResult
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-group-request-list__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.im-group-request-list__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.im-group-request-list__card--compact {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.im-group-request-list__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.im-group-request-list__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.im-group-request-list__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.im-group-request-list__source {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.im-group-request-list__inviter {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.im-group-request-list__quote {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.im-group-request-list__quote-name {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.im-group-request-list__divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 6px 0 -2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.im-group-request-list__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 自绘按钮:贴近微信小药丸样式;el-button 默认尺寸偏大、圆角偏方 */
|
||||
.im-group-request-list__btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 56px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.im-group-request-list__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.im-group-request-list__btn--primary {
|
||||
color: #fff;
|
||||
background-color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.im-group-request-list__btn--primary:hover:not(:disabled) {
|
||||
background-color: var(--el-color-primary-light-3);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
.im-group-request-list__btn--ghost {
|
||||
color: var(--el-text-color-regular);
|
||||
background-color: var(--el-bg-color);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
.im-group-request-list__btn--ghost:hover:not(:disabled) {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.im-group-request-list__done {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.im-group-request-list__dialog .el-dialog__body {
|
||||
padding: 12px 20px 8px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -57,6 +57,13 @@ const { readActive, syncPrivateReadStatus } = useMessageSender()
|
|||
|
||||
/** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
|
||||
onMounted(async () => {
|
||||
// 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程
|
||||
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
|
||||
// 0.2 我管理的群下未处理加群申请:会话列表全局入口 / 群顶部横幅都从这份 store 派生;后台拉,不阻断
|
||||
void groupRequestStore.fetchUnhandledList().catch((e) =>
|
||||
console.warn('[IM] 拉取未处理加群申请失败', e)
|
||||
)
|
||||
|
||||
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||
conversationStore.loading = true
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -272,13 +272,34 @@
|
|||
<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>
|
||||
|
||||
<!-- ==================== 进群审批 ==================== -->
|
||||
<!-- 单独一段:群主开关 + 紧跟「- 进群申请」子项;与微信群管理布局对齐 -->
|
||||
<template v-if="isOwner || (isOwnerOrAdmin && !!group.joinApproval)">
|
||||
<div class="im-conversation-group-side__spacer"></div>
|
||||
<div class="im-conversation-group-side__section">
|
||||
<!-- 进群审批:仅群主可操作;开启后普通成员的「申请」「邀请」路径都需群主 / 管理员同意;群主 / 管理员邀请直进 -->
|
||||
<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>
|
||||
<!-- 进群申请子项:仅当开启审批 + 当前用户是 owner / admin 时出现;点击进列表 dialog -->
|
||||
<div
|
||||
v-if="isOwnerOrAdmin && !!group.joinApproval"
|
||||
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
|
||||
@click="requestListVisible = true"
|
||||
>
|
||||
<span class="im-conversation-group-side__label">- 进群申请</span>
|
||||
<Icon
|
||||
icon="ant-design:right-outlined"
|
||||
:size="11"
|
||||
class="im-conversation-group-side__chevron"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== 群主操作 ==================== -->
|
||||
<!-- 仅群主可见,含管理员设置 + 群主管理权转让 -->
|
||||
<template v-if="isOwner">
|
||||
|
|
@ -371,6 +392,9 @@
|
|||
:max-size="1"
|
||||
@complete="handleTransferOwnerComplete"
|
||||
/>
|
||||
|
||||
<!-- 进群申请列表(仅当开启审批 + 当前用户是 owner / admin 时入口可见) -->
|
||||
<GroupRequestListDialog v-model="requestListVisible" :group-id="group?.id" />
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
|
|
@ -402,6 +426,7 @@ import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDia
|
|||
import GroupMemberSelector, {
|
||||
type GroupMemberFlag
|
||||
} from '../../../../components/group/GroupMemberSelector.vue'
|
||||
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue'
|
||||
import type { Conversation, FriendLite, GroupLite } from '../../../../types'
|
||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||
|
||||
|
|
@ -445,6 +470,7 @@ const inviteVisible = ref(false)
|
|||
const removeVisible = ref(false)
|
||||
const adminVisible = ref(false)
|
||||
const transferOwnerVisible = ref(false)
|
||||
const requestListVisible = ref(false)
|
||||
const showAllMembers = ref(false)
|
||||
const namePopoverVisible = ref(false)
|
||||
const noticePopoverVisible = ref(false)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mt-1 leading-5">
|
||||
<!-- 进群申请红字前缀:群主 / 管理员看到自己管理的群下还有未处理申请时显示 -->
|
||||
<span v-if="requestText" class="flex-shrink-0 text-12px text-[#c70b0b]">
|
||||
{{ requestText }}
|
||||
</span>
|
||||
<!-- @红字提示:atMe 优先于 atAll -->
|
||||
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span>
|
||||
<!-- 群聊最后一条发送者前缀:按 lastSenderId + 当前会话上下文实时算名字 -->
|
||||
|
|
@ -78,6 +82,7 @@ import { useMessage } from '@/hooks/web/useMessage'
|
|||
import { useConversationStore } from '../../../../store/conversationStore'
|
||||
import { useFriendStore } from '../../../../store/friendStore'
|
||||
import { useGroupStore } from '../../../../store/groupStore'
|
||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||
import { useImUiStore } from '../../../../store/uiStore'
|
||||
import { useDraftStore } from '../../../../store/draftStore'
|
||||
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
|
||||
|
|
@ -98,6 +103,7 @@ const props = defineProps<{
|
|||
const conversationStore = useConversationStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const uiStore = useImUiStore()
|
||||
const draftStore = useDraftStore()
|
||||
const message = useMessage()
|
||||
|
|
@ -176,6 +182,15 @@ const atText = computed(() => {
|
|||
return ''
|
||||
})
|
||||
|
||||
/** 群聊未处理加群申请红字前缀;store 已经按「我管理的群」过滤过,count > 0 即可显示 */
|
||||
const requestText = computed(() => {
|
||||
if (!isGroup.value) {
|
||||
return ''
|
||||
}
|
||||
const count = groupRequestStore.getUnhandledCountByGroupId(props.conversation.targetId)
|
||||
return count > 0 ? `[${count}条进群申请]` : ''
|
||||
})
|
||||
|
||||
/** 点击切会话 */
|
||||
function handleClick() {
|
||||
conversationStore.setActiveConversation(props.conversation)
|
||||
|
|
|
|||
|
|
@ -2,79 +2,38 @@
|
|||
<!--
|
||||
群顶部「待处理加群申请」横幅
|
||||
- 仅当登录用户是该群 owner / admin 且该群下未处理申请数 > 0 时显示
|
||||
- count 与 list 都从 groupRequestStore 派生(全局存);本端处理 / WS 通知到达后 store 自动更新
|
||||
- 单条胶囊一行;点击展开下拉,每条带「同意 / 拒绝」按钮
|
||||
- count 从 groupRequestStore 派生(全局存);本端处理 / WS 通知到达后 store 自动更新
|
||||
- 点击横幅打开 GroupRequestListDialog(含历史已处理记录),不再就地展开
|
||||
-->
|
||||
<!-- 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"
|
||||
>
|
||||
<div class="im-conversation-group-request__row" @click="dialogVisible = true">
|
||||
<Icon
|
||||
icon="ant-design:user-add-outlined"
|
||||
:size="14"
|
||||
class="im-conversation-group-request__icon"
|
||||
/>
|
||||
<span class="im-conversation-group-request__text">
|
||||
{{ pendingCount }} 条新的入群申请待处理
|
||||
</span>
|
||||
<span class="im-conversation-group-request__text"> 新进群申请({{ pendingCount }}) </span>
|
||||
<Icon
|
||||
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
||||
icon="ant-design:right-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>
|
||||
<!-- 申请列表 dialog:复用同一组件,避免群管理面板与会话顶部各写一份 -->
|
||||
<GroupRequestListDialog v-model="dialogVisible" :group-id="groupId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } 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'
|
||||
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue'
|
||||
|
||||
defineOptions({ name: 'ImConversationGroupRequestPending' })
|
||||
|
||||
|
|
@ -85,10 +44,8 @@ const props = defineProps<{
|
|||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const groupRequestStore = useGroupRequestStore()
|
||||
const message = useMessage()
|
||||
|
||||
const expanded = ref(false)
|
||||
const actingId = ref<number | null>(null)
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
/** 当前群(含 ownerUserId / members) */
|
||||
const group = computed(() => groupStore.getGroup(props.groupId))
|
||||
|
|
@ -109,62 +66,11 @@ const canManage = computed(
|
|||
|
||||
/** 当前群未处理申请数;从 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>
|
||||
/* 容器:align-items flex-start 让胶囊靠左、不占整行;高度由内容撑开,与置顶消息横幅节奏对齐 */
|
||||
.im-conversation-group-request {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -173,28 +79,31 @@ async function handleRefuse(item: ImGroupRequestRespVO) {
|
|||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* 胶囊本体:内容自适应宽度,padding / 圆角 / 阴影对齐 ConversationGroupPinned 的 __row */
|
||||
.im-conversation-group-request__row {
|
||||
display: flex;
|
||||
display: inline-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;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.im-conversation-group-request__row--clickable:hover {
|
||||
.im-conversation-group-request__row:hover {
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
/* 绿色「加好友」icon:与置顶消息黄色 pushpin 同节奏,仅换色调;svg 强制 currentColor 应对暗色覆盖 */
|
||||
.im-conversation-group-request__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.im-conversation-group-request__icon :deep(svg) {
|
||||
fill: currentColor !important;
|
||||
}
|
||||
|
||||
.im-conversation-group-request__text {
|
||||
|
|
@ -209,59 +118,4 @@ async function handleRefuse(item: ImGroupRequestRespVO) {
|
|||
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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue