From 2ce479aefd669e8fff8ed7959048e778a4dcb6f2 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Wed, 11 Feb 2026 12:14:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(chat-ui):=20=E6=8A=BD=E7=A6=BB?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7/=E5=AE=9E=E6=97=B6/=E9=9A=90=E7=A7=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SidebarRail 组件并统一主导航入口 引入 chatAccounts/chatRealtime/privacy 三个 Pinia store 复用全局状态 聊天/联系人/朋友圈页面去重侧栏逻辑,app 根布局统一承载标题栏与内容区 --- frontend/app.vue | 45 ++- frontend/components/SidebarRail.vue | 240 ++++++++++++ frontend/nuxt.config.ts | 3 +- frontend/pages/chat/[[username]].vue | 538 +++------------------------ frontend/pages/contacts.vue | 144 +------ frontend/pages/sns.vue | 226 +---------- frontend/stores/chatAccounts.js | 107 ++++++ frontend/stores/chatRealtime.js | 226 +++++++++++ frontend/stores/privacy.js | 31 ++ frontend/utils/privacy-mode.js | 20 + 10 files changed, 728 insertions(+), 852 deletions(-) create mode 100644 frontend/components/SidebarRail.vue create mode 100644 frontend/stores/chatAccounts.js create mode 100644 frontend/stores/chatRealtime.js create mode 100644 frontend/stores/privacy.js create mode 100644 frontend/utils/privacy-mode.js diff --git a/frontend/app.vue b/frontend/app.vue index f1b0426..6ee997a 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,13 +1,22 @@ diff --git a/frontend/components/SidebarRail.vue b/frontend/components/SidebarRail.vue new file mode 100644 index 0000000..0d3d84d --- /dev/null +++ b/frontend/components/SidebarRail.vue @@ -0,0 +1,240 @@ + + + + diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 5c84c27..bbe6e30 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -31,8 +31,7 @@ export default defineNuxtConfig({ { rel: 'icon', type: 'image/png', href: '/logo.png' }, { rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' } ] - }, - pageTransition: { name: 'page', mode: 'out-in' } + } }, // 模块配置 diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 147654f..6e4fafd 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -1,7 +1,7 @@ - - diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index e8ad502..a23b441 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -1,146 +1,7 @@ - - diff --git a/frontend/stores/chatAccounts.js b/frontend/stores/chatAccounts.js new file mode 100644 index 0000000..053cfa7 --- /dev/null +++ b/frontend/stores/chatAccounts.js @@ -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, + } +}) diff --git a/frontend/stores/chatRealtime.js b/frontend/stores/chatRealtime.js new file mode 100644 index 0000000..132256c --- /dev/null +++ b/frontend/stores/chatRealtime.js @@ -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, + } +}) + diff --git a/frontend/stores/privacy.js b/frontend/stores/privacy.js new file mode 100644 index 0000000..06d579f --- /dev/null +++ b/frontend/stores/privacy.js @@ -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, + } +}) + diff --git a/frontend/utils/privacy-mode.js b/frontend/utils/privacy-mode.js new file mode 100644 index 0000000..85c7ce7 --- /dev/null +++ b/frontend/utils/privacy-mode.js @@ -0,0 +1,20 @@ +export const PRIVACY_MODE_KEY = 'ui.privacy_mode' + +export const readPrivacyMode = (fallback = false) => { + if (!process.client) return !!fallback + try { + const raw = localStorage.getItem(PRIVACY_MODE_KEY) + if (raw == null) return !!fallback + const normalized = String(raw).trim().toLowerCase() + return normalized === '1' || normalized === 'true' + } catch { + return !!fallback + } +} + +export const writePrivacyMode = (enabled) => { + if (!process.client) return + try { + localStorage.setItem(PRIVACY_MODE_KEY, enabled ? '1' : '0') + } catch {} +}