mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-04-27 15:29:49 +08:00
feat: 成员Tab中支持设置Owner视角
This commit is contained in:
@@ -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_
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ParsedMeta {
|
||||
type: ChatType
|
||||
groupId?: string // 群ID(群聊类型有值)
|
||||
groupAvatar?: string // 群头像(base64 Data URL)
|
||||
ownerId?: string // 所有者/导出者的 platformId
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
1
src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
105
src/components/analysis/member/OwnerSelector.vue
Normal file
105
src/components/analysis/member/OwnerSelector.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 // 失败时返回错误信息
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user