feat: 成员Tab中支持设置Owner视角

This commit is contained in:
digua
2025-12-24 23:45:06 +08:00
parent c82334b1ad
commit 8175d175b5
9 changed files with 174 additions and 25 deletions

View File

@@ -357,19 +357,41 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator<ParseEvent
// 提取群头像(从 avatars 中获取群ID对应的头像
const groupAvatar = groupId ? avatarsMap.get(groupId) : undefined
// 发送 meta
// 快速扫描获取 ownerId通过 isSend === 1 推断)
let ownerId: string | undefined
try {
await new Promise<void>((resolve) => {
const scanStream = fs.createReadStream(filePath, { encoding: 'utf-8' })
const scanPipeline = chain([scanStream, parser(), pick({ filter: /^messages\.\d+$/ }), streamValues()])
scanPipeline.on('data', ({ value }: { value: EchotraceMessage }) => {
if (value.isSend === 1 && value.senderUsername && !value.senderUsername.endsWith('@chatroom')) {
ownerId = value.senderUsername
scanStream.destroy() // 找到后立即停止扫描
}
})
scanStream.on('close', () => resolve())
scanPipeline.on('end', () => resolve())
scanPipeline.on('error', () => resolve())
})
} catch {
// 扫描失败ownerId 保持 undefined
}
// 发送 meta包含推断的 ownerId
const meta: ParsedMeta = {
name: chatName,
platform: KNOWN_PLATFORMS.WECHAT,
type: chatType,
groupId,
groupAvatar,
ownerId,
}
yield { type: 'meta', data: meta }
// 收集成员和消息
const memberMap = new Map<string, MemberInfo>()
let messageBatch: ParsedMessage[] = []
// 流式解析
await new Promise<void>((resolve, reject) => {
@@ -518,4 +540,3 @@ const module_: FormatModule = {
}
export default module_

View File

@@ -16,6 +16,7 @@ export interface ParsedMeta {
type: ChatType
groupId?: string // 群ID群聊类型有值
groupAvatar?: string // 群头像base64 Data URL
ownerId?: string // 所有者/导出者的 platformId
}
/**

View File

@@ -18,15 +18,7 @@ import {
type ParsedMessage,
} from '../../parser'
import { getDbDir } from '../core'
import {
initPerfLog,
logPerf,
logPerfDetail,
resetPerfLog,
logInfo,
logError,
logSummary,
} from '../core'
import { initPerfLog, logPerf, logPerfDetail, resetPerfLog, logInfo, logError, logSummary } from '../core'
/** 流式导入结果 */
export interface StreamImportResult {
@@ -75,7 +67,9 @@ function createTempDatabase(dbPath: string): Database.Database {
platform TEXT NOT NULL,
type TEXT NOT NULL,
group_id TEXT,
group_avatar TEXT
group_avatar TEXT,
owner_id TEXT,
schema_version INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS member (
@@ -154,7 +148,9 @@ function createDatabaseWithoutIndexes(sessionId: string): Database.Database {
type TEXT NOT NULL,
imported_at INTEGER NOT NULL,
group_id TEXT,
group_avatar TEXT
group_avatar TEXT,
owner_id TEXT,
schema_version INTEGER DEFAULT 1
);
CREATE TABLE IF NOT EXISTS member (
@@ -264,7 +260,7 @@ export async function streamImport(filePath: string, requestId: string): Promise
// 准备语句
const insertMeta = db.prepare(`
INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar) VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?)
`)
const insertMember = db.prepare(`
INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)
@@ -386,7 +382,8 @@ export async function streamImport(filePath: string, requestId: string): Promise
meta.type,
Math.floor(Date.now() / 1000),
meta.groupId || null,
meta.groupAvatar || null
meta.groupAvatar || null,
meta.ownerId || null
)
metaInserted = true
}
@@ -394,7 +391,12 @@ export async function streamImport(filePath: string, requestId: string): Promise
onMembers: (members: ParsedMember[]) => {
for (const member of members) {
insertMember.run(member.platformId, member.accountName || null, member.groupNickname || null, member.avatar || null)
insertMember.run(
member.platformId,
member.accountName || null,
member.groupNickname || null,
member.avatar || null
)
const row = getMemberId.get(member.platformId) as { id: number } | undefined
if (row) {
memberIdMap.set(member.platformId, row.id)
@@ -730,7 +732,9 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
const db = createTempDatabase(tempDbPath)
// 准备语句
const insertMeta = db.prepare('INSERT INTO meta (name, platform, type, group_id, group_avatar) VALUES (?, ?, ?, ?, ?)')
const insertMeta = db.prepare(
'INSERT INTO meta (name, platform, type, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?)'
)
const insertMember = db.prepare(
'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)'
)
@@ -764,7 +768,8 @@ export async function streamParseFileInfo(filePath: string, requestId: string):
parsedMeta.platform,
parsedMeta.type,
parsedMeta.groupId || null,
parsedMeta.groupAvatar || null
parsedMeta.groupAvatar || null,
parsedMeta.ownerId || null
)
metaInserted = true
}

1
src/components.d.ts vendored
View File

@@ -28,6 +28,7 @@ declare module 'vue' {
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UProgress: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Progress.vue')['default']
USelect: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Select.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
UTextarea: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.2.1_@babel+parser@7.28.5_axios@1.13.2_embla-carousel@8.6.0_typescript@5.9.3__1572391ae10a8169a5c9784ec5cec455/node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { MemberWithStats } from '@/types/analysis'
import { useSessionStore } from '@/stores/session'
const sessionStore = useSessionStore()
// Props
const props = defineProps<{
sessionId: string
members: MemberWithStats[]
isLoading?: boolean
chatType?: 'group' | 'private'
}>()
// Owner 配置相关
const isSavingOwner = ref(false)
// 获取成员显示名称
function getDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
}
// 当前 owner 的成员信息
const currentOwner = computed(() => {
const ownerId = sessionStore.currentSession?.ownerId
if (!ownerId) return null
return props.members.find((m) => m.platformId === ownerId) || null
})
// 特殊值,用于表示"未设置"
const UNSET_VALUE = '__UNSET__'
// 成员选项(用于下拉选择)
const memberOptions = computed(() => {
return [
{ label: '未设置', value: UNSET_VALUE },
...props.members.map((m) => ({
label: `${getDisplayName(m)} (${m.platformId})`,
value: m.platformId,
})),
]
})
// 当前选中的 owner 值(用于 USelect
const selectedOwnerValue = computed(() => {
return sessionStore.currentSession?.ownerId || UNSET_VALUE
})
// 提示文字
const hintText = computed(() => {
if (currentOwner.value) {
return `当前:${getDisplayName(currentOwner.value)}`
}
return props.chatType === 'group' ? '选择你在群聊中的身份,便于数据分析' : '选择你在对话中的身份,便于数据分析'
})
// 更新 owner
async function updateOwner(value: string) {
const platformId = value === UNSET_VALUE ? null : value
isSavingOwner.value = true
try {
await sessionStore.updateSessionOwnerId(props.sessionId, platformId)
} catch (error) {
console.error('更新所有者失败:', error)
} finally {
isSavingOwner.value = false
}
}
</script>
<template>
<div class="w-150 rounded-lg border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-linear-to-br"
:class="chatType === 'group' ? 'from-pink-400 to-pink-600' : 'from-purple-400 to-purple-600'"
>
<UIcon name="i-heroicons-user" class="h-4 w-4 text-white" />
</div>
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">设置Owner</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ hintText }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<USelect
:model-value="selectedOwnerValue"
:items="memberOptions"
placeholder="选择成员"
class="w-48"
:disabled="isSavingOwner || isLoading"
@update:model-value="updateOwner"
/>
<UIcon
v-if="isSavingOwner"
name="i-heroicons-arrow-path"
class="h-4 w-4 animate-spin"
:class="chatType === 'group' ? 'text-pink-500' : 'text-purple-500'"
/>
</div>
</div>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { MemberWithStats } from '@/types/analysis'
import OwnerSelector from '@/components/analysis/member/OwnerSelector.vue'
// Props
const props = defineProps<{
@@ -17,6 +18,10 @@ const members = ref<MemberWithStats[]>([])
const isLoading = ref(false)
const searchQuery = ref('')
// 删除确认状态
const deletingMember = ref<MemberWithStats | null>(null)
const isDeleting = ref(false)
// 分页配置
const pageSize = 20
const currentPage = ref(1)
@@ -24,10 +29,6 @@ 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)
@@ -188,6 +189,9 @@ onMounted(() => {
</div>
</div>
<!-- Owner配置 -->
<OwnerSelector class="mb-6" :session-id="sessionId" :members="members" :is-loading="isLoading" chat-type="group" />
<!-- 搜索框 -->
<div class="mb-4">
<UInput

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type { MemberWithStats } from '@/types/analysis'
import OwnerSelector from '@/components/analysis/member/OwnerSelector.vue'
// Props
const props = defineProps<{
@@ -104,6 +105,15 @@ onMounted(() => {
</div>
</div>
<!-- Owner配置 -->
<OwnerSelector
class="mb-6"
:session-id="sessionId"
:members="members"
:is-loading="isLoading"
chat-type="private"
/>
<!-- 加载状态 -->
<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" />

View File

@@ -118,6 +118,7 @@ export interface DbMeta {
imported_at: number // 导入时间戳(秒)
group_id: string | null // 群ID群聊类型有值私聊为空
group_avatar: string | null // 群头像base64 Data URL
owner_id: string | null // 所有者/导出者的 platformId
}
/**
@@ -179,6 +180,7 @@ export interface ParseResult {
type: ChatType
groupId?: string // 群ID群聊类型有值
groupAvatar?: string // 群头像base64 Data URL
ownerId?: string // 所有者/导出者的 platformId
}
members: ParsedMember[]
messages: ParsedMessage[]
@@ -200,6 +202,7 @@ export interface AnalysisSession {
dbPath: string // 数据库文件完整路径
groupId: string | null // 群ID群聊类型有值私聊为空
groupAvatar: string | null // 群头像base64 Data URL
ownerId: string | null // 所有者/导出者的 platformId
}
/**
@@ -223,4 +226,3 @@ export interface ImportResult {
sessionId?: string // 成功时返回会话ID
error?: string // 失败时返回错误信息
}

View File

@@ -36,6 +36,7 @@ export interface ChatLabMeta {
sources?: MergeSource[] // 合并来源(可选)
groupId?: string // 群ID可选仅群聊
groupAvatar?: string // 群头像base64 Data URL可选
ownerId?: string // 所有者/导出者的 platformId可选
}
/**
@@ -181,4 +182,3 @@ export interface ChatRecordMessage {
timestamp: number
type: number
}