Skip to content

Commit f69999e

Browse files
committed
feat: support switchable multi-account sidebar
1 parent 9cc4ecd commit f69999e

6 files changed

Lines changed: 455 additions & 48 deletions

File tree

frontend/components/SidebarRail.vue

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<button
99
type="button"
1010
class="group relative w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0 ring-1 ring-transparent transition hover:ring-[#07b75b]/40"
11-
title="账号信息"
11+
:title="avatarButtonTitle"
1212
@click="openAccountDialog"
1313
>
1414
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
@@ -292,9 +292,9 @@
292292
class="account-info-dialog fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
293293
@click.self="closeAccountDialog"
294294
>
295-
<div class="account-info-dialog-panel w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
295+
<div class="account-info-dialog-panel w-full max-w-[520px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
296296
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
297-
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
297+
<div class="text-[14px] font-semibold text-[#222]">账号切换</div>
298298
<button
299299
type="button"
300300
class="flex h-7 w-7 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
@@ -309,6 +309,67 @@
309309
</div>
310310

311311
<div class="space-y-3 px-4 py-4">
312+
<div class="rounded-[10px] border border-[#ededed] bg-[#fafafa] p-2">
313+
<div class="mb-2 flex items-center justify-between px-1">
314+
<div class="text-[12px] font-medium text-[#444]">可切换账号</div>
315+
<button
316+
type="button"
317+
class="text-[11px] text-[#07b75b] hover:text-[#04994c] disabled:cursor-not-allowed disabled:opacity-50"
318+
:disabled="chatAccounts.loading"
319+
@click="refreshSwitchableAccounts"
320+
>
321+
{{ chatAccounts.loading ? '刷新中...' : '刷新' }}
322+
</button>
323+
</div>
324+
325+
<div v-if="chatAccounts.loading && !switchableAccountItems.length" class="px-1 py-2 text-[12px] text-[#7a7a7a]">
326+
正在加载可切换账号...
327+
</div>
328+
<div v-else-if="!switchableAccountItems.length" class="space-y-1 px-1 py-2">
329+
<div class="text-[12px] text-[#666]">暂无可切换账号</div>
330+
<div class="text-[11px] leading-relaxed text-[#8a8a8a]">
331+
只有已经获取并保存“数据库密钥 + 图片密钥”的账号会出现在这里。
332+
</div>
333+
<div v-if="chatAccounts.error" class="text-[11px] text-red-600">{{ chatAccounts.error }}</div>
334+
</div>
335+
<div v-else class="max-h-[260px] space-y-1 overflow-y-auto pr-1">
336+
<button
337+
v-for="item in switchableAccountItems"
338+
:key="item.account"
339+
type="button"
340+
class="flex w-full items-center gap-3 rounded-[8px] border px-2.5 py-2 text-left transition"
341+
:class="item.active ? 'border-[#07b75b]/45 bg-[#ecfff5]' : 'border-transparent bg-white hover:border-[#e4e4e4] hover:bg-[#f7f7f7]'"
342+
:disabled="accountDeleteLoading"
343+
@click="selectAccountFromDialog(item.account)"
344+
>
345+
<div class="h-[38px] w-[38px] shrink-0 overflow-hidden rounded-md bg-gray-300">
346+
<img
347+
v-if="item.avatarUrl && !accountAvatarBroken[item.account]"
348+
:src="item.avatarUrl"
349+
alt="avatar"
350+
class="h-full w-full object-cover"
351+
loading="lazy"
352+
@error="markAccountAvatarBroken(item.account)"
353+
/>
354+
<div
355+
v-else
356+
class="flex h-full w-full items-center justify-center text-xs font-bold text-white"
357+
:style="{ backgroundColor: '#4B5563' }"
358+
>
359+
{{ accountFallbackText(item.account) }}
360+
</div>
361+
</div>
362+
<div class="min-w-0 flex-1">
363+
<div class="truncate text-[13px] font-semibold text-[#222]">{{ item.account }}</div>
364+
</div>
365+
<div class="flex shrink-0 items-center gap-1">
366+
<span class="rounded-full bg-[#eefbf4] px-1.5 py-0.5 text-[10px] font-medium text-[#07964c]">DB</span>
367+
<span class="rounded-full bg-[#eefbf4] px-1.5 py-0.5 text-[10px] font-medium text-[#07964c]">图片</span>
368+
</div>
369+
</button>
370+
</div>
371+
</div>
372+
312373
<div v-if="accountInfoLoading" class="text-[12px] text-[#7a7a7a]">正在加载账号信息...</div>
313374
<template v-else>
314375
<div class="flex items-center gap-3">
@@ -329,10 +390,6 @@
329390
</div>
330391

331392
<div class="rounded-[8px] border border-[#ededed] bg-[#fafafa] px-3 py-2 text-[12px] text-[#5f5f5f] space-y-1.5">
332-
<div class="flex items-start justify-between gap-3">
333-
<span class="text-[#8a8a8a] shrink-0">读取模式</span>
334-
<span class="font-medium text-[#333]">{{ accountModeText }}</span>
335-
</div>
336393
<div class="flex items-start justify-between gap-3">
337394
<span class="text-[#8a8a8a] shrink-0">本地解密库数量</span>
338395
<span class="font-medium text-[#333]">{{ accountInfo?.database_count ?? '—' }}</span>
@@ -381,7 +438,10 @@ import { useThemeStore } from '~/stores/theme'
381438
const route = useRoute()
382439
383440
const chatAccounts = useChatAccountsStore()
384-
const { selectedAccount } = storeToRefs(chatAccounts)
441+
const {
442+
selectedAccount,
443+
switchableAccounts,
444+
} = storeToRefs(chatAccounts)
385445
386446
const privacyStore = usePrivacyStore()
387447
const { privacyMode } = storeToRefs(privacyStore)
@@ -405,14 +465,9 @@ const accountDeleteLoading = ref(false)
405465
const accountDeleteError = ref('')
406466
const accountInfoApiUnsupported = ref(false)
407467
const deleteAccountApiUnsupported = ref(false)
468+
const accountAvatarBroken = ref({})
408469
409-
const accountModeText = computed(() => {
410-
const mode = String(accountInfo.value?.mode || '').trim()
411-
const realtimeAvailable = !!accountInfo.value?.realtimeAvailable || !!accountInfo.value?.realtime?.available
412-
if (mode === 'direct' || realtimeAvailable) return '原始 WCDB 实时读取'
413-
if (mode === 'decrypted' || accountInfo.value?.hasDecryptedDbs) return '旧本地解密库'
414-
return ''
415-
})
470+
const normalizeAccountName = (value) => String(value || '').trim()
416471
417472
const accountDataPath = computed(() => {
418473
const info = accountInfo.value || {}
@@ -425,6 +480,44 @@ const accountDataPath = computed(() => {
425480
)
426481
})
427482
483+
const avatarButtonTitle = computed(() => {
484+
const count = Array.isArray(switchableAccounts.value) ? switchableAccounts.value.length : 0
485+
if (count > 1) return '切换账号'
486+
return '账号信息'
487+
})
488+
489+
const accountAvatarUrl = (account) => {
490+
const acc = normalizeAccountName(account)
491+
if (!acc) return ''
492+
return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
493+
}
494+
495+
const accountFallbackText = (account) => {
496+
const value = normalizeAccountName(account)
497+
if (!value) return ''
498+
return value.slice(0, 1).toUpperCase()
499+
}
500+
501+
const switchableAccountItems = computed(() => {
502+
const list = Array.isArray(switchableAccounts.value) ? switchableAccounts.value : []
503+
return list
504+
.map((account) => normalizeAccountName(account))
505+
.filter(Boolean)
506+
.map((account) => {
507+
return {
508+
account,
509+
active: account === normalizeAccountName(selectedAccount.value),
510+
avatarUrl: accountAvatarUrl(account),
511+
}
512+
})
513+
})
514+
515+
const markAccountAvatarBroken = (account) => {
516+
const acc = normalizeAccountName(account)
517+
if (!acc) return
518+
accountAvatarBroken.value = { ...accountAvatarBroken.value, [acc]: true }
519+
}
520+
428521
const sessionUpdatedAtText = computed(() => {
429522
const ts = Number(accountInfo.value?.session_updated_at || 0)
430523
if (!Number.isFinite(ts) || ts <= 0) return ''
@@ -553,9 +646,24 @@ const apiBase = useApiBase()
553646
const selfAvatarUrl = computed(() => {
554647
const acc = String(selectedAccount.value || '').trim()
555648
if (!acc) return ''
556-
return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
649+
return accountAvatarUrl(acc)
557650
})
558651
652+
const refreshSwitchableAccounts = async () => {
653+
await chatAccounts.ensureLoaded({ force: true })
654+
}
655+
656+
const selectAccountFromDialog = async (account) => {
657+
const next = normalizeAccountName(account)
658+
if (!next || accountDeleteLoading.value) return
659+
if (next === normalizeAccountName(selectedAccount.value)) return
660+
661+
accountInfoError.value = ''
662+
accountDeleteError.value = ''
663+
accountInfo.value = null
664+
chatAccounts.setSelectedAccount(next)
665+
}
666+
559667
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
560668
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
561669
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))

frontend/pages/chat/[[username]].vue

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ const selectContact = async (contact, options = {}) => {
346346
const applyRouteSelection = async (options = {}) => {
347347
const selectionReason = String(options.reason || 'route-selection').trim() || 'route-selection'
348348
const requested = routeUsername.value || ''
349+
const fallbackToFirstWhenMissing = !!options.fallbackToFirstWhenMissing
349350
if ((!contacts.value || contacts.value.length === 0) && requested) {
350351
if (selectedContact.value?.username === requested) {
351352
return
@@ -377,6 +378,15 @@ const applyRouteSelection = async (options = {}) => {
377378
}
378379
return
379380
}
381+
if (fallbackToFirstWhenMissing) {
382+
await selectContact(contacts.value[0], {
383+
syncRoute: true,
384+
replaceRoute: true,
385+
deferLoadMessages: !!options.deferLoadMessages,
386+
reason: `${selectionReason}:route-missing-fallback-first-contact`
387+
})
388+
return
389+
}
380390
await selectContact(buildTransientContact({ username: requested }), {
381391
syncRoute: false,
382392
deferLoadMessages: !!options.deferLoadMessages,
@@ -502,7 +512,11 @@ const {
502512
503513
const { stopExportPolling } = exportState
504514
515+
let accountBootstrapInProgress = false
516+
let accountChangeInProgress = false
517+
505518
const resetAccountScopedState = () => {
519+
selectedContact.value = null
506520
resetMessageState()
507521
searchState.resetSearchState()
508522
closeContextMenu()
@@ -531,33 +545,40 @@ const queueRealtimeSessionsRefresh = () => {
531545
}
532546
533547
const onAccountChange = async () => {
534-
logChatBootstrap('accountChange:start', {
535-
selectedAccount: selectedAccount.value
536-
})
537-
await realtimeStore.enable({ silent: true })
548+
if (accountChangeInProgress) return
549+
accountChangeInProgress = true
538550
try {
539-
isLoadingContacts.value = true
540-
contactsError.value = ''
541-
await loadSessionsForSelectedAccount()
542-
} catch (error) {
543-
contactsError.value = error?.message || '加载会话失败'
551+
logChatBootstrap('accountChange:start', {
552+
selectedAccount: selectedAccount.value
553+
})
554+
resetAccountScopedState()
555+
try {
556+
await realtimeStore.enable({ silent: true })
557+
isLoadingContacts.value = true
558+
contactsError.value = ''
559+
await loadSessionsForSelectedAccount()
560+
} catch (error) {
561+
contactsError.value = error?.message || '加载会话失败'
562+
} finally {
563+
isLoadingContacts.value = false
564+
}
565+
566+
logChatBootstrap('accountChange:applyRouteSelection:start', {
567+
selectedAccount: selectedAccount.value,
568+
contactCount: contacts.value.length
569+
})
570+
await applyRouteSelection({
571+
reason: 'account-change',
572+
fallbackToFirstWhenMissing: true
573+
})
574+
logChatBootstrap('accountChange:end', {
575+
selectedAccount: selectedAccount.value,
576+
selectedUsername: selectedContact.value?.username || '',
577+
contactCount: contacts.value.length
578+
})
544579
} finally {
545-
isLoadingContacts.value = false
580+
accountChangeInProgress = false
546581
}
547-
548-
resetAccountScopedState()
549-
logChatBootstrap('accountChange:applyRouteSelection:start', {
550-
selectedAccount: selectedAccount.value,
551-
contactCount: contacts.value.length
552-
})
553-
await applyRouteSelection({
554-
reason: 'account-change'
555-
})
556-
logChatBootstrap('accountChange:end', {
557-
selectedAccount: selectedAccount.value,
558-
selectedUsername: selectedContact.value?.username || '',
559-
contactCount: contacts.value.length
560-
})
561582
}
562583
563584
const onGlobalClick = (event) => {
@@ -669,9 +690,14 @@ onMounted(async () => {
669690
logChatBootstrap('loadContacts:start', {
670691
selectedAccount: selectedAccount.value
671692
})
672-
await chatAccounts.ensureLoaded()
673-
await realtimeStore.enable({ silent: true })
674-
await loadContacts()
693+
accountBootstrapInProgress = true
694+
try {
695+
await chatAccounts.ensureLoaded()
696+
await realtimeStore.enable({ silent: true })
697+
await loadContacts()
698+
} finally {
699+
accountBootstrapInProgress = false
700+
}
675701
logChatBootstrap('loadContacts:end', {
676702
selectedAccount: selectedAccount.value,
677703
contactCount: contacts.value.length
@@ -755,6 +781,16 @@ watch(messageTypeFilter, async (next, prev) => {
755781
await refreshSelectedMessages()
756782
})
757783
784+
watch(
785+
selectedAccount,
786+
async (next, prev) => {
787+
if (!process.client) return
788+
if (accountBootstrapInProgress) return
789+
if (String(next || '') === String(prev || '')) return
790+
await onAccountChange()
791+
}
792+
)
793+
758794
watch(
759795
routeUsername,
760796
async (next, prev) => {

0 commit comments

Comments
 (0)