✨ 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 列表
|
// 查询「我管理的所有群」下的未处理加群申请列表(不分页);前端 store 据此派生横幅红点 + Drawer 列表
|
||||||
export const getUnhandledRequestList = () => {
|
export const getUnhandledRequestList = () => {
|
||||||
return request.get<ImGroupRequestRespVO[]>({
|
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 通知到达后用)
|
// 按 id 单查申请记录(带越权过滤;WebSocket 通知到达后用)
|
||||||
export const getMyGroupRequest = (id: number) => {
|
export const getMyGroupRequest = (id: number) => {
|
||||||
return request.get<ImGroupRequestRespVO | null>({
|
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 () => {
|
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 跳过断线积压消息
|
// 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
|
||||||
conversationStore.loading = true
|
conversationStore.loading = true
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -272,13 +272,34 @@
|
||||||
<span class="im-conversation-group-side__label">全群禁言</span>
|
<span class="im-conversation-group-side__label">全群禁言</span>
|
||||||
<el-switch :model-value="!!currentMutedAll" @change="onMuteAllChange" />
|
<el-switch :model-value="!!currentMutedAll" @change="onMuteAllChange" />
|
||||||
</div>
|
</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>
|
</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">
|
<template v-if="isOwner">
|
||||||
|
|
@ -371,6 +392,9 @@
|
||||||
:max-size="1"
|
:max-size="1"
|
||||||
@complete="handleTransferOwnerComplete"
|
@complete="handleTransferOwnerComplete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 进群申请列表(仅当开启审批 + 当前用户是 owner / admin 时入口可见) -->
|
||||||
|
<GroupRequestListDialog v-model="requestListVisible" :group-id="group?.id" />
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -402,6 +426,7 @@ import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDia
|
||||||
import GroupMemberSelector, {
|
import GroupMemberSelector, {
|
||||||
type GroupMemberFlag
|
type GroupMemberFlag
|
||||||
} from '../../../../components/group/GroupMemberSelector.vue'
|
} from '../../../../components/group/GroupMemberSelector.vue'
|
||||||
|
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue'
|
||||||
import type { Conversation, FriendLite, GroupLite } from '../../../../types'
|
import type { Conversation, FriendLite, GroupLite } from '../../../../types'
|
||||||
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue'
|
||||||
|
|
||||||
|
|
@ -445,6 +470,7 @@ const inviteVisible = ref(false)
|
||||||
const removeVisible = ref(false)
|
const removeVisible = ref(false)
|
||||||
const adminVisible = ref(false)
|
const adminVisible = ref(false)
|
||||||
const transferOwnerVisible = ref(false)
|
const transferOwnerVisible = ref(false)
|
||||||
|
const requestListVisible = ref(false)
|
||||||
const showAllMembers = ref(false)
|
const showAllMembers = ref(false)
|
||||||
const namePopoverVisible = ref(false)
|
const namePopoverVisible = ref(false)
|
||||||
const noticePopoverVisible = ref(false)
|
const noticePopoverVisible = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mt-1 leading-5">
|
<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 -->
|
<!-- @红字提示:atMe 优先于 atAll -->
|
||||||
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span>
|
<span v-if="atText" class="flex-shrink-0 text-12px text-[#c70b0b]">{{ atText }}</span>
|
||||||
<!-- 群聊最后一条发送者前缀:按 lastSenderId + 当前会话上下文实时算名字 -->
|
<!-- 群聊最后一条发送者前缀:按 lastSenderId + 当前会话上下文实时算名字 -->
|
||||||
|
|
@ -78,6 +82,7 @@ import { useMessage } from '@/hooks/web/useMessage'
|
||||||
import { useConversationStore } from '../../../../store/conversationStore'
|
import { useConversationStore } from '../../../../store/conversationStore'
|
||||||
import { useFriendStore } from '../../../../store/friendStore'
|
import { useFriendStore } from '../../../../store/friendStore'
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
import { useGroupStore } from '../../../../store/groupStore'
|
||||||
|
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||||
import { useImUiStore } from '../../../../store/uiStore'
|
import { useImUiStore } from '../../../../store/uiStore'
|
||||||
import { useDraftStore } from '../../../../store/draftStore'
|
import { useDraftStore } from '../../../../store/draftStore'
|
||||||
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
|
import { ImConversationType, ImMessageType, isNormalMessage } from '../../../../../utils/constants'
|
||||||
|
|
@ -98,6 +103,7 @@ const props = defineProps<{
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const friendStore = useFriendStore()
|
const friendStore = useFriendStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const groupRequestStore = useGroupRequestStore()
|
||||||
const uiStore = useImUiStore()
|
const uiStore = useImUiStore()
|
||||||
const draftStore = useDraftStore()
|
const draftStore = useDraftStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
@ -176,6 +182,15 @@ const atText = computed(() => {
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 群聊未处理加群申请红字前缀;store 已经按「我管理的群」过滤过,count > 0 即可显示 */
|
||||||
|
const requestText = computed(() => {
|
||||||
|
if (!isGroup.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const count = groupRequestStore.getUnhandledCountByGroupId(props.conversation.targetId)
|
||||||
|
return count > 0 ? `[${count}条进群申请]` : ''
|
||||||
|
})
|
||||||
|
|
||||||
/** 点击切会话 */
|
/** 点击切会话 */
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
conversationStore.setActiveConversation(props.conversation)
|
conversationStore.setActiveConversation(props.conversation)
|
||||||
|
|
|
||||||
|
|
@ -2,79 +2,38 @@
|
||||||
<!--
|
<!--
|
||||||
群顶部「待处理加群申请」横幅
|
群顶部「待处理加群申请」横幅
|
||||||
- 仅当登录用户是该群 owner / admin 且该群下未处理申请数 > 0 时显示
|
- 仅当登录用户是该群 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 v-if="canManage && pendingCount > 0" class="im-conversation-group-request">
|
||||||
<div
|
<div class="im-conversation-group-request__row" @click="dialogVisible = true">
|
||||||
class="im-conversation-group-request__row im-conversation-group-request__row--clickable"
|
|
||||||
@click="expanded = !expanded"
|
|
||||||
>
|
|
||||||
<Icon
|
<Icon
|
||||||
icon="ant-design:user-add-outlined"
|
icon="ant-design:user-add-outlined"
|
||||||
:size="14"
|
:size="14"
|
||||||
class="im-conversation-group-request__icon"
|
class="im-conversation-group-request__icon"
|
||||||
/>
|
/>
|
||||||
<span class="im-conversation-group-request__text">
|
<span class="im-conversation-group-request__text"> 新进群申请({{ pendingCount }}) </span>
|
||||||
{{ pendingCount }} 条新的入群申请待处理
|
|
||||||
</span>
|
|
||||||
<Icon
|
<Icon
|
||||||
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
|
icon="ant-design:right-outlined"
|
||||||
:size="11"
|
:size="11"
|
||||||
class="im-conversation-group-request__chevron"
|
class="im-conversation-group-request__chevron"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开列表面板:浅色面板 + 每条独立卡片,行内含「同意 / 拒绝」按钮 -->
|
<!-- 申请列表 dialog:复用同一组件,避免群管理面板与会话顶部各写一份 -->
|
||||||
<div v-if="expanded" class="im-conversation-group-request__list">
|
<GroupRequestListDialog v-model="dialogVisible" :group-id="groupId" />
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import Icon from '@/components/Icon/src/Icon.vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { useMessage } from '@/hooks/web/useMessage'
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
|
||||||
import type { ImGroupRequestRespVO } from '@/api/im/group/request'
|
|
||||||
import { ImGroupMemberRole } from '@/views/im/utils/constants'
|
import { ImGroupMemberRole } from '@/views/im/utils/constants'
|
||||||
import { useGroupStore } from '../../../../store/groupStore'
|
import { useGroupStore } from '../../../../store/groupStore'
|
||||||
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
|
||||||
import UserAvatar from '../../../../components/user/UserAvatar.vue'
|
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ImConversationGroupRequestPending' })
|
defineOptions({ name: 'ImConversationGroupRequestPending' })
|
||||||
|
|
||||||
|
|
@ -85,10 +44,8 @@ const props = defineProps<{
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const groupRequestStore = useGroupRequestStore()
|
const groupRequestStore = useGroupRequestStore()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const expanded = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const actingId = ref<number | null>(null)
|
|
||||||
|
|
||||||
/** 当前群(含 ownerUserId / members) */
|
/** 当前群(含 ownerUserId / members) */
|
||||||
const group = computed(() => groupStore.getGroup(props.groupId))
|
const group = computed(() => groupStore.getGroup(props.groupId))
|
||||||
|
|
@ -109,62 +66,11 @@ const canManage = computed(
|
||||||
|
|
||||||
/** 当前群未处理申请数;从 store 派生 */
|
/** 当前群未处理申请数;从 store 派生 */
|
||||||
const pendingCount = computed(() => groupRequestStore.getUnhandledCountByGroupId(props.groupId))
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 容器:align-items flex-start 让胶囊靠左、不占整行;高度由内容撑开,与置顶消息横幅节奏对齐 */
|
||||||
.im-conversation-group-request {
|
.im-conversation-group-request {
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -173,28 +79,31 @@ async function handleRefuse(item: ImGroupRequestRespVO) {
|
||||||
background-color: var(--el-fill-color-light);
|
background-color: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 胶囊本体:内容自适应宽度,padding / 圆角 / 阴影对齐 ConversationGroupPinned 的 __row */
|
||||||
.im-conversation-group-request__row {
|
.im-conversation-group-request__row {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 360px;
|
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background-color: var(--el-bg-color);
|
background-color: var(--el-bg-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--el-text-color-primary);
|
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;
|
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);
|
background-color: var(--el-fill-color-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 绿色「加好友」icon:与置顶消息黄色 pushpin 同节奏,仅换色调;svg 强制 currentColor 应对暗色覆盖 */
|
||||||
.im-conversation-group-request__icon {
|
.im-conversation-group-request__icon {
|
||||||
flex-shrink: 0;
|
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 {
|
.im-conversation-group-request__text {
|
||||||
|
|
@ -209,59 +118,4 @@ async function handleRefuse(item: ImGroupRequestRespVO) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--el-text-color-placeholder);
|
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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue