gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
<script lang="ts" setup>
import type { ImGroupRequestApi } from '#/api/im/group/request'
 
import { computed, ref, watch } from 'vue'
 
import { prompt } from '#/packages/effects/common-ui/src'
 
import { Empty, Input, message, Modal, Spin } from 'ant-design-vue'
 
import { getGroupRequestListByGroupId } from '#/api/im/group/request'
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
 
import { useGroupRequestStore } from '../../store/groupRequestStore'
import { UserAvatar } from '../user'
 
defineOptions({ name: 'ImGroupRequestListDialog' })
 
const groupRequestStore = useGroupRequestStore()
 
const visible = ref(false)
const groupId = ref<number | undefined>() // 当前展示的群编号;undefined 时走全局未处理列表(store.unhandledList)
const loading = ref(false)
const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([])
const actingId = ref<null | number>(null)
 
defineExpose({
  /** 打开进群申请弹窗:reset → 灌参 → visible=true;不传 groupId 走全局未处理列表 */
  open(opts?: { groupId?: number }) {
    groupId.value = opts?.groupId
    actingId.value = null
    visible.value = true
  }
})
 
/** 数据源:单群模式用 fetch 回来的 groupList;全局模式直接读 store.unhandledList,处理后 store 自动 reactive 同步 */
const list = computed<ImGroupRequestApi.GroupRequestRespVO[]>(() =>
  groupId.value ? groupList.value : groupRequestStore.unhandledList
)
 
/** 顶部卡片:最新一条;空数组时为 null */
const latest = computed(() => list.value[0] || null)
/** 历史列表:除最新一条外的其余 */
const histories = computed(() => list.value.slice(1))
 
/** 打开 dialog 时拉数据:单群拉 API;全局直接读 store;关闭时清掉单群缓存 */
watch(
  [visible, groupId],
  ([isVisible, currentGroupId]) => {
    if (isVisible && currentGroupId) {
      void fetchList(currentGroupId)
    } else if (!isVisible) {
      groupList.value = []
    }
  },
  { immediate: true }
)
 
/**
 * 单群模式下订阅 store 中归属本群的未处理列表变化:远端事件(WS 1503 新申请 / 其他管理员处理)触发时 refetch
 * 拿最新 handleResult;本端 agree / refuse 期间 actingId 锁住,跳过本端动作引发的 store 变化避免冗余 RTT
 *
 * key 不能只 join id:复用旧记录时同一 requestId 的 applyContent / inviterUserId 会刷新但 id 不变,必须把内容字段也纳入触发
 */
watch(
  () =>
    groupId.value && visible.value
      ? groupRequestStore.unhandledList
          .filter((request) => request.groupId === groupId.value)
          .map(
            (request) =>
              `${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
          )
          .join(',')
      : null,
  (current, previous) => {
    if (current === null || previous === undefined || current === previous) {
      return
    }
    if (actingId.value !== null) {
      return
    }
    if (groupId.value) {
      void fetchList(groupId.value)
    }
  }
)
 
let fetchSeq = 0 // 单调递增请求序号;同群也会因为 WS 1503 推送触发额外 fetch,乱序返回时旧响应不能覆盖新数据
async function fetchList(targetGroupId: number) {
  const seq = ++fetchSeq
  loading.value = true
  try {
    const data = (await getGroupRequestListByGroupId(targetGroupId)) || []
    // 期间切群 / 关弹窗 / 又触发更新 fetch:丢响应
    if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) {
      return
    }
    groupList.value = data
  } finally {
    // 旧请求 finally 命中时新请求仍在跑,跳过避免提前关 loading
    if (seq === fetchSeq) {
      loading.value = false
    }
  }
}
 
/** 同意:走 store 同步全局未处理列表 + 本地更新 handleResult 让按钮变灰 */
async function handleAgree(item: ImGroupRequestApi.GroupRequestRespVO) {
  if (actingId.value !== null) return
  actingId.value = item.id
  try {
    await groupRequestStore.agreeGroupRequest(item.id)
    updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
    message.success('已同意')
  } finally {
    actingId.value = null
  }
}
 
/** 拒绝:弹理由输入框;为空则不带 handleContent */
async function handleRefuse(item: ImGroupRequestApi.GroupRequestRespVO) {
  if (actingId.value !== null) return
  let handleContent: string
  try {
    const result = await prompt<string>({
      component: Input,
      componentProps: {
        allowClear: true,
        placeholder: '请输入拒绝理由(可选)'
      },
      content: '',
      modelPropName: 'value',
      title: '拒绝申请'
    })
    handleContent = result || ''
  } catch {
    return
  }
  actingId.value = item.id
  try {
    await groupRequestStore.refuseGroupRequest(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>
 
<template>
  <!--
    群「进群申请」列表对话框
    - 仅群主 / 管理员入口可达;展示当前群下全部申请(含已处理)
    - 顶部最新一条卡片化突出(带申请理由),其余按 id 倒序紧凑列表
    - 同意 / 拒绝走 groupRequestStore 的 action,处理后本地更新 handleResult 让按钮转灰态
  -->
  <Modal
    v-model:open="visible"
    title="进群申请"
    width="560px"
    :footer="null"
    :mask-closable="false"
    class="im-group-request-list__dialog"
  >
    <Spin :spinning="loading" wrapper-class-name="w-full">
      <div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
        <!-- 空态 -->
        <Empty v-if="!loading && list.length === 0" description="暂无进群申请" />
 
      <!-- 顶部卡片:最新一条 -->
        <div
          v-if="latest"
          class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
        >
        <div class="flex items-center gap-3">
          <UserAvatar
            :url="latest.userAvatar"
            :name="latest.userNickname"
            :size="44"
            :clickable="false"
          />
          <div class="flex-1 min-w-0">
            <div
              class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
            >
              {{ latest.userNickname || `用户 ${latest.userId}` }}
            </div>
            <div
              class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
            >
              <template v-if="latest.inviterUserId">
                通过
                <span class="text-[var(--ant-color-primary)]">
                  {{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
                </span>
                的邀请进群
              </template>
              <template v-else>申请加入</template>
            </div>
          </div>
          <span
            v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            已同意
          </span>
          <span
            v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            已拒绝
          </span>
          <div v-else class="flex gap-1.5 flex-shrink-0">
            <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="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--ant-color-fill-secondary)] text-[var(--ant-color-text)]"
        >
          <span class="text-[var(--ant-color-primary)]">
            {{
              latest.inviterUserId
                ? latest.inviterNickname || `用户 ${latest.inviterUserId}`
                : latest.userNickname || `用户 ${latest.userId}`
            }}:
          </span>
          {{ latest.applyContent }}
        </div>
      </div>
 
      <!-- 分割线:仅在有更早申请时出现 -->
        <div
          v-if="histories.length > 0"
          class="flex items-center justify-center mt-1.5 -mb-0.5 text-12px text-[var(--ant-color-text-placeholder)]"
        >
          <span>以下为更早的申请</span>
        </div>
 
        <!-- 历史申请列表 -->
        <div
          v-for="item in histories"
          :key="item.id"
          class="flex flex-col gap-2.5 px-3.5 py-2.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
        >
        <div class="flex items-center gap-3">
          <UserAvatar
            :url="item.userAvatar"
            :name="item.userNickname"
            :size="40"
            :clickable="false"
          />
          <div class="flex-1 min-w-0">
            <div
              class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
            >
              {{ item.userNickname || `用户 ${item.userId}` }}
            </div>
            <div
              class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
            >
              <template v-if="item.inviterUserId">
                通过
                <span class="text-[var(--ant-color-primary)]">
                  {{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
                </span>
                的邀请进群
              </template>
              <template v-else>申请加入</template>
            </div>
          </div>
          <span
            v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            已同意
          </span>
          <span
            v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
            class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
          >
            已拒绝
          </span>
          <div v-else class="flex gap-1.5 flex-shrink-0">
            <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>
    </Spin>
  </Modal>
</template>
 
<style scoped>
/* 自绘按钮:贴近微信小药丸样式;与 :disabled、:hover:not(:disabled) 等伪类叠加 modifier 类的组合选择器写在 class 里成本高,留 SCSS */
.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(--ant-color-primary);
  border-color: var(--ant-color-primary);
}
.im-group-request-list__btn--primary:hover:not(:disabled) {
  background-color: var(--ant-color-primary-hover);
  border-color: var(--ant-color-primary-hover);
}
 
.im-group-request-list__btn--ghost {
  color: var(--ant-color-text);
  background-color: var(--ant-color-bg-container);
  border-color: var(--ant-color-border);
}
.im-group-request-list__btn--ghost:hover:not(:disabled) {
  color: var(--ant-color-primary);
  border-color: var(--ant-color-primary);
}
</style>
 
<style>
/* el-dialog 内部 body 通过 teleport 渲染到 body,scoped 选不到,留非 scoped 全局覆盖 */
.im-group-request-list__dialog .el-dialog__body {
  padding: 12px 20px 8px;
  background-color: var(--ant-color-fill-secondary);
}
</style>