feat(im): fuzzy member search with chip tags in create-group dialog

- ImManagementView: replace textarea with debounced search input + closable el-tag chips
- im.ts: add searchUsers(keyword) API call to im-service admin search endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-04-25 16:41:55 +08:00
父节点 e27a9ef917
当前提交 6c44354f2b
共有 2 个文件被更改,包括 126 次插入8 次删除

查看文件

@ -73,4 +73,11 @@ export const imAdminApi = {
{ params: { appId } }, { params: { appId } },
) )
}, },
searchUsers(appId: string, keyword: string, size = 20) {
return imClient.get<{ data: ImUser[] }>(
'/api/im/admin/users/search',
{ params: { appId, keyword, size } },
)
},
} }

查看文件

@ -97,13 +97,47 @@
</el-dialog> </el-dialog>
<!-- Create Group Dialog --> <!-- Create Group Dialog -->
<el-dialog v-model="showCreateGroup" title="创建群组" width="420px"> <el-dialog v-model="showCreateGroup" title="创建群组" width="480px" @closed="resetCreateGroupForm">
<el-form :model="createGroupForm" label-width="90px"> <el-form :model="createGroupForm" label-width="90px">
<el-form-item label="群名称"><el-input v-model="createGroupForm.name" /></el-form-item> <el-form-item label="群名称"><el-input v-model="createGroupForm.name" /></el-form-item>
<el-form-item label="创建者ID"><el-input v-model="createGroupForm.creatorId" /></el-form-item> <el-form-item label="创建者ID"><el-input v-model="createGroupForm.creatorId" /></el-form-item>
<el-form-item label="初始成员"> <el-form-item label="搜索成员">
<el-input v-model="createGroupForm.memberIdsRaw" type="textarea" :rows="3" <el-input
placeholder="每行一个用户ID" /> v-model="memberSearchKeyword"
:prefix-icon="Search"
placeholder="输入昵称或用户ID搜索"
clearable
@input="onMemberSearchInput"
/>
<!-- Search results list -->
<div v-if="memberSearchResults.length > 0" class="search-results">
<div
v-for="user in memberSearchResults"
:key="user.userId"
class="search-result-item"
@click="addMember(user)"
>
<span class="result-nickname">{{ user.nickname || user.userId }}</span>
<span class="result-userid">{{ user.userId }}</span>
</div>
</div>
<el-empty v-else-if="memberSearchKeyword && !searchingMembers && memberSearchResults.length === 0"
description="未找到匹配用户" :image-size="48" style="padding:8px 0" />
</el-form-item>
<el-form-item label="已选成员">
<div class="selected-members">
<el-tag
v-for="m in selectedMembers"
:key="m.userId"
closable
type="primary"
style="margin:3px"
@close="removeMember(m.userId)"
>
{{ m.nickname || m.userId }}
</el-tag>
<span v-if="selectedMembers.length === 0" class="no-members-hint">暂未选择成员</span>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@ -118,6 +152,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { imAdminApi, type ImUser, type ImGroup, type ImStats } from '@/api/im' import { imAdminApi, type ImUser, type ImGroup, type ImStats } from '@/api/im'
const route = useRoute() const route = useRoute()
@ -141,7 +176,55 @@ const registerForm = ref({ userId: '', nickname: '' })
const showCreateGroup = ref(false) const showCreateGroup = ref(false)
const submittingCreateGroup = ref(false) const submittingCreateGroup = ref(false)
const createGroupForm = ref({ name: '', creatorId: '', memberIdsRaw: '' }) const createGroupForm = ref({ name: '', creatorId: '' })
// Member fuzzy search
const memberSearchKeyword = ref('')
const memberSearchResults = ref<ImUser[]>([])
const searchingMembers = ref(false)
const selectedMembers = ref<ImUser[]>([])
let memberSearchTimer: ReturnType<typeof setTimeout> | null = null
function onMemberSearchInput() {
if (memberSearchTimer) clearTimeout(memberSearchTimer)
const kw = memberSearchKeyword.value.trim()
if (!kw) {
memberSearchResults.value = []
return
}
memberSearchTimer = setTimeout(async () => {
searchingMembers.value = true
try {
const res = await imAdminApi.searchUsers(appId, kw)
// Exclude already-selected members from results
const selectedIds = new Set(selectedMembers.value.map(m => m.userId))
memberSearchResults.value = res.data.data.filter(u => !selectedIds.has(u.userId))
} catch {
memberSearchResults.value = []
} finally {
searchingMembers.value = false
}
}, 300)
}
function addMember(user: ImUser) {
if (!selectedMembers.value.find(m => m.userId === user.userId)) {
selectedMembers.value.push(user)
}
memberSearchResults.value = memberSearchResults.value.filter(u => u.userId !== user.userId)
memberSearchKeyword.value = ''
}
function removeMember(userId: string) {
selectedMembers.value = selectedMembers.value.filter(m => m.userId !== userId)
}
function resetCreateGroupForm() {
createGroupForm.value = { name: '', creatorId: '' }
memberSearchKeyword.value = ''
memberSearchResults.value = []
selectedMembers.value = []
}
const statCards = computed(() => [ const statCards = computed(() => [
{ label: '注册用户', value: stats.value?.totalUsers ?? '-' }, { label: '注册用户', value: stats.value?.totalUsers ?? '-' },
@ -226,12 +309,10 @@ async function submitCreateGroup() {
} }
submittingCreateGroup.value = true submittingCreateGroup.value = true
try { try {
const memberIds = createGroupForm.value.memberIdsRaw const memberIds = selectedMembers.value.map(m => m.userId)
.split('\n').map(s => s.trim()).filter(Boolean)
await imAdminApi.createGroup(appId, createGroupForm.value.name, createGroupForm.value.creatorId, memberIds) await imAdminApi.createGroup(appId, createGroupForm.value.name, createGroupForm.value.creatorId, memberIds)
ElMessage.success('群组创建成功') ElMessage.success('群组创建成功')
showCreateGroup.value = false showCreateGroup.value = false
createGroupForm.value = { name: '', creatorId: '', memberIdsRaw: '' }
loadGroups() loadGroups()
loadStats() loadStats()
} catch { } catch {
@ -252,4 +333,34 @@ onMounted(() => {
.stat-value { font-size: 28px; font-weight: 800; color: #1f2933; } .stat-value { font-size: 28px; font-weight: 800; color: #1f2933; }
.stat-label { font-size: 13px; color: #6b7280; } .stat-label { font-size: 13px; color: #6b7280; }
.toolbar { margin-bottom: 12px; } .toolbar { margin-bottom: 12px; }
.search-results {
width: 100%;
margin-top: 4px;
border: 1px solid #e4e7ed;
border-radius: 4px;
max-height: 180px;
overflow-y: auto;
background: #fff;
}
.search-result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
cursor: pointer;
transition: background 0.15s;
}
.search-result-item:hover { background: #f0f4ff; }
.result-nickname { font-weight: 500; color: #303133; }
.result-userid { font-size: 12px; color: #909399; }
.selected-members {
display: flex;
flex-wrap: wrap;
align-items: center;
min-height: 32px;
width: 100%;
}
.no-members-hint { font-size: 13px; color: #c0c4cc; }
</style> </style>