|
99 | 99 | </div> |
100 | 100 |
|
101 | 101 | <!-- 中间列表区域 --> |
102 | | - <div class="w-80 border-r border-gray-200 flex flex-col min-h-0" style="background-color: #F7F7F7"> |
| 102 | + <div |
| 103 | + class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative" |
| 104 | + :style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }" |
| 105 | + > |
| 106 | + <!-- 拖动调整会话列表宽度 --> |
| 107 | + <div |
| 108 | + class="session-list-resizer" |
| 109 | + :class="{ 'session-list-resizer-active': sessionListResizing }" |
| 110 | + title="拖动调整会话列表宽度" |
| 111 | + @pointerdown="onSessionListResizerPointerDown" |
| 112 | + @dblclick="resetSessionListWidth" |
| 113 | + /> |
103 | 114 | <!-- 聊天列表 --> |
104 | 115 | <div class="h-full flex flex-col min-h-0"> |
105 | 116 | <!-- 搜索栏 --> |
|
143 | 154 | <!-- 联系人列表 --> |
144 | 155 | <div class="flex-1 overflow-y-auto min-h-0"> |
145 | 156 | <div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden"> |
146 | | - <div v-for="i in 15" :key="i" class="flex items-center space-x-3 py-2"> |
147 | | - <div class="w-10 h-10 rounded-md bg-gray-200 skeleton-pulse"></div> |
| 157 | + <div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(85px/var(--dpr))]"> |
| 158 | + <div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div> |
148 | 159 | <div class="flex-1 space-y-2"> |
149 | 160 | <div class="h-3.5 bg-gray-200 rounded skeleton-pulse" :style="{ width: (60 + (i % 4) * 15) + 'px' }"></div> |
150 | 161 | <div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (80 + (i % 3) * 20) + 'px' }"></div> |
|
159 | 170 | </div> |
160 | 171 | <template v-else> |
161 | 172 | <div v-for="contact in filteredContacts" :key="contact.id" |
162 | | - class="px-3 py-2 cursor-pointer transition-colors duration-150 border-b border-gray-100" |
| 173 | + class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(85px/var(--dpr))] flex items-center" |
163 | 174 | :class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'" |
164 | 175 | @click="selectContact(contact)"> |
165 | | - <div class="flex items-center space-x-3"> |
| 176 | + <div class="flex items-center space-x-3 w-full"> |
166 | 177 | <!-- 联系人头像 --> |
167 | | - <div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }"> |
| 178 | + <div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }"> |
168 | 179 | <div v-if="contact.avatar" class="w-full h-full"> |
169 | 180 | <img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover"> |
170 | 181 | </div> |
|
340 | 351 | <div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'"> |
341 | 352 | <div class="flex items-start max-w-md" :class="message.isSent ? 'flex-row-reverse' : ''"> |
342 | 353 | <!-- 消息发送者头像 --> |
343 | | - <div class="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]"> |
| 354 | + <div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]"> |
344 | 355 | <div v-if="message.avatar" class="w-full h-full"> |
345 | 356 | <img |
346 | 357 | :src="message.avatar" |
@@ -1802,6 +1813,129 @@ watch( |
1802 | 1813 | } |
1803 | 1814 | ) |
1804 | 1815 |
|
| 1816 | +// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px,支持拖动调整并持久化 |
| 1817 | +const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical' |
| 1818 | +const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width' |
| 1819 | +const SESSION_LIST_WIDTH_DEFAULT = 295 |
| 1820 | +const SESSION_LIST_WIDTH_MIN = 220 |
| 1821 | +const SESSION_LIST_WIDTH_MAX = 520 |
| 1822 | + |
| 1823 | +const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT) |
| 1824 | +const sessionListResizing = ref(false) |
| 1825 | + |
| 1826 | +let sessionListResizeStartX = 0 |
| 1827 | +let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT |
| 1828 | +let sessionListResizeStartDpr = 1 |
| 1829 | +let sessionListResizePrevCursor = '' |
| 1830 | +let sessionListResizePrevUserSelect = '' |
| 1831 | + |
| 1832 | +const clampSessionListWidth = (n) => { |
| 1833 | + const v = Number.isFinite(n) ? n : SESSION_LIST_WIDTH_DEFAULT |
| 1834 | + return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(v))) |
| 1835 | +} |
| 1836 | + |
| 1837 | +const loadSessionListWidth = () => { |
| 1838 | + if (!process.client) return |
| 1839 | + try { |
| 1840 | + const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY) |
| 1841 | + const v = parseInt(String(raw || ''), 10) |
| 1842 | + if (!Number.isNaN(v)) { |
| 1843 | + sessionListWidth.value = clampSessionListWidth(v) |
| 1844 | + return |
| 1845 | + } |
| 1846 | + |
| 1847 | + // Legacy: value was stored as CSS px. Convert to physical px using current dpr. |
| 1848 | + const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY) |
| 1849 | + const legacyV = parseInt(String(legacy || ''), 10) |
| 1850 | + if (!Number.isNaN(legacyV)) { |
| 1851 | + const dpr = window.devicePixelRatio || 1 |
| 1852 | + const converted = clampSessionListWidth(legacyV * dpr) |
| 1853 | + sessionListWidth.value = converted |
| 1854 | + try { |
| 1855 | + localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted)) |
| 1856 | + localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY) |
| 1857 | + } catch {} |
| 1858 | + } |
| 1859 | + } catch {} |
| 1860 | +} |
| 1861 | + |
| 1862 | +const saveSessionListWidth = () => { |
| 1863 | + if (!process.client) return |
| 1864 | + try { |
| 1865 | + localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value))) |
| 1866 | + } catch {} |
| 1867 | +} |
| 1868 | + |
| 1869 | +const setSessionListResizingActive = (active) => { |
| 1870 | + if (!process.client) return |
| 1871 | + try { |
| 1872 | + const body = document.body |
| 1873 | + if (!body) return |
| 1874 | + if (active) { |
| 1875 | + sessionListResizePrevCursor = body.style.cursor || '' |
| 1876 | + sessionListResizePrevUserSelect = body.style.userSelect || '' |
| 1877 | + body.style.cursor = 'col-resize' |
| 1878 | + body.style.userSelect = 'none' |
| 1879 | + } else { |
| 1880 | + body.style.cursor = sessionListResizePrevCursor |
| 1881 | + body.style.userSelect = sessionListResizePrevUserSelect |
| 1882 | + sessionListResizePrevCursor = '' |
| 1883 | + sessionListResizePrevUserSelect = '' |
| 1884 | + } |
| 1885 | + } catch {} |
| 1886 | +} |
| 1887 | + |
| 1888 | +const onSessionListResizerPointerMove = (ev) => { |
| 1889 | + if (!sessionListResizing.value) return |
| 1890 | + const clientX = Number(ev?.clientX || 0) |
| 1891 | + // `clientX` delta is in CSS px. We store width as physical px, so multiply by dpr. |
| 1892 | + sessionListWidth.value = clampSessionListWidth( |
| 1893 | + sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1) |
| 1894 | + ) |
| 1895 | +} |
| 1896 | + |
| 1897 | +const stopSessionListResize = () => { |
| 1898 | + if (!process.client) return |
| 1899 | + if (!sessionListResizing.value) return |
| 1900 | + sessionListResizing.value = false |
| 1901 | + setSessionListResizingActive(false) |
| 1902 | + try { |
| 1903 | + window.removeEventListener('pointermove', onSessionListResizerPointerMove) |
| 1904 | + } catch {} |
| 1905 | + saveSessionListWidth() |
| 1906 | +} |
| 1907 | + |
| 1908 | +const onSessionListResizerPointerUp = () => { |
| 1909 | + stopSessionListResize() |
| 1910 | +} |
| 1911 | + |
| 1912 | +const onSessionListResizerPointerDown = (ev) => { |
| 1913 | + if (!process.client) return |
| 1914 | + try { |
| 1915 | + ev?.preventDefault?.() |
| 1916 | + } catch {} |
| 1917 | + |
| 1918 | + sessionListResizing.value = true |
| 1919 | + sessionListResizeStartX = Number(ev?.clientX || 0) |
| 1920 | + sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT) |
| 1921 | + sessionListResizeStartDpr = window.devicePixelRatio || 1 |
| 1922 | + setSessionListResizingActive(true) |
| 1923 | + |
| 1924 | + try { |
| 1925 | + window.addEventListener('pointermove', onSessionListResizerPointerMove) |
| 1926 | + window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true }) |
| 1927 | + } catch {} |
| 1928 | +} |
| 1929 | + |
| 1930 | +const resetSessionListWidth = () => { |
| 1931 | + sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT |
| 1932 | + saveSessionListWidth() |
| 1933 | +} |
| 1934 | + |
| 1935 | +onMounted(() => { |
| 1936 | + loadSessionListWidth() |
| 1937 | +}) |
| 1938 | + |
1805 | 1939 | // 桌面端设置(仅 Electron 环境可见) |
1806 | 1940 | const isDesktopEnv = ref(false) |
1807 | 1941 | const desktopSettingsOpen = ref(false) |
@@ -4677,6 +4811,7 @@ onUnmounted(() => { |
4677 | 4811 | if (!process.client) return |
4678 | 4812 | document.removeEventListener('click', onGlobalClick) |
4679 | 4813 | document.removeEventListener('keydown', onGlobalKeyDown) |
| 4814 | + stopSessionListResize() |
4680 | 4815 | if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer) |
4681 | 4816 | messageSearchDebounceTimer = null |
4682 | 4817 | if (highlightMessageTimer) clearTimeout(highlightMessageTimer) |
@@ -4976,8 +5111,22 @@ const toggleRealtime = async (opts = {}) => { |
4976 | 5111 | return true |
4977 | 5112 | } |
4978 | 5113 |
|
| 5114 | + // Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first, |
| 5115 | + // otherwise the UI will fall back to an outdated decrypted snapshot. |
4979 | 5116 | realtimeEnabled.value = false |
4980 | 5117 | stopRealtimeStream() |
| 5118 | + try { |
| 5119 | + const api = useApi() |
| 5120 | + const u = String(selectedContact.value?.username || '').trim() |
| 5121 | + if (u) { |
| 5122 | + // Use a larger scan window on shutdown to reduce the chance of missing a backlog. |
| 5123 | + await api.syncChatRealtimeMessages({ |
| 5124 | + account: selectedAccount.value, |
| 5125 | + username: u, |
| 5126 | + max_scan: 5000 |
| 5127 | + }) |
| 5128 | + } |
| 5129 | + } catch {} |
4981 | 5130 | await refreshSessionsForSelectedAccount({ sourceOverride: '' }) |
4982 | 5131 | if (selectedContact.value?.username) { |
4983 | 5132 | await refreshSelectedMessages() |
@@ -5246,6 +5395,38 @@ const LinkCard = defineComponent({ |
5246 | 5395 | background: #a1a1a1; |
5247 | 5396 | } |
5248 | 5397 |
|
| 5398 | +/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */ |
| 5399 | +.session-list-panel { |
| 5400 | + width: calc(var(--session-list-width, 295px) / var(--dpr)); |
| 5401 | +} |
| 5402 | + |
| 5403 | +/* 会话列表拖动条(中间栏右侧) */ |
| 5404 | +.session-list-resizer { |
| 5405 | + position: absolute; |
| 5406 | + top: 0; |
| 5407 | + right: -3px; /* 覆盖在 border 上,便于拖动 */ |
| 5408 | + width: 6px; |
| 5409 | + height: 100%; |
| 5410 | + cursor: col-resize; |
| 5411 | + z-index: 50; |
| 5412 | +} |
| 5413 | + |
| 5414 | +.session-list-resizer::after { |
| 5415 | + content: ''; |
| 5416 | + position: absolute; |
| 5417 | + top: 0; |
| 5418 | + bottom: 0; |
| 5419 | + left: 2px; |
| 5420 | + width: 2px; |
| 5421 | + background: transparent; |
| 5422 | + transition: background-color 0.15s ease; |
| 5423 | +} |
| 5424 | + |
| 5425 | +.session-list-resizer:hover::after, |
| 5426 | +.session-list-resizer-active::after { |
| 5427 | + background: rgba(0, 0, 0, 0.12); |
| 5428 | +} |
| 5429 | + |
5249 | 5430 | /* 消息气泡样式 */ |
5250 | 5431 | .message-bubble { |
5251 | 5432 | border-radius: var(--message-radius); |
|
0 commit comments