✨ feat(im): 增加 pinyin 功能
parent
d19bdd42d5
commit
0ab8b292f2
|
|
@ -6,11 +6,13 @@ export interface ImFriendRespVO {
|
||||||
friendUserId: number // 好友的用户编号
|
friendUserId: number // 好友的用户编号
|
||||||
muted?: boolean // 是否免打扰
|
muted?: boolean // 是否免打扰
|
||||||
displayName?: string // 好友展示备注(仅自己可见)
|
displayName?: string // 好友展示备注(仅自己可见)
|
||||||
|
displayNamePinyin?: string // 备注的拼音(小写无空格,前端按首字母分桶 / 拼音搜索)
|
||||||
status?: number // 好友状态(0=正常,1=已删除)
|
status?: number // 好友状态(0=正常,1=已删除)
|
||||||
addTime?: string // 添加好友时间
|
addTime?: string // 添加好友时间
|
||||||
deleteTime?: string // 删除好友时间
|
deleteTime?: string // 删除好友时间
|
||||||
// 聚合字段(自 AdminUser)
|
// 聚合字段(自 AdminUser)
|
||||||
nickname?: string // 好友昵称
|
nickname?: string // 好友昵称
|
||||||
|
nicknamePinyin?: string // 昵称的拼音(小写无空格,前端按首字母分桶 / 拼音搜索)
|
||||||
avatar?: string // 好友头像
|
avatar?: string // 好友头像
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,18 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const expanded = ref(true)
|
const expanded = ref(true)
|
||||||
|
|
||||||
/** 关键字过滤:兼顾备注 + 原昵称,记不住哪个就按哪个搜 */
|
/** 拼音首字母拼接:「lao zhang」→ "lz",用于支持「输 lz 搜老张」 */
|
||||||
|
function pinyinInitials(pinyin?: string): string {
|
||||||
|
if (!pinyin) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return pinyin
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word.charAt(0))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键字过滤:备注 / 昵称 / 全拼 / 首字母任一命中即可,记不住哪个就按哪个搜 */
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const keywordLower = props.keyword.trim().toLowerCase()
|
const keywordLower = props.keyword.trim().toLowerCase()
|
||||||
return props.friends.filter((friend) => {
|
return props.friends.filter((friend) => {
|
||||||
|
|
@ -74,47 +85,62 @@ const filtered = computed(() => {
|
||||||
if (!keywordLower) {
|
if (!keywordLower) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// 全拼搜索去掉空格,让「laozhang」也能命中「lao zhang」
|
||||||
|
const nicknamePinyin = friend.nicknamePinyin || ''
|
||||||
|
const displayNamePinyin = friend.displayNamePinyin || ''
|
||||||
return (
|
return (
|
||||||
(friend.nickname || '').toLowerCase().includes(keywordLower) ||
|
(friend.nickname || '').toLowerCase().includes(keywordLower) ||
|
||||||
(friend.displayName || '').toLowerCase().includes(keywordLower)
|
(friend.displayName || '').toLowerCase().includes(keywordLower) ||
|
||||||
|
nicknamePinyin.replace(/\s/g, '').includes(keywordLower) ||
|
||||||
|
displayNamePinyin.replace(/\s/g, '').includes(keywordLower) ||
|
||||||
|
pinyinInitials(nicknamePinyin).includes(keywordLower) ||
|
||||||
|
pinyinInitials(displayNamePinyin).includes(keywordLower)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** 桶排序键:优先备注拼音,回落昵称拼音 / 名字本身(兜底英文 / 数字场景) */
|
||||||
* 字母分桶:
|
function getSortKey(friend: FriendLite): string {
|
||||||
* - ASCII 字母直接取首字母大写
|
return (
|
||||||
* - 中文 / 其它非拉丁字符统一进 "#"(项目未引 pinyin 库,留中文按 "#" 显示,避免引入新依赖)
|
friend.displayNamePinyin ||
|
||||||
* - 桶内按显示名 localeCompare 自然序
|
friend.nicknamePinyin ||
|
||||||
*/
|
(friend.displayName || friend.nickname || '').toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取分桶字母:拼音首字母大写,非字母(如纯符号)兜底 "#" */
|
||||||
|
function getBucketLetter(friend: FriendLite): string {
|
||||||
|
const first = getSortKey(friend).charAt(0)
|
||||||
|
return /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#'
|
||||||
|
}
|
||||||
|
|
||||||
interface FriendBucket {
|
interface FriendBucket {
|
||||||
letter: string
|
letter: string
|
||||||
list: FriendLite[]
|
list: FriendLite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:需要增加拼音返回;我们要讨论下,hutool 有拼音库;可能要引入下库;
|
/** 字母分桶:A-Z 优先,"#" 兜底;桶内按拼音 / 名字自然序 */
|
||||||
const buckets = computed<FriendBucket[]>(() => {
|
const buckets = computed<FriendBucket[]>(() => {
|
||||||
const map = new Map<string, FriendLite[]>()
|
const map = new Map<string, FriendLite[]>()
|
||||||
for (const friend of filtered.value) {
|
for (const friend of filtered.value) {
|
||||||
const name = (friend.displayName || friend.nickname || '').trim()
|
const letter = getBucketLetter(friend)
|
||||||
const first = name.charAt(0)
|
|
||||||
const letter = /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#'
|
|
||||||
if (!map.has(letter)) {
|
if (!map.has(letter)) {
|
||||||
map.set(letter, [])
|
map.set(letter, [])
|
||||||
}
|
}
|
||||||
map.get(letter)!.push(friend)
|
map.get(letter)!.push(friend)
|
||||||
}
|
}
|
||||||
// letter 排序:A-Z 在前,"#" 兜底
|
|
||||||
const letters = Array.from(map.keys()).sort((a, b) => {
|
const letters = Array.from(map.keys()).sort((a, b) => {
|
||||||
if (a === '#') return 1
|
if (a === '#') {
|
||||||
if (b === '#') return -1
|
return 1
|
||||||
|
}
|
||||||
|
if (b === '#') {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
return a.localeCompare(b)
|
return a.localeCompare(b)
|
||||||
})
|
})
|
||||||
return letters.map((letter) => ({
|
return letters.map((letter) => ({
|
||||||
letter,
|
letter,
|
||||||
list: map.get(letter)!.sort((a, b) =>
|
list: map.get(letter)!.sort((a, b) => getSortKey(a).localeCompare(getSortKey(b)))
|
||||||
(a.displayName || a.nickname || '').localeCompare(b.displayName || b.nickname || '')
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -108,12 +108,15 @@ type Selection = { type: 'friend'; friend: FriendLite } | { type: 'group'; group
|
||||||
const selection = ref<Selection | null>(null)
|
const selection = ref<Selection | null>(null)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
|
|
||||||
|
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
|
||||||
const friends = computed<FriendLite[]>(() =>
|
const friends = computed<FriendLite[]>(() =>
|
||||||
friendStore.getActiveFriends.map((friend: Friend) => ({
|
friendStore.getActiveFriends.map((friend: Friend) => ({
|
||||||
id: friend.friendUserId,
|
id: friend.friendUserId,
|
||||||
nickname: friend.nickname,
|
nickname: friend.nickname,
|
||||||
|
nicknamePinyin: friend.nicknamePinyin,
|
||||||
avatar: friend.avatar,
|
avatar: friend.avatar,
|
||||||
displayName: friend.displayName,
|
displayName: friend.displayName,
|
||||||
|
displayNamePinyin: friend.displayNamePinyin,
|
||||||
deleted: friend.status === CommonStatusEnum.DISABLE
|
deleted: friend.status === CommonStatusEnum.DISABLE
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
<template>
|
|
||||||
<!--
|
|
||||||
添加好友对话框
|
|
||||||
- 搜索用户(按用户名 / 昵称),列出结果
|
|
||||||
- 点"添加"发送好友申请
|
|
||||||
- TODO 好友模块后端 API 接入后替换 searchUsers / addFriend 的调用
|
|
||||||
-->
|
|
||||||
<el-dialog
|
|
||||||
v-model="visible"
|
|
||||||
title="添加好友"
|
|
||||||
width="480px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
>
|
|
||||||
<el-input
|
|
||||||
v-model="keyword"
|
|
||||||
placeholder="输入用户名或昵称回车搜索(最多展示 20 条)"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<el-icon class="im-add-friend__search-icon" @click="handleSearch">
|
|
||||||
<Search />
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
|
|
||||||
<el-scrollbar class="im-add-friend__result">
|
|
||||||
<div v-if="users.length === 0" class="im-add-friend__empty">
|
|
||||||
{{ searched ? '没有搜到用户' : '输入关键字后回车开始搜索' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
v-show="String(user.id) !== currentUserId"
|
|
||||||
class="im-add-friend__item"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
:id="user.id"
|
|
||||||
:url="user.headImage"
|
|
||||||
:name="user.nickName"
|
|
||||||
:online="user.online"
|
|
||||||
:size="42"
|
|
||||||
:clickable="false"
|
|
||||||
/>
|
|
||||||
<div class="im-add-friend__user-info">
|
|
||||||
<div class="im-add-friend__nick">
|
|
||||||
{{ user.nickName }}
|
|
||||||
<span class="im-add-friend__tag" :class="{ 'is-online': user.online }">
|
|
||||||
{{ user.online ? '[在线]' : '[离线]' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-button
|
|
||||||
v-if="!isFriend(user.id)"
|
|
||||||
type="primary"
|
|
||||||
size="small"
|
|
||||||
@click="handleAdd(user)"
|
|
||||||
>
|
|
||||||
添加
|
|
||||||
</el-button>
|
|
||||||
<el-button v-else size="small" disabled>已添加</el-button>
|
|
||||||
</div>
|
|
||||||
</el-scrollbar>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { Search } from '@element-plus/icons-vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
import { useUserStore } from '@/store/modules/user'
|
|
||||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
|
||||||
import { useFriendStore } from '../../../store/friendStore'
|
|
||||||
import { getSimpleUserListByNickname, type ImUserSimpleRespVO } from '@/api/im/user'
|
|
||||||
|
|
||||||
defineOptions({ name: 'ImAddFriendDialog' })
|
|
||||||
|
|
||||||
interface UserCandidate {
|
|
||||||
id: string | number
|
|
||||||
nickName: string
|
|
||||||
headImage?: string
|
|
||||||
online?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
/** 添加好友成功后抛出,页面层去刷新 friendStore */
|
|
||||||
added: [friend: UserCandidate]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v)
|
|
||||||
})
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const friendStore = useFriendStore()
|
|
||||||
const currentUserId = computed(() => userStore.getUser?.id?.toString() || '')
|
|
||||||
|
|
||||||
const keyword = ref('')
|
|
||||||
const users = ref<UserCandidate[]>([])
|
|
||||||
const searched = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function handleSearch() {
|
|
||||||
searched.value = true
|
|
||||||
if (!keyword.value.trim()) {
|
|
||||||
users.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const list = await getSimpleUserListByNickname(keyword.value.trim())
|
|
||||||
users.value = (list || []).map(toCandidate)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[IM] 用户搜索失败', e)
|
|
||||||
ElMessage.error('搜索失败')
|
|
||||||
users.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAdd(user: UserCandidate) {
|
|
||||||
try {
|
|
||||||
await friendStore.addFriend(user.id, {
|
|
||||||
nickName: user.nickName,
|
|
||||||
headImage: user.headImage,
|
|
||||||
online: user.online
|
|
||||||
})
|
|
||||||
ElMessage.success('已添加好友')
|
|
||||||
emit('added', user)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[IM] 添加好友失败', e)
|
|
||||||
ElMessage.error(e?.message || '添加好友失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFriend(userId: string | number): boolean {
|
|
||||||
return friendStore.isFriend(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCandidate(vo: ImUserSimpleRespVO): UserCandidate {
|
|
||||||
return {
|
|
||||||
id: vo.id,
|
|
||||||
nickName: vo.nickname,
|
|
||||||
headImage: vo.avatar,
|
|
||||||
online: vo.online
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.im-add-friend__result {
|
|
||||||
height: 400px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__empty {
|
|
||||||
padding: 40px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__item {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 8px;
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__user-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__nick {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__user-name {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__tag {
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__tag.is-online {
|
|
||||||
color: #67c23a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.im-add-friend__search-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -226,9 +226,11 @@ function convertFriend(vo: ImFriendRespVO): Friend {
|
||||||
id: vo.id,
|
id: vo.id,
|
||||||
friendUserId: vo.friendUserId,
|
friendUserId: vo.friendUserId,
|
||||||
nickname: vo.nickname || String(vo.friendUserId),
|
nickname: vo.nickname || String(vo.friendUserId),
|
||||||
|
nicknamePinyin: vo.nicknamePinyin,
|
||||||
avatar: vo.avatar,
|
avatar: vo.avatar,
|
||||||
muted: !!vo.muted,
|
muted: !!vo.muted,
|
||||||
displayName: vo.displayName || '',
|
displayName: vo.displayName || '',
|
||||||
|
displayNamePinyin: vo.displayNamePinyin,
|
||||||
status: vo.status,
|
status: vo.status,
|
||||||
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
|
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
|
||||||
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined
|
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined
|
||||||
|
|
|
||||||
|
|
@ -145,9 +145,11 @@ export interface Friend {
|
||||||
id?: number // 好友关系记录编号(本地乐观新增时可能暂缺)
|
id?: number // 好友关系记录编号(本地乐观新增时可能暂缺)
|
||||||
friendUserId: number // 好友用户编号(与 Conversation.targetId 对齐)
|
friendUserId: number // 好友用户编号(与 Conversation.targetId 对齐)
|
||||||
nickname: string // 好友昵称(对方真实昵称,永远不被备注覆盖;UI 显示走 displayName || nickname)
|
nickname: string // 好友昵称(对方真实昵称,永远不被备注覆盖;UI 显示走 displayName || nickname)
|
||||||
|
nicknamePinyin?: string // 昵称的拼音(后端用 Pinyin4j 算好回填,小写无空格)
|
||||||
avatar?: string // 好友头像
|
avatar?: string // 好友头像
|
||||||
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||||
displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
|
displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
|
||||||
|
displayNamePinyin?: string // 备注的拼音(后端用 Pinyin4j 算好回填,小写无空格)
|
||||||
status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录)
|
status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录)
|
||||||
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
||||||
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
|
||||||
|
|
@ -175,8 +177,10 @@ export interface User {
|
||||||
export interface FriendLite {
|
export interface FriendLite {
|
||||||
id: number
|
id: number
|
||||||
nickname: string
|
nickname: string
|
||||||
|
nicknamePinyin?: string // 昵称拼音(用于字母分桶 / 拼音搜索)
|
||||||
avatar?: string
|
avatar?: string
|
||||||
displayName?: string
|
displayName?: string
|
||||||
|
displayNamePinyin?: string // 备注拼音(优先于 nicknamePinyin 参与分桶)
|
||||||
deleted?: boolean
|
deleted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue