feat: 成员管理中支持成员消息合并

This commit is contained in:
digua
2026-04-13 23:47:32 +08:00
committed by digua
parent ad4afc77d7
commit 452deedb5d
13 changed files with 413 additions and 7 deletions
+15
View File
@@ -636,6 +636,21 @@ export function registerChatHandlers(ctx: IpcContext): void {
}
})
/**
* 合并成员(保留消息数更多的一方)
*/
ipcMain.handle('chat:mergeMembers', async (_, sessionId: string, memberId1: number, memberId2: number) => {
try {
await worker.closeDatabase(sessionId)
const result = await worker.mergeMembers(sessionId, memberId1, memberId2)
if (result) worker.invalidateAnalysisCache(sessionId).catch(() => {})
return result
} catch (error) {
console.error('Failed to merge members:', error)
return false
}
})
/**
* 删除成员及其所有消息
*/
+2
View File
@@ -47,6 +47,7 @@ import {
getMembers,
getMembersPaginated,
updateMemberAliases,
mergeMembers,
deleteMember,
// SQL 实验室
executeRawSQL,
@@ -178,6 +179,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getMembers: (p) => getMembers(p.sessionId),
getMembersPaginated: (p) => getMembersPaginated(p.sessionId, p.params),
updateMemberAliases: (p) => updateMemberAliases(p.sessionId, p.memberId, p.aliases),
mergeMembers: (p) => mergeMembers(p.sessionId, p.memberId1, p.memberId2),
deleteMember: (p) => deleteMember(p.sessionId, p.memberId),
// 高级分析
+107
View File
@@ -704,6 +704,113 @@ export function updateMemberAliases(sessionId: string, memberId: number, aliases
}
}
type MemberMergeRow = {
id: number
platformId: string
accountName: string | null
groupNickname: string | null
aliases: string | null
avatar: string | null
messageCount: number
}
function parseAliases(raw: string | null): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
} catch {
return []
}
}
/**
* 合并两个成员(保留消息数更多的一方)
*/
export function mergeMembers(sessionId: string, memberId1: number, memberId2: number): boolean {
const dbPath = getDbPath(sessionId)
if (!fs.existsSync(dbPath) || memberId1 === memberId2) {
return false
}
try {
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
const rows = db
.prepare(
`
SELECT
m.id,
m.platform_id as platformId,
m.account_name as accountName,
m.group_nickname as groupNickname,
m.aliases,
m.avatar,
COUNT(msg.id) as messageCount
FROM member m
LEFT JOIN message msg ON m.id = msg.sender_id
WHERE m.id IN (?, ?)
GROUP BY m.id
`
)
.all(memberId1, memberId2) as MemberMergeRow[]
if (rows.length !== 2) {
db.close()
return false
}
const [memberA, memberB] = rows
let primary = memberA
let secondary = memberB
if (
memberB.messageCount > memberA.messageCount ||
(memberB.messageCount === memberA.messageCount && memberB.id < memberA.id)
) {
primary = memberB
secondary = memberA
}
const mergedAliases = Array.from(new Set([...parseAliases(primary.aliases), ...parseAliases(secondary.aliases)]))
const mergedAccountName = primary.accountName || secondary.accountName
const mergedGroupNickname = primary.groupNickname || secondary.groupNickname
const mergedAvatar = primary.avatar || secondary.avatar
const mergeTransaction = db.transaction(() => {
// 1. 归并消息归属到主成员
db.prepare('UPDATE message SET sender_id = ? WHERE sender_id = ?').run(primary.id, secondary.id)
// 2. 归并昵称历史
db.prepare('UPDATE member_name_history SET member_id = ? WHERE member_id = ?').run(primary.id, secondary.id)
// 3. owner_id 若指向被合并成员,切换到主成员 platformId
db.prepare('UPDATE meta SET owner_id = ? WHERE owner_id = ?').run(primary.platformId, secondary.platformId)
// 4. 更新主成员资料(默认以消息更多一方为主,补齐缺失字段)
db.prepare(
`
UPDATE member
SET account_name = ?, group_nickname = ?, avatar = ?, aliases = ?
WHERE id = ?
`
).run(mergedAccountName, mergedGroupNickname, mergedAvatar, JSON.stringify(mergedAliases), primary.id)
// 5. 删除被合并成员
db.prepare('DELETE FROM member WHERE id = ?').run(secondary.id)
})
mergeTransaction()
db.close()
return true
} catch (error) {
console.error('[Worker] Failed to merge members:', error)
return false
}
}
/**
* 删除成员及其所有消息
*/
+1
View File
@@ -20,6 +20,7 @@ export {
getMembers,
getMembersPaginated,
updateMemberAliases,
mergeMembers,
deleteMember,
} from './basic'
+7
View File
@@ -470,6 +470,13 @@ export async function updateMemberAliases(sessionId: string, memberId: number, a
return sendToWorker('updateMemberAliases', { sessionId, memberId, aliases })
}
/**
* 合并两个成员(保留消息数更多的一方)
*/
export async function mergeMembers(sessionId: string, memberId1: number, memberId2: number): Promise<boolean> {
return sendToWorker('mergeMembers', { sessionId, memberId1, memberId2 })
}
/**
* 删除成员及其所有消息
*/
+7
View File
@@ -339,6 +339,13 @@ export const chatApi = {
return ipcRenderer.invoke('chat:updateMemberAliases', sessionId, memberId, aliases)
},
/**
* 合并成员(保留消息数更多的一方)
*/
mergeMembers: (sessionId: string, memberId1: number, memberId2: number): Promise<boolean> => {
return ipcRenderer.invoke('chat:mergeMembers', sessionId, memberId1, memberId2)
},
/**
* 删除成员及其所有消息
*/
+1
View File
@@ -149,6 +149,7 @@ interface ChatApi {
totalPages: number
}>
updateMemberAliases: (sessionId: string, memberId: number, aliases: string[]) => Promise<boolean>
mergeMembers: (sessionId: string, memberId1: number, memberId2: number) => Promise<boolean>
deleteMember: (sessionId: string, memberId: number) => Promise<boolean>
// 插件系统
pluginQuery: <T = Record<string, any>>(sessionId: string, sql: string, params?: any[]) => Promise<T[]>
+124 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MemberWithStats } from '@/types/analysis'
import OwnerSelector from '@/components/analysis/member/OwnerSelector.vue'
@@ -32,6 +32,11 @@ const searchQuery = ref('')
const deletingMember = ref<MemberWithStats | null>(null)
const isDeleting = ref(false)
// 合并确认状态
const selectedMergeIds = ref<Set<number>>(new Set())
const showMergeModal = ref(false)
const isMerging = ref(false)
// 分页配置
const pageSize = 20
const currentPage = ref(1)
@@ -58,6 +63,21 @@ function getFirstChar(member: MemberWithStats): string {
return name.slice(0, 1)
}
const selectedMergeMembers = computed(() => allMembers.value.filter((member) => selectedMergeIds.value.has(member.id)))
const canMergeSelected = computed(() => selectedMergeMembers.value.length === 2)
const mergePlan = computed(() => {
if (selectedMergeMembers.value.length !== 2) return null
const [memberA, memberB] = selectedMergeMembers.value
if (
memberB.messageCount > memberA.messageCount ||
(memberB.messageCount === memberA.messageCount && memberB.id < memberA.id)
) {
return { primary: memberB, secondary: memberA }
}
return { primary: memberA, secondary: memberB }
})
// 切换排序
function toggleSort() {
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc'
@@ -158,6 +178,48 @@ async function confirmDelete() {
}
}
function toggleMergeSelection(memberId: number) {
const next = new Set(selectedMergeIds.value)
if (next.has(memberId)) {
next.delete(memberId)
} else {
next.add(memberId)
}
selectedMergeIds.value = next
}
function openMergeModal() {
if (!canMergeSelected.value) return
showMergeModal.value = true
}
function clearMergeSelection() {
selectedMergeIds.value = new Set()
}
async function confirmMerge() {
if (!mergePlan.value) return
isMerging.value = true
try {
const success = await window.chatApi.mergeMembers(
props.sessionId,
mergePlan.value.primary.id,
mergePlan.value.secondary.id
)
if (success) {
showMergeModal.value = false
clearMergeSelection()
await loadMembers()
await loadAllMembers()
emit('data-changed')
}
} catch (error) {
console.error('合并成员失败:', error)
} finally {
isMerging.value = false
}
}
// 搜索时重置页码并防抖加载
watch(searchQuery, () => {
currentPage.value = 1
@@ -181,6 +243,7 @@ watch(
() => {
searchQuery.value = ''
currentPage.value = 1
clearMergeSelection()
loadMembers()
loadAllMembers()
},
@@ -220,17 +283,31 @@ onMounted(() => {
/>
<!-- 搜索框 -->
<div class="mb-4">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<UInput
v-model="searchQuery"
:placeholder="t('members.list.searchPlaceholder')"
icon="i-heroicons-magnifying-glass"
class="w-100"
class="w-full md:w-100"
>
<template v-if="searchQuery" #trailing>
<UButton icon="i-heroicons-x-mark" variant="link" color="neutral" size="xs" @click="searchQuery = ''" />
</template>
</UInput>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('members.list.mergeSelectedCount', { count: selectedMergeIds.size }) }}
</span>
<UButton
icon="i-heroicons-link"
size="sm"
color="primary"
:disabled="!canMergeSelected"
@click="openMergeModal"
>
{{ t('members.list.mergeSelected') }}
</UButton>
</div>
</div>
<!-- 成员列表 -->
@@ -257,6 +334,7 @@ onMounted(() => {
<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="w-12 px-4 py-4" />
<th class="px-4 py-4">{{ t('members.list.table.accountName') }}</th>
<th class="px-4 py-4">{{ t('members.list.table.groupNickname') }}</th>
<th class="px-4 py-4">
@@ -284,6 +362,14 @@ onMounted(() => {
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="member in members" :key="member.id" class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<!-- 选择 -->
<td class="px-4 py-4">
<UCheckbox
:model-value="selectedMergeIds.has(member.id)"
@click.stop="toggleMergeSelection(member.id)"
/>
</td>
<!-- 账号名称 (ID) -->
<td class="px-4 py-4">
<div class="flex items-center gap-2">
@@ -413,5 +499,40 @@ onMounted(() => {
</div>
</template>
</UModal>
<!-- 合并确认弹窗 -->
<UModal :open="showMergeModal" :ui="{ content: 'max-w-md' }" @update:open="showMergeModal = $event">
<template #content>
<div class="p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30">
<UIcon name="i-heroicons-link" class="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('members.list.mergeModal.title') }}
</h3>
</div>
<p class="mb-2 text-sm text-gray-600 dark:text-gray-400">
{{
t('members.list.mergeModal.content', {
source: mergePlan ? getDisplayName(mergePlan.secondary) : '',
sourceCount: mergePlan ? mergePlan.secondary.messageCount.toLocaleString() : '0',
target: mergePlan ? getDisplayName(mergePlan.primary) : '',
targetCount: mergePlan ? mergePlan.primary.messageCount.toLocaleString() : '0',
})
}}
</p>
<p class="mb-6 text-xs text-amber-700 dark:text-amber-300">{{ t('members.list.mergeModal.hint') }}</p>
<div class="flex justify-end gap-2">
<UButton variant="outline" :disabled="isMerging" @click="showMergeModal = false">
{{ t('members.list.mergeModal.cancel') }}
</UButton>
<UButton color="primary" :loading="isMerging" @click="confirmMerge">
{{ t('members.list.mergeModal.confirm') }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
+9
View File
@@ -21,8 +21,17 @@
},
"aliasPlaceholder": "Press Enter to add",
"delete": "Delete",
"mergeSelected": "Merge Selected",
"mergeSelectedCount": "{count} members selected",
"pagination": "Showing {start} - {end} of {total} members",
"tip": "Tip: Adding aliases helps identify chat participants and improves search and AI analysis accuracy.",
"mergeModal": {
"title": "Merge Members?",
"content": "Merge {source} ({sourceCount} messages) into {target} ({targetCount} messages). By default, the merged name keeps the member with more messages.",
"hint": "This action cannot be undone. All messages from the merged member will be reassigned to the kept member.",
"cancel": "Cancel",
"confirm": "Confirm Merge"
},
"modal": {
"title": "Delete Member?",
"content": "This will delete member {name} and their {count} messages. This action cannot be undone.",
+9
View File
@@ -21,8 +21,17 @@
},
"aliasPlaceholder": "入力して Enter で追加",
"delete": "削除",
"mergeSelected": "選択を結合",
"mergeSelectedCount": "{count} 名を選択中",
"pagination": "{start} - {end} 件表示、全 {total} 名のメンバー",
"tip": "ヒント:別名を追加すると、チャット履歴の相手をより正確に識別できます。別名は検索や AI 分析で使用されます。",
"mergeModal": {
"title": "メンバーを結合しますか?",
"content": "{source}{sourceCount} 件)を {target}{targetCount} 件)へ結合します。結合後の名前は、メッセージ数が多い側を優先して保持します。",
"hint": "この操作は元に戻せません。結合される側のメッセージはすべて保持側に移動します。",
"cancel": "キャンセル",
"confirm": "結合を確認"
},
"modal": {
"title": "メンバーを削除しますか?",
"content": "メンバー {name} とその {count} 件のメッセージを削除します。この操作は元に戻せません。",
+9
View File
@@ -21,8 +21,17 @@
},
"aliasPlaceholder": "输入后回车添加",
"delete": "删除",
"mergeSelected": "合并选中",
"mergeSelectedCount": "已选 {count} 位成员",
"pagination": "显示 {start} - {end} 条,共 {total} 位成员",
"tip": "提示:添加别名可以更好地识别聊天记录中的对话对象,别名将用于搜索和 AI 分析中。",
"mergeModal": {
"title": "确认合并成员?",
"content": "将成员 {source}{sourceCount} 条消息)合并到 {target}{targetCount} 条消息)下。默认保留消息更多一方的名称。",
"hint": "合并后不可恢复,被合并成员的消息将全部归属到保留成员。",
"cancel": "取消",
"confirm": "确认合并"
},
"modal": {
"title": "确认删除成员?",
"content": "即将删除成员 {name} 及其 {count} 条消息,此操作不可恢复。",
+9
View File
@@ -21,8 +21,17 @@
},
"aliasPlaceholder": "輸入後按 Enter 新增",
"delete": "刪除",
"mergeSelected": "合併所選",
"mergeSelectedCount": "已選 {count} 位成員",
"pagination": "顯示第 {start} - {end} 筆,共 {total} 位成員",
"tip": "提示:新增別名後,搜尋與 AI 分析時會更容易辨識聊天對象。",
"mergeModal": {
"title": "確認合併成員?",
"content": "將成員 {source}{sourceCount} 則訊息)合併至 {target}{targetCount} 則訊息)下。預設保留訊息較多一方的名稱。",
"hint": "合併後無法復原,被合併成員的訊息將全部歸屬至保留成員。",
"cancel": "取消",
"confirm": "確認合併"
},
"modal": {
"title": "確認刪除成員?",
"content": "即將刪除成員 {name} 及其 {count} 則訊息,此操作不可復原。",
+113 -4
View File
@@ -24,6 +24,11 @@ const isLoading = ref(false)
// 正在保存别名的成员ID
const savingAliasesId = ref<number | null>(null)
// 合并成员状态
const selectedMergeIds = ref<Set<number>>(new Set())
const showMergeModal = ref(false)
const isMerging = ref(false)
// 获取成员显示名称
function getDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
@@ -35,6 +40,21 @@ function getFirstChar(member: MemberWithStats): string {
return name.slice(0, 1)
}
const selectedMergeMembers = computed(() => members.value.filter((member) => selectedMergeIds.value.has(member.id)))
const canMergeSelected = computed(() => selectedMergeMembers.value.length === 2)
const mergePlan = computed(() => {
if (selectedMergeMembers.value.length !== 2) return null
const [memberA, memberB] = selectedMergeMembers.value
if (
memberB.messageCount > memberA.messageCount ||
(memberB.messageCount === memberA.messageCount && memberB.id < memberA.id)
) {
return { primary: memberB, secondary: memberA }
}
return { primary: memberA, secondary: memberB }
})
// 计算消息总数
const totalMessageCount = computed(() => {
return members.value.reduce((sum, m) => sum + m.messageCount, 0)
@@ -86,10 +106,51 @@ async function updateAliases(member: MemberWithStats, newAliases: string[]) {
}
}
function toggleMergeSelection(memberId: number) {
const next = new Set(selectedMergeIds.value)
if (next.has(memberId)) {
next.delete(memberId)
} else {
next.add(memberId)
}
selectedMergeIds.value = next
}
function openMergeModal() {
if (!canMergeSelected.value) return
showMergeModal.value = true
}
function clearMergeSelection() {
selectedMergeIds.value = new Set()
}
async function confirmMerge() {
if (!mergePlan.value) return
isMerging.value = true
try {
const success = await window.chatApi.mergeMembers(
props.sessionId,
mergePlan.value.primary.id,
mergePlan.value.secondary.id
)
if (success) {
showMergeModal.value = false
clearMergeSelection()
await loadMembers()
}
} catch (error) {
console.error('合并成员失败:', error)
} finally {
isMerging.value = false
}
}
// 监听 sessionId 变化
watch(
() => props.sessionId,
() => {
clearMergeSelection()
loadMembers()
},
{ immediate: true }
@@ -128,13 +189,26 @@ onMounted(() => {
<UIcon name="i-heroicons-arrow-path" class="h-8 w-8 animate-spin text-pink-500" />
</div>
<div v-if="!isLoading && members.length > 1" class="mb-4 flex items-center justify-end gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('members.list.mergeSelectedCount', { count: selectedMergeIds.size }) }}
</span>
<UButton icon="i-heroicons-link" size="sm" color="primary" :disabled="!canMergeSelected" @click="openMergeModal">
{{ t('members.list.mergeSelected') }}
</UButton>
</div>
<!-- 成员卡片列表 -->
<div v-else class="grid gap-4 md:grid-cols-2">
<div v-if="!isLoading" class="grid gap-4 md:grid-cols-2">
<div
v-for="member in members"
:key="member.id"
class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"
class="relative rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<div class="absolute right-4 top-4">
<UCheckbox :model-value="selectedMergeIds.has(member.id)" @click.stop="toggleMergeSelection(member.id)" />
</div>
<!-- 成员头部信息 -->
<div class="flex items-start gap-4">
<!-- 头像优先显示真实头像否则显示首字母 -->
@@ -146,7 +220,7 @@ onMounted(() => {
/>
<div
v-else
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-pink-600 text-lg font-medium text-white"
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-pink-400 to-pink-600 text-lg font-medium text-white"
>
{{ getFirstChar(member) }}
</div>
@@ -172,7 +246,7 @@ onMounted(() => {
<!-- 进度条 -->
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
class="h-full rounded-full bg-gradient-to-r from-pink-400 to-pink-600 transition-all duration-500"
class="h-full rounded-full bg-linear-to-r from-pink-400 to-pink-600 transition-all duration-500"
:style="{ width: `${getPercentage(member.messageCount)}%` }"
/>
</div>
@@ -219,5 +293,40 @@ onMounted(() => {
</p>
</div>
</div>
<!-- 合并确认弹窗 -->
<UModal :open="showMergeModal" :ui="{ content: 'max-w-md' }" @update:open="showMergeModal = $event">
<template #content>
<div class="p-6">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30">
<UIcon name="i-heroicons-link" class="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('members.list.mergeModal.title') }}
</h3>
</div>
<p class="mb-2 text-sm text-gray-600 dark:text-gray-400">
{{
t('members.list.mergeModal.content', {
source: mergePlan ? getDisplayName(mergePlan.secondary) : '',
sourceCount: mergePlan ? mergePlan.secondary.messageCount.toLocaleString() : '0',
target: mergePlan ? getDisplayName(mergePlan.primary) : '',
targetCount: mergePlan ? mergePlan.primary.messageCount.toLocaleString() : '0',
})
}}
</p>
<p class="mb-6 text-xs text-amber-700 dark:text-amber-300">{{ t('members.list.mergeModal.hint') }}</p>
<div class="flex justify-end gap-2">
<UButton variant="outline" :disabled="isMerging" @click="showMergeModal = false">
{{ t('members.list.mergeModal.cancel') }}
</UButton>
<UButton color="primary" :loading="isMerging" @click="confirmMerge">
{{ t('members.list.mergeModal.confirm') }}
</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>