Files
ChatLab/src/pages/group-chat/components/member/MemberList.vue
2025-12-21 17:20:06 +08:00

384 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { MemberWithStats } from '@/types/analysis'
// Props
const props = defineProps<{
sessionId: string
}>()
// Emits
const emit = defineEmits<{
'data-changed': []
}>()
// 成员列表
const members = ref<MemberWithStats[]>([])
const isLoading = ref(false)
const searchQuery = ref('')
// 分页配置
const pageSize = 20
const currentPage = ref(1)
// 排序配置
const sortOrder = ref<'desc' | 'asc'>('desc') // desc = 发言多在前
// 删除确认状态
const deletingMember = ref<MemberWithStats | null>(null)
const isDeleting = ref(false)
// 正在保存别名的成员ID用于显示加载状态
const savingAliasesId = ref<number | null>(null)
// 获取成员显示名称
function getDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
}
// 获取成员首字符(用于头像)
function getFirstChar(member: MemberWithStats): string {
const name = getDisplayName(member)
return name.slice(0, 1)
}
// 过滤和排序后的成员列表
const filteredAndSortedMembers = computed(() => {
let result = [...members.value]
// 搜索过滤
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(m) =>
(m.groupNickname && m.groupNickname.toLowerCase().includes(query)) ||
(m.accountName && m.accountName.toLowerCase().includes(query)) ||
m.platformId.toLowerCase().includes(query) ||
m.aliases.some((a) => a.toLowerCase().includes(query))
)
}
// 按消息数排序
result.sort((a, b) =>
sortOrder.value === 'desc' ? b.messageCount - a.messageCount : a.messageCount - b.messageCount
)
return result
})
// 分页后的成员列表
const paginatedMembers = computed(() => {
const start = (currentPage.value - 1) * pageSize
return filteredAndSortedMembers.value.slice(start, start + pageSize)
})
// 总页数
const totalPages = computed(() => Math.ceil(filteredAndSortedMembers.value.length / pageSize))
// 切换排序
function toggleSort() {
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc'
}
// 加载成员列表
async function loadMembers() {
if (!props.sessionId) return
isLoading.value = true
try {
members.value = await window.chatApi.getMembers(props.sessionId)
} catch (error) {
console.error('加载成员列表失败:', error)
} finally {
isLoading.value = false
}
}
// 直接更新别名(输入框失焦或回车时触发)
async function updateAliases(member: MemberWithStats, newAliases: string[]) {
// 将 Vue 响应式数组转换为普通数组,避免 IPC 序列化问题
const aliasesToSave = JSON.parse(JSON.stringify(newAliases)) as string[]
// 检查是否有变化
const currentAliases = JSON.stringify(member.aliases)
const newAliasesStr = JSON.stringify(aliasesToSave)
if (currentAliases === newAliasesStr) return
savingAliasesId.value = member.id
try {
const success = await window.chatApi.updateMemberAliases(props.sessionId, member.id, aliasesToSave)
if (success) {
// 更新本地数据 - 找到对应成员并更新
const idx = members.value.findIndex((m) => m.id === member.id)
if (idx !== -1) {
members.value[idx] = {
...members.value[idx],
aliases: aliasesToSave,
}
}
}
} catch (error) {
console.error('保存别名失败:', error)
} finally {
savingAliasesId.value = null
}
}
// 显示删除确认
function showDeleteConfirm(member: MemberWithStats) {
deletingMember.value = member
}
// 取消删除
function cancelDelete() {
deletingMember.value = null
}
// 确认删除
async function confirmDelete() {
if (!deletingMember.value) return
isDeleting.value = true
try {
const success = await window.chatApi.deleteMember(props.sessionId, deletingMember.value.id)
if (success) {
// 从列表中移除
members.value = members.value.filter((m) => m.id !== deletingMember.value!.id)
// 通知父组件刷新数据
emit('data-changed')
}
} catch (error) {
console.error('删除成员失败:', error)
} finally {
isDeleting.value = false
deletingMember.value = null
}
}
// 搜索时重置页码
watch(searchQuery, () => {
currentPage.value = 1
})
// 监听 sessionId 变化
watch(
() => props.sessionId,
() => {
loadMembers()
searchQuery.value = ''
currentPage.value = 1
},
{ immediate: true }
)
onMounted(() => {
loadMembers()
})
</script>
<template>
<div class="main-content max-w-5xl p-6">
<!-- 页面标题 -->
<div class="mb-6">
<div class="flex items-center gap-3">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">群成员管理</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ members.length }} 位成员可为成员添加别名备注或移除成员
</p>
</div>
</div>
</div>
<!-- 搜索框 -->
<div class="mb-4">
<UInput
v-model="searchQuery"
placeholder="搜索群昵称、账号名称、ID 或别名"
icon="i-heroicons-magnifying-glass"
class="w-100"
>
<template #trailing v-if="searchQuery">
<UButton icon="i-heroicons-x-mark" variant="link" color="neutral" size="xs" @click="searchQuery = ''" />
</template>
</UInput>
</div>
<!-- 成员列表 -->
<div class="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
<!-- 加载状态 -->
<div v-if="isLoading" class="flex h-60 items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<!-- 空状态 -->
<div v-else-if="filteredAndSortedMembers.length === 0" class="flex h-60 flex-col items-center justify-center">
<UIcon name="i-heroicons-user-group" class="mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p class="text-gray-500 dark:text-gray-400">
{{ searchQuery ? '没有找到匹配的成员' : '暂无成员数据' }}
</p>
</div>
<!-- 成员表格 -->
<div v-else>
<div class="max-h-[500px] overflow-y-auto">
<table class="w-full">
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
<th class="px-4 py-4">账号名称</th>
<th class="px-4 py-4">群昵称</th>
<th class="px-4 py-4">
<button
class="flex items-center gap-1.5 hover:text-gray-700 dark:hover:text-gray-200"
@click="toggleSort"
>
消息数
<UIcon
:name="sortOrder === 'desc' ? 'i-heroicons-arrow-down' : 'i-heroicons-arrow-up'"
class="h-3.5 w-3.5"
/>
</button>
</th>
<th class="px-4 py-4 w-64">自定义别名</th>
<th class="px-4 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="member in paginatedMembers"
:key="member.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<!-- 账号名称 (ID) -->
<td class="px-4 py-4">
<div class="flex items-center gap-2">
<!-- 头像优先显示真实头像否则显示首字母 -->
<img
v-if="member.avatar"
:src="member.avatar"
:alt="getDisplayName(member)"
class="h-8 w-8 shrink-0 rounded-full object-cover"
/>
<div
v-else
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-pink-600 text-xs font-medium text-white"
>
{{ getFirstChar(member) }}
</div>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ member.accountName || '-' }}
</span>
<span class="ml-1 text-sm text-gray-500 dark:text-gray-400">({{ member.platformId }})</span>
</div>
</div>
</td>
<!-- 群昵称 -->
<td class="px-4 py-4">
<span v-if="member.groupNickname" class="text-sm font-medium text-gray-900 dark:text-white">
{{ member.groupNickname }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</td>
<!-- 消息数 -->
<td class="px-4 py-4">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{{ member.messageCount.toLocaleString() }}
</span>
</td>
<!-- 别名 - 直接编辑 -->
<td class="px-4 py-4">
<div class="max-w-xs">
<UInputTags
:model-value="member.aliases"
@update:model-value="(val) => updateAliases(member, val)"
placeholder="输入后回车添加"
class="w-80"
/>
<!-- 保存中指示器 -->
<div v-if="savingAliasesId === member.id" class="absolute right-2 top-1/2 -translate-y-1/2">
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-pink-500" />
</div>
</div>
</td>
<!-- 操作 -->
<td class="px-4 py-4 text-right">
<UButton label="删除" size="xs" @click="showDeleteConfirm(member)" />
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between border-t border-gray-200 px-6 py-4 dark:border-gray-700"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
显示 {{ (currentPage - 1) * pageSize + 1 }} -
{{ Math.min(currentPage * pageSize, filteredAndSortedMembers.length) }}
条,共 {{ filteredAndSortedMembers.length }} 位成员
</p>
<div class="flex items-center gap-2">
<UButton
icon="i-heroicons-chevron-left"
variant="outline"
size="sm"
:disabled="currentPage === 1"
@click="currentPage--"
/>
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ currentPage }} / {{ totalPages }}
</span>
<UButton
icon="i-heroicons-chevron-right"
variant="outline"
size="sm"
:disabled="currentPage >= totalPages"
@click="currentPage++"
/>
</div>
</div>
</div>
</div>
<!-- 提示信息 -->
<div class="mt-4 flex items-start gap-3 rounded-xl bg-amber-50 p-4 dark:bg-amber-900/20">
<UIcon name="i-heroicons-exclamation-triangle" class="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
提示:添加别名可以更好地识别聊天记录中的对话对象,别名将用于搜索和 AI 分析中。
</p>
</div>
</div>
<!-- 删除确认弹窗 -->
<UModal :open="!!deletingMember" @update:open="deletingMember = null" :ui="{ content: 'max-w-sm' }">
<template #content>
<div class="p-6 text-center">
<div
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-7 w-7 text-red-500" />
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">确认删除成员?</h3>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400">
即将删除成员
<span class="font-medium text-gray-900 dark:text-white">
{{ deletingMember ? getDisplayName(deletingMember) : '' }}
</span>
及其 {{ deletingMember?.messageCount.toLocaleString() }} 条消息,此操作不可恢复。
</p>
<div class="flex justify-center gap-3">
<UButton variant="outline" @click="cancelDelete">取消</UButton>
<UButton color="error" :loading="isDeleting" @click="confirmDelete">确认删除</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>