feat(settings): manage session owner identities

This commit is contained in:
digua committed 2026-07-12 15:29:09 +08:00
1 parent abf4a015cc
commit f198b41bdb
12 files changed
+206 -23

No files matched your search

@@ -24,6 +24,8 @@ export interface AnalysisSessionDTO extends CoreSessionInfo {
dbPath: string
memberAvatar: string | null
aiConversationCount: number
ownerName: string | null
ownerStatus: 'resolved' | 'missing' | 'unresolved'
}
/**
@@ -64,7 +66,26 @@ function buildSession(
memberAvatar = getPrivateChatMemberAvatar(db, meta.name, meta.ownerId)
}
let dto: AnalysisSessionDTO = { ...info, id, dbPath, memberAvatar, aiConversationCount: 0 }
const ownerMember = meta.ownerId
? (db
.prepare(
`SELECT COALESCE(NULLIF(group_nickname, ''), NULLIF(account_name, ''), platform_id) as displayName
FROM member
WHERE platform_id = ?
LIMIT 1`
)
.get(meta.ownerId) as { displayName: string } | undefined)
: undefined
let dto: AnalysisSessionDTO = {
...info,
id,
dbPath,
memberAvatar,
aiConversationCount: 0,
ownerName: ownerMember?.displayName ?? null,
ownerStatus: !meta.ownerId ? 'missing' : ownerMember ? 'resolved' : 'unresolved',
}
if (options?.enrichSession) {
dto = options.enrichSession(dto)
}
@@ -54,7 +54,9 @@ const isDismissing = ref(false)
const searchQuery = ref('')
const selectedPlatformId = ref<string | null>(null)
const currentOwnerId = computed(() => sessionStore.currentSession?.ownerId ?? null)
const currentOwnerId = computed(
() => sessionStore.sessions.find((session) => session.id === props.sessionId)?.ownerId ?? null
)
function getDisplayName(member: MemberWithStats): string {
return member.groupNickname || member.accountName || member.platformId
@@ -154,18 +156,26 @@ function later() {
isOpen.value = false
}
// 打开时加载成员并默认选中当前 owner
watch(isOpen, (open) => {
if (open) {
searchQuery.value = ''
selectedPlatformId.value = currentOwnerId.value
loadMembers()
}
})
function prepareOwnerSelection() {
searchQuery.value = ''
selectedPlatformId.value = currentOwnerId.value
members.value = []
void loadMembers()
}
// 打开时加载成员并默认选中当前 owner。immediate 覆盖以打开状态首次挂载的场景。
watch(
isOpen,
(open) => {
if (open) prepareOwnerSelection()
},
{ immediate: true }
)
watch(
() => props.sessionId,
() => {
if (isOpen.value) prepareOwnerSelection()
checkAndAutoOpen()
}
)
@@ -176,7 +186,7 @@ onMounted(() => {
</script>
<template>
<UModal v-model:open="isOpen" :ui="{ content: 'max-w-md' }">
<UModal v-model:open="isOpen" :ui="{ content: 'max-w-md z-[111]', overlay: 'z-[110]' }">
<template #content>
<div class="p-6">
<!-- 头部 -->
@@ -270,8 +280,8 @@ onMounted(() => {
<div class="mt-5 flex items-center justify-between gap-2">
<UButton
v-if="!currentOwnerId"
variant="ghost"
color="neutral"
variant="soft"
color="primary"
size="sm"
:loading="isDismissing"
@click="dismissForever"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useVirtualizer } from '@tanstack/vue-virtual'
@@ -7,6 +7,7 @@ import { useToast } from '@/composables/useToast'
import { useSessionStore } from '@/stores/session'
import type { AnalysisSession } from '@/types/base'
import LazyAvatar from '@/components/common/avatar/LazyAvatar.vue'
import OwnerPromptModal from '@/components/analysis/member/OwnerPromptModal.vue'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
@@ -15,12 +16,24 @@ dayjs.extend(relativeTime)
const toast = useToast()
const SESSION_ROW_HEIGHT = 48
const props = withDefaults(
defineProps<{
focusOwnerIssues?: boolean
}>(),
{
focusOwnerIssues: false,
}
)
const { t, locale } = useI18n()
const sessionStore = useSessionStore()
const { sessions } = storeToRefs(sessionStore)
// 搜索关键词
const searchQuery = ref('')
const ownerIssueCount = computed(
() => sessions.value.filter((session) => session.ownerStatus !== 'resolved').length
)
// 过滤后的会话列表
const filteredSessions = computed(() => {
@@ -28,10 +41,12 @@ const filteredSessions = computed(() => {
return sessions.value
}
const query = searchQuery.value.toLowerCase().trim()
return sessions.value.filter((s) => s.name.toLowerCase().includes(query) || s.platform.toLowerCase().includes(query))
return sessions.value.filter(
(session) => session.name.toLowerCase().includes(query) || session.platform.toLowerCase().includes(query)
)
})
type SortField = 'name' | 'platform' | 'messageCount' | 'importedAt'
type SortField = 'name' | 'platform' | 'owner' | 'messageCount' | 'importedAt'
type SortDirection = 'asc' | 'desc'
type HeaderAlign = 'left' | 'center' | 'right'
type HeaderColumn =
@@ -75,6 +90,14 @@ const headerColumns: HeaderColumn[] = [
class: 'w-20',
align: 'center',
},
{
key: 'owner',
type: 'sortable',
field: 'owner',
labelKey: 'tools.batchManage.columns.owner',
class: 'w-28',
align: 'left',
},
{
key: 'messages',
type: 'sortable',
@@ -108,8 +131,8 @@ const headerColumns: HeaderColumn[] = [
]
const sortState = ref<{ field: SortField | null; direction: SortDirection | null }>({
field: null,
direction: null,
field: 'importedAt',
direction: 'desc',
})
function getDefaultDirection(_field: SortField): SortDirection {
@@ -173,6 +196,13 @@ const sortedSessions = computed(() => {
if (field === 'platform') {
return getPlatformLabel(a.platform).localeCompare(getPlatformLabel(b.platform), locale.value) * multiplier
}
if (field === 'owner') {
const ownerValue = (session: AnalysisSession) => {
if (session.ownerStatus === 'resolved') return `0:${session.ownerName || session.ownerId || ''}`
return session.ownerStatus === 'unresolved' ? '1:' : '2:'
}
return ownerValue(a).localeCompare(ownerValue(b), locale.value) * multiplier
}
if (field === 'messageCount') {
return (a.messageCount - b.messageCount) * multiplier
}
@@ -183,6 +213,20 @@ const sortedSessions = computed(() => {
})
const listScrollRef = ref<HTMLElement | null>(null)
function prioritizeOwnerIssues() {
sortState.value = { field: 'owner', direction: 'desc' }
nextTick(() => listScrollRef.value?.scrollTo({ top: 0 }))
}
watch(
() => props.focusOwnerIssues,
(focus) => {
if (focus) prioritizeOwnerIssues()
},
{ immediate: true }
)
const sessionVirtualizer = useVirtualizer(
computed(() => ({
count: sortedSessions.value.length,
@@ -215,6 +259,32 @@ const editingId = ref<string | null>(null)
// 编辑中的名称
const editingName = ref('')
const ownerSessionId = ref<string | null>(null)
const showOwnerModal = ref(false)
const continueOwnerSetup = ref(false)
const ownerSession = computed(() => sessions.value.find((session) => session.id === ownerSessionId.value) ?? null)
function openOwnerModal(session: AnalysisSession, event: Event) {
event.stopPropagation()
ownerSessionId.value = session.id
continueOwnerSetup.value = session.ownerStatus !== 'resolved'
showOwnerModal.value = true
}
async function refreshAfterOwnerChange() {
await sessionStore.loadSessions()
if (!continueOwnerSetup.value) return
await nextTick()
const nextSession = sortedSessions.value.find((session) => session.ownerStatus !== 'resolved')
if (nextSession) {
ownerSessionId.value = nextSession.id
showOwnerModal.value = true
} else {
continueOwnerSetup.value = false
}
}
const selectedMergeSessions = computed(() => sessions.value.filter((s) => selectedIds.value.has(s.id)))
const selectedMergeTypes = computed(() => new Set(selectedMergeSessions.value.map((s) => s.type)))
@@ -538,6 +608,17 @@ onMounted(() => {
<template>
<div class="flex h-full flex-col">
<button
v-if="ownerIssueCount > 0"
type="button"
class="mb-3 inline-flex w-fit max-w-full items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-left text-xs text-amber-800 transition-colors hover:bg-amber-100 dark:bg-amber-950/20 dark:text-amber-300 dark:hover:bg-amber-950/30"
@click="prioritizeOwnerIssues"
>
<UIcon name="i-heroicons-user-circle" class="h-4 w-4 shrink-0" />
<span>{{ t('tools.batchManage.ownerIssues', { count: ownerIssueCount }) }}</span>
<UIcon name="i-heroicons-arrow-right" class="h-3.5 w-3.5 shrink-0 opacity-70" />
</button>
<!-- 搜索栏 -->
<div class="mb-4">
<UInput
@@ -713,6 +794,28 @@ onMounted(() => {
</span>
</div>
<!-- 我是谁 -->
<button
type="button"
class="flex w-28 min-w-0 items-center gap-1.5 text-left text-xs transition-colors hover:text-primary-600 dark:hover:text-primary-400"
:class="session.ownerStatus === 'resolved' ? 'text-gray-600 dark:text-gray-300' : 'text-amber-600 dark:text-amber-400'"
:title="session.ownerId || t('tools.batchManage.ownerStatus.missing')"
@click="openOwnerModal(session, $event)"
>
<UIcon
:name="session.ownerStatus === 'resolved' ? 'i-heroicons-user-circle' : 'i-heroicons-exclamation-circle'"
class="h-3.5 w-3.5 shrink-0"
/>
<span class="truncate">
{{
session.ownerStatus === 'resolved'
? session.ownerName || session.ownerId
: t(`tools.batchManage.ownerStatus.${session.ownerStatus}`)
}}
</span>
<UIcon name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-60" />
</button>
<!-- 消息数 -->
<div class="w-24 text-right text-sm text-gray-600 dark:text-gray-300">
{{ session.messageCount.toLocaleString() }}
@@ -737,6 +840,15 @@ onMounted(() => {
</div>
</div>
<OwnerPromptModal
v-if="ownerSession"
v-model="showOwnerModal"
:session-id="ownerSession.id"
:chat-type="ownerSession.type"
@saved="refreshAfterOwnerChange"
@cleared="refreshAfterOwnerChange"
/>
<!-- 合并确认弹窗 -->
<UModal v-model:open="showMergeModal" :ui="{ content: 'z-[101]', overlay: 'z-[100]' }">
<template #content>
+5 -1
View File
@@ -116,7 +116,11 @@ watch(showSettings, async (visible) => {
:ref="(el: unknown) => setTabRef('ai', el)"
@config-changed="handleAIConfigChanged"
/>
<BatchManageTab v-else-if="activeTab === 'data'" key="data" />
<BatchManageTab
v-else-if="activeTab === 'data'"
key="data"
:focus-owner-issues="settingsSubTab === 'missing-owner'"
/>
<ApiSettingsTab v-else-if="activeTab === 'api'" key="api" />
<StorageTab
v-else-if="activeTab === 'storage'"
+6
View File
@@ -2,6 +2,11 @@
"batchManage": {
"searchPlaceholder": "Search conversation name",
"searchResult": "Found {count} / {total}",
"ownerIssues": "{count} conversations have not set who you are",
"ownerStatus": {
"missing": "Not set",
"unresolved": "Needs review"
},
"noSearchResult": "No matching chat records found",
"selectAll": "Select All",
"shiftClickHint": "Hold Shift to select a range",
@@ -14,6 +19,7 @@
"columns": {
"name": "Name",
"platform": "Platform",
"owner": "Me",
"messages": "Messages",
"summaries": "AI Summaries",
"aiChats": "AI Chats",
+6
View File
@@ -2,6 +2,11 @@
"batchManage": {
"searchPlaceholder": "チャット名を検索",
"searchResult": "{count} / {total} 件を検出",
"ownerIssues": "{count} 件の会話で自分が未設定です",
"ownerStatus": {
"missing": "未設定",
"unresolved": "要確認"
},
"noSearchResult": "一致するチャット履歴が見つかりません",
"selectAll": "すべて選択",
"shiftClickHint": "Shiftキーを押しながらクリックで範囲選択できます",
@@ -14,6 +19,7 @@
"columns": {
"name": "名前",
"platform": "プラットフォーム",
"owner": "自分",
"messages": "メッセージ数",
"summaries": "AI要約",
"aiChats": "AI チャット",
+6
View File
@@ -2,6 +2,11 @@
"batchManage": {
"searchPlaceholder": "搜索对话名称",
"searchResult": "找到 {count} / {total} 个",
"ownerIssues": "{count} 个会话尚未设置“我是谁”",
"ownerStatus": {
"missing": "未设置",
"unresolved": "需确认"
},
"noSearchResult": "未找到匹配的聊天记录",
"selectAll": "全选",
"shiftClickHint": "按住 Shift 点击可范围多选",
@@ -14,6 +19,7 @@
"columns": {
"name": "名称",
"platform": "平台",
"owner": "我是谁",
"messages": "消息数",
"summaries": "AI摘要",
"aiChats": "AI 对话",
+6
View File
@@ -2,6 +2,11 @@
"batchManage": {
"searchPlaceholder": "搜尋聊天名稱",
"searchResult": "找到 {count} / {total} 筆",
"ownerIssues": "{count} 個會話尚未設定「我是誰」",
"ownerStatus": {
"missing": "未設定",
"unresolved": "需確認"
},
"noSearchResult": "未找到匹配的聊天紀錄",
"selectAll": "全選",
"shiftClickHint": "按住 Shift 點擊可範圍多選",
@@ -14,6 +19,7 @@
"columns": {
"name": "名稱",
"platform": "平台",
"owner": "我是誰",
"messages": "訊息數",
"summaries": "AI摘要",
"aiChats": "AI 對話",
+3 -3
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/layout'
import type { AnnualSummaryFetchOptions } from '@/services/data/types'
import type { AnnualSummaryResponse } from '@openchatlab/shared-types'
import LoadingState from '@/components/UI/LoadingState.vue'
@@ -11,7 +11,7 @@ import { useAnnualSummaryTimeRange } from '../annual-summary-time-range'
import AnnualInsightBoard from './components/AnnualInsightBoard.vue'
const { t } = useI18n()
const router = useRouter()
const layoutStore = useLayoutStore()
const currentYear = new Date().getFullYear()
const timeRange = useAnnualSummaryTimeRange()
const response = ref<AnnualSummaryResponse | null>(null)
@@ -103,7 +103,7 @@ function switchToLatestYear(): void {
}
function openSessions(): void {
void router.push('/')
layoutStore.openSettings('data', 'missing-owner')
}
</script>
+5 -1
View File
@@ -119,7 +119,11 @@ onMounted(async () => {
:ref="(el) => setTabRef('ai', el)"
@config-changed="handleAIConfigChanged"
/>
<BatchManageTab v-else-if="activeTab === 'data'" key="data" />
<BatchManageTab
v-else-if="activeTab === 'data'"
key="data"
:focus-owner-issues="route.query.subTab === 'missing-owner'"
/>
<ApiSettingsTab v-else-if="activeTab === 'api'" key="api" />
<StorageTab v-else-if="activeTab === 'storage'" key="storage" :ref="(el) => setTabRef('storage', el)" />
<AboutTab v-else-if="activeTab === 'about'" key="about" />
+6
View File
@@ -679,6 +679,8 @@ export const useSessionStore = defineStore(
const session = sessions.value.find((s) => s.id === id)
if (session) {
session.ownerId = ownerId
session.ownerName = null
session.ownerStatus = ownerId ? 'unresolved' : 'missing'
}
}
return success
@@ -698,6 +700,8 @@ export const useSessionStore = defineStore(
const session = sessions.value.find((s) => s.id === sessionId)
if (session) {
session.ownerId = result.updatedSessionOwnerIds[sessionId] ?? result.ownerId
session.ownerName = session.ownerId
session.ownerStatus = 'resolved'
}
}
return result
@@ -712,6 +716,8 @@ export const useSessionStore = defineStore(
const session = sessions.value.find((s) => s.id === id)
if (session) {
session.ownerId = result.ownerId
session.ownerName = result.ownerId
session.ownerStatus = 'resolved'
}
}
return result
+2
View File
@@ -128,6 +128,8 @@ export interface AnalysisSession {
groupId: string | null // 群ID(群聊类型有值,私聊为空)
groupAvatar: string | null // 群头像(base64 Data URL)
ownerId: string | null // 所有者/导出者的 platformId
ownerName: string | null // 所有者在当前会话中的显示名称
ownerStatus: 'resolved' | 'missing' | 'unresolved' // 所有者是否能匹配当前会话成员
memberAvatar: string | null // 私聊对方头像(base64 Data URL)
lastMessageTs: number | null // 最后一条消息时间戳(秒)
summaryCount: number // 已生成摘要的会话片段数