mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-05-20 21:30:28 +08:00
feat: 成员管理中支持成员消息合并
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除成员及其所有消息
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
|
||||
// 高级分析
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成员及其所有消息
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
getMembers,
|
||||
getMembersPaginated,
|
||||
updateMemberAliases,
|
||||
mergeMembers,
|
||||
deleteMember,
|
||||
} from './basic'
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成员及其所有消息
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除成员及其所有消息
|
||||
*/
|
||||
|
||||
Vendored
+1
@@ -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[]>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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} 件のメッセージを削除します。この操作は元に戻せません。",
|
||||
|
||||
@@ -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} 条消息,此操作不可恢复。",
|
||||
|
||||
@@ -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} 則訊息,此操作不可復原。",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user