refactor(chat-ui): 抽离侧边栏并统一账号/实时/隐私状态

新增 SidebarRail 组件并统一主导航入口

引入 chatAccounts/chatRealtime/privacy 三个 Pinia store 复用全局状态

聊天/联系人/朋友圈页面去重侧栏逻辑,app 根布局统一承载标题栏与内容区
This commit is contained in:
2977094657
2026-02-11 12:14:21 +08:00
parent 7447a904b3
commit 2ce479aefd
10 changed files with 728 additions and 852 deletions

View File

@@ -0,0 +1,107 @@
import { defineStore } from 'pinia'
const SELECTED_ACCOUNT_KEY = 'ui.selected_account'
export const useChatAccountsStore = defineStore('chatAccounts', () => {
const accounts = ref([])
const selectedAccount = ref(null)
const loading = ref(false)
const error = ref('')
const loaded = ref(false)
let loadPromise = null
const readSelectedAccount = () => {
if (!process.client) return null
try {
const raw = localStorage.getItem(SELECTED_ACCOUNT_KEY)
const v = String(raw || '').trim()
return v || null
} catch {
return null
}
}
const writeSelectedAccount = (value) => {
if (!process.client) return
try {
const v = String(value || '').trim()
if (!v) {
localStorage.removeItem(SELECTED_ACCOUNT_KEY)
return
}
localStorage.setItem(SELECTED_ACCOUNT_KEY, v)
} catch {}
}
const setSelectedAccount = (next) => {
selectedAccount.value = next ? String(next) : null
writeSelectedAccount(selectedAccount.value)
}
if (process.client) {
watch(selectedAccount, (next) => {
writeSelectedAccount(next)
})
}
const ensureLoaded = async ({ force = false } = {}) => {
if (!process.client) return
if (loaded.value && !force) return
if (loadPromise && !force) {
await loadPromise
return
}
loadPromise = (async () => {
loading.value = true
error.value = ''
if (!selectedAccount.value) {
const cached = readSelectedAccount()
if (cached) selectedAccount.value = cached
}
try {
const api = useApi()
const resp = await api.listChatAccounts()
const nextAccounts = Array.isArray(resp?.accounts) ? resp.accounts : []
accounts.value = nextAccounts
const preferred = String(selectedAccount.value || '').trim()
const defaultAccount = String(resp?.default_account || '').trim()
const fallback = defaultAccount || nextAccounts[0] || ''
const nextSelected = preferred && nextAccounts.includes(preferred) ? preferred : (fallback || null)
selectedAccount.value = nextSelected
writeSelectedAccount(nextSelected)
loaded.value = true
} catch (e) {
accounts.value = []
selectedAccount.value = null
writeSelectedAccount(null)
loaded.value = true
error.value = e?.message || '加载账号失败'
} finally {
loading.value = false
}
})()
try {
await loadPromise
} finally {
loadPromise = null
}
}
return {
accounts,
selectedAccount,
loading,
error,
loaded,
ensureLoaded,
setSelectedAccount,
}
})

View File

@@ -0,0 +1,226 @@
import { defineStore } from 'pinia'
import { useChatAccountsStore } from '~/stores/chatAccounts'
export const useChatRealtimeStore = defineStore('chatRealtime', () => {
const chatAccounts = useChatAccountsStore()
const enabled = ref(false)
const available = ref(false)
const checking = ref(false)
const statusInfo = ref(null)
const statusError = ref('')
const toggling = ref(false)
const toggleSeq = ref(0)
const lastToggleAction = ref('')
const changeSeq = ref(0)
const priorityUsername = ref('')
let eventSource = null
let changeDebounceTimer = null
const getAccount = () => String(chatAccounts.selectedAccount || '').trim()
const setPriorityUsername = (username) => {
priorityUsername.value = String(username || '').trim()
}
const ensureReadyAccount = async () => {
if (!process.client) return false
await chatAccounts.ensureLoaded()
return !!getAccount()
}
const fetchStatus = async () => {
if (!process.client) return
const account = getAccount()
if (!account) {
available.value = false
statusInfo.value = null
statusError.value = '未检测到已解密账号,请先解密数据库。'
return
}
const api = useApi()
checking.value = true
statusError.value = ''
try {
const resp = await api.getChatRealtimeStatus({ account })
available.value = !!resp?.available
statusInfo.value = resp?.realtime || null
statusError.value = ''
} catch (e) {
available.value = false
statusInfo.value = null
statusError.value = e?.message || '实时状态获取失败'
} finally {
checking.value = false
}
}
const stopStream = () => {
if (eventSource) {
try {
eventSource.close()
} catch {}
eventSource = null
}
if (changeDebounceTimer) {
try {
clearTimeout(changeDebounceTimer)
} catch {}
changeDebounceTimer = null
}
}
const bumpChangeSeqDebounced = () => {
if (changeDebounceTimer) return
changeDebounceTimer = setTimeout(() => {
changeDebounceTimer = null
changeSeq.value += 1
}, 500)
}
const startStream = () => {
stopStream()
if (!process.client || typeof window === 'undefined') return
if (!enabled.value) return
const account = getAccount()
if (!account) return
if (typeof EventSource === 'undefined') return
const base = 'http://localhost:8000'
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(account)}`
try {
eventSource = new EventSource(url)
} catch {
eventSource = null
return
}
eventSource.onmessage = (ev) => {
try {
const data = JSON.parse(String(ev.data || '{}'))
if (String(data?.type || '') === 'change') {
bumpChangeSeqDebounced()
}
} catch {}
}
eventSource.onerror = () => {
// Keep `enabled` as-is; same behavior as the old in-page implementation.
stopStream()
}
}
const enable = async ({ silent = false } = {}) => {
if (toggling.value) return false
toggling.value = true
try {
const ok = await ensureReadyAccount()
if (!ok) {
if (!silent && process.client && typeof window !== 'undefined') {
window.alert('未检测到已解密账号,请先解密数据库。')
}
statusError.value = '未检测到已解密账号,请先解密数据库。'
return false
}
await fetchStatus()
if (!available.value) {
if (!silent && process.client && typeof window !== 'undefined') {
window.alert(statusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
}
return false
}
enabled.value = true
startStream()
lastToggleAction.value = 'enabled'
toggleSeq.value += 1
return true
} finally {
toggling.value = false
}
}
const disable = async ({ silent = false } = {}) => {
if (toggling.value) return false
toggling.value = true
try {
const account = getAccount()
enabled.value = false
stopStream()
if (!account) {
lastToggleAction.value = 'disabled'
toggleSeq.value += 1
return true
}
try {
const api = useApi()
await api.syncChatRealtimeAll({
account,
max_scan: 200,
priority_username: priorityUsername.value || '',
priority_max_scan: 5000,
include_hidden: true,
include_official: true,
})
} catch (e) {
if (!silent && process.client && typeof window !== 'undefined') {
window.alert(e?.message || '关闭实时模式时同步失败')
}
}
lastToggleAction.value = 'disabled'
toggleSeq.value += 1
return true
} finally {
toggling.value = false
}
}
const toggle = async (opts = {}) => {
return enabled.value ? await disable(opts) : await enable(opts)
}
if (process.client) {
watch(
() => chatAccounts.selectedAccount,
async () => {
setPriorityUsername('')
await fetchStatus()
if (enabled.value) {
startStream()
}
},
{ immediate: true }
)
}
return {
enabled,
available,
checking,
statusInfo,
statusError,
toggling,
toggleSeq,
lastToggleAction,
changeSeq,
priorityUsername,
setPriorityUsername,
ensureReadyAccount,
fetchStatus,
startStream,
stopStream,
enable,
disable,
toggle,
}
})

View File

@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { readPrivacyMode, writePrivacyMode } from '~/utils/privacy-mode'
export const usePrivacyStore = defineStore('privacy', () => {
const privacyMode = ref(false)
const initialized = ref(false)
const init = () => {
if (initialized.value) return
initialized.value = true
privacyMode.value = readPrivacyMode(false)
}
const set = (enabled) => {
privacyMode.value = !!enabled
writePrivacyMode(privacyMode.value)
}
const toggle = () => {
set(!privacyMode.value)
}
return {
privacyMode,
init,
set,
toggle,
}
})