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>
这个提交包含在:
父节点
e27a9ef917
当前提交
6c44354f2b
@ -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>
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户