diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 05e6340..1edfcd8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6,6 +6,9 @@ import { AppShell } from './components/layout/AppShell' import { AppPage } from './components/layout/navigation' import { Contact, Message } from '../../shared/types' +const SIDEBAR_MIN_WIDTH = 260 +const SIDEBAR_MAX_WIDTH = 380 + function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement { return (
selectedContact && handleSelectContact(selectedContact)} onRefreshData={loadContacts} /> diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 4744b60..66f5bf0 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -165,7 +165,6 @@ body { border-radius: var(--wxex-radius-md); background: transparent; color: var(--wxex-text-primary); - cursor: pointer; font-family: var(--wxex-font); text-align: left; } @@ -176,6 +175,7 @@ body { .account-summary.compact { justify-content: center; + cursor: pointer; padding: 8px 0; } @@ -216,6 +216,10 @@ body { background: var(--wxex-success); } +.account-summary:not(.compact) .account-summary-avatar .account-summary-status { + display: none; +} + .account-summary-text { min-width: 0; display: flex; @@ -241,6 +245,62 @@ body { white-space: nowrap; } +.account-summary-state { + overflow: hidden; + display: flex; + align-items: center; + gap: 5px; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-summary-state-dot { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--wxex-text-muted); +} + +.account-summary-state-dot.ready { + background: var(--wxex-success); +} + +.account-summary-settings { + box-sizing: border-box; + width: 28px; + height: 28px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 0; + border-radius: var(--wxex-radius-sm); + background: transparent; + color: var(--wxex-text-muted); + cursor: pointer; +} + +.account-summary-settings:hover { + background: var(--wxex-brand-soft); + color: var(--wxex-brand); +} + +.account-summary-settings svg { + width: 17px; + height: 17px; +} + +.account-summary-settings path { + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + .app-shell-main { box-sizing: border-box; flex: 1; @@ -298,6 +358,317 @@ body { overflow: hidden; } +.conversation-sidebar { + box-sizing: border-box; + display: flex; + height: 100%; + min-height: 0; + flex: 0 0 auto; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--wxex-border); + background: var(--wxex-bg-sidebar); + color: var(--wxex-text-primary); +} + +.conversation-sidebar-header { + box-sizing: border-box; + flex: 0 0 auto; + padding: 16px 14px 12px; + border-bottom: 1px solid var(--wxex-border); + -webkit-app-region: drag; +} + +.conversation-sidebar-title-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.conversation-sidebar-title-row h2 { + margin: 0; + color: var(--wxex-text-primary); + font-size: 18px; + font-weight: 700; + line-height: 24px; +} + +.conversation-sidebar-title-row span { + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} + +.conversation-search { + box-sizing: border-box; + display: flex; + height: 34px; + align-items: center; + gap: 8px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + padding: 0 10px; + color: var(--wxex-text-muted); + -webkit-app-region: no-drag; +} + +.conversation-search:focus-within { + border-color: rgba(36, 122, 99, 0.58); + color: var(--wxex-brand); +} + +.conversation-search-icon { + width: 17px; + height: 17px; + display: grid; + place-items: center; + flex: 0 0 auto; +} + +.conversation-search-icon svg { + width: 17px; + height: 17px; +} + +.conversation-search-icon circle, +.conversation-search-icon path { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.conversation-search input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--wxex-text-primary); + font: 13px/18px var(--wxex-font); +} + +.conversation-search input::placeholder { + color: var(--wxex-text-muted); +} + +.conversation-date-range { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + margin-top: 10px; + -webkit-app-region: no-drag; +} + +.conversation-date-range-button { + box-sizing: border-box; + height: 30px; + min-width: 0; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + color: var(--wxex-text-secondary); + cursor: pointer; + font: 12px/16px var(--wxex-font); + white-space: nowrap; +} + +.conversation-date-range-button:hover { + border-color: rgba(36, 122, 99, 0.28); + color: var(--wxex-text-primary); +} + +.conversation-date-range-button.active { + border-color: var(--wxex-brand); + background: var(--wxex-brand); + color: #ffffff; + font-weight: 600; +} + +.conversation-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px 8px 12px; +} + +.conversation-section + .conversation-section { + margin-top: 6px; +} + +.conversation-section-header { + box-sizing: border-box; + width: 100%; + height: 32px; + display: flex; + align-items: center; + gap: 4px; + border: 0; + border-radius: var(--wxex-radius-sm); + background: transparent; + color: var(--wxex-text-secondary); + cursor: pointer; + font: 600 12px/16px var(--wxex-font); + padding: 0 8px; + text-align: left; +} + +.conversation-section-header:hover { + background: rgba(255, 255, 255, 0.48); + color: var(--wxex-text-primary); +} + +.conversation-section-chevron { + width: 16px; + height: 16px; + display: grid; + place-items: center; + flex: 0 0 auto; +} + +.conversation-section-chevron svg { + width: 14px; + height: 14px; +} + +.conversation-section-chevron path { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.conversation-section-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-section-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.conversation-section-empty { + padding: 10px 12px 12px 28px; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; +} + +.conversation-item { + box-sizing: border-box; + position: relative; + width: 100%; + min-height: 58px; + display: flex; + align-items: center; + gap: 10px; + border: 0; + border-radius: var(--wxex-radius-md); + background: transparent; + color: var(--wxex-text-primary); + cursor: pointer; + font-family: var(--wxex-font); + padding: 8px 10px 8px 13px; + text-align: left; +} + +.conversation-item:hover { + background: rgba(255, 255, 255, 0.56); +} + +.conversation-item.active { + background: var(--wxex-brand-soft); +} + +.conversation-item-active-mark { + position: absolute; + top: 10px; + bottom: 10px; + left: 0; + width: 3px; + border-radius: 0 999px 999px 0; + background: transparent; +} + +.conversation-item.active .conversation-item-active-mark { + background: var(--wxex-brand); +} + +.conversation-item-avatar { + box-sizing: border-box; + width: 40px; + height: 40px; + display: grid; + place-items: center; + flex: 0 0 auto; + overflow: hidden; + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + color: var(--wxex-brand); + font-size: 14px; + font-weight: 700; +} + +.conversation-item-avatar img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.conversation-item-body { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; +} + +.conversation-item-name { + overflow: hidden; + color: var(--wxex-text-primary); + font-size: 13px; + font-weight: 600; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-item-meta { + overflow: hidden; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.conversation-sidebar-account { + box-sizing: border-box; + flex: 0 0 auto; + border-top: 1px solid var(--wxex-border); + padding: 10px; +} + +.conversation-sidebar-account .account-summary { + min-height: 58px; + padding: 8px; +} + +.conversation-sidebar-account .account-summary-text { + flex: 1; +} + .sidebar { background-color: var(--sidebar-bg); border-right: 1px solid var(--border-color); @@ -459,42 +830,316 @@ body { } .resizer { - width: 5px; + width: 4px; cursor: col-resize; background-color: transparent; border-right: 1px solid transparent; - transition: background-color 0.2s; + transition: + background-color 0.2s, + border-color 0.2s; z-index: 10; } .resizer:hover, .resizer:active { - background-color: var(--border-color); + border-color: rgba(36, 122, 99, 0.22); + background-color: rgba(36, 122, 99, 0.06); } .chat-window { flex: 1; display: flex; flex-direction: column; - background-color: var(--chat-bg); + background-color: var(--wxex-bg-main); height: 100%; min-height: 0; min-width: 0; - /* Allow flex item to shrink below content size */ + color: var(--wxex-text-primary); + overflow: hidden; } -.chat-header { - height: 50px; - flex: 0 0 50px; - border-bottom: 1px solid var(--border-color); +.chat-window.empty { + align-items: center; + justify-content: center; +} + +.chat-archive-header { + box-sizing: border-box; + min-height: 70px; + flex: 0 0 auto; display: flex; align-items: center; - padding: 0 20px; - font-weight: bold; - font-size: 16px; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--wxex-border); + background: var(--wxex-bg-main); + padding: 12px 18px; -webkit-app-region: drag; } +.chat-title-block { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.chat-title-avatar { + width: 42px; + height: 42px; + display: grid; + place-items: center; + flex: 0 0 auto; + overflow: hidden; + border-radius: var(--wxex-radius-md); + background: var(--wxex-brand-soft); + color: var(--wxex-brand); + font-size: 15px; + font-weight: 700; +} + +.chat-title-avatar img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.chat-title-text { + min-width: 0; +} + +.chat-title-text h2 { + overflow: hidden; + margin: 0; + color: var(--wxex-text-primary); + font-size: 19px; + font-weight: 700; + line-height: 26px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-title-meta { + display: flex; + align-items: center; + gap: 8px; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 18px; +} + +.chat-title-meta span + span::before { + content: ''; + display: inline-block; + width: 3px; + height: 3px; + margin-right: 8px; + border-radius: 999px; + background: var(--wxex-text-muted); + vertical-align: middle; +} + +.chat-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex: 0 0 auto; + -webkit-app-region: no-drag; +} + +.chat-icon-button, +.chat-tool-button, +.chat-ai-button { + box-sizing: border-box; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid transparent; + border-radius: var(--wxex-radius-md); + background: transparent; + color: var(--wxex-text-secondary); + cursor: pointer; + font: 13px/18px var(--wxex-font); +} + +.chat-icon-button { + width: 32px; + padding: 0; +} + +.chat-tool-button { + padding: 0 9px; + border-color: var(--wxex-border); + background: var(--wxex-bg-elevated); +} + +.chat-icon-button:hover, +.chat-tool-button:hover { + background: var(--wxex-brand-soft); + color: var(--wxex-brand); +} + +.chat-tool-button:disabled, +.chat-ai-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.chat-ai-button { + padding: 0 12px; + border-color: var(--wxex-ai); + background: var(--wxex-ai); + color: #ffffff; + font-weight: 600; +} + +.chat-ai-button:hover:not(:disabled) { + background: #5a60c6; +} + +.chat-icon-button svg, +.chat-tool-button svg, +.chat-ai-button svg, +.chat-content-search svg, +.data-trust-bar svg { + width: 17px; + height: 17px; +} + +.chat-icon-button path, +.chat-tool-button path, +.chat-ai-button path, +.chat-content-search path, +.data-trust-bar path, +.chat-icon-button circle, +.chat-tool-button circle, +.chat-ai-button circle, +.chat-content-search circle, +.data-trust-bar circle { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-menu { + position: relative; +} + +.chat-dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 40; + min-width: 150px; + overflow: hidden; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + box-shadow: var(--wxex-shadow-popover); + padding: 4px; +} + +.chat-dropdown-menu.right { + right: 0; + left: auto; +} + +.chat-dropdown-menu button { + box-sizing: border-box; + width: 100%; + display: block; + border: 0; + border-radius: var(--wxex-radius-sm); + background: transparent; + color: var(--wxex-text-primary); + cursor: pointer; + font: 13px/18px var(--wxex-font); + padding: 7px 9px; + text-align: left; + white-space: nowrap; +} + +.chat-dropdown-menu button:hover { + background: var(--wxex-brand-soft); + color: var(--wxex-brand); +} + +.chat-content-search { + box-sizing: border-box; + width: 280px; + height: 34px; + display: flex; + align-items: center; + gap: 7px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + padding: 0 5px 0 10px; + color: var(--wxex-text-muted); +} + +.chat-content-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--wxex-text-primary); + font: 13px/18px var(--wxex-font); +} + +.chat-content-search-count { + color: var(--wxex-text-muted); + font-size: 12px; + white-space: nowrap; +} + +.data-trust-bar { + box-sizing: border-box; + min-height: 34px; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--wxex-border); + background: #f3f6f4; + color: var(--wxex-text-secondary); + padding: 0 18px; + font-size: 12px; + line-height: 18px; +} + +.data-trust-left, +.data-trust-right, +.data-trust-item { + display: flex; + align-items: center; + gap: 6px; +} + +.data-trust-right { + flex-wrap: wrap; + justify-content: flex-end; +} + +.data-trust-item { + color: var(--wxex-success); +} + +.data-trust-dot { + width: 3px; + height: 3px; + border-radius: 999px; + background: var(--wxex-text-muted); +} + .message-list { flex: 1; overflow-y: auto; @@ -504,12 +1149,9 @@ body { } .wechat-message-list { - padding: 18px 28px; - background-color: #edf1f2; - background-image: - radial-gradient(circle at 20px 20px, rgba(0, 0, 0, 0.025) 1px, transparent 1px), - radial-gradient(circle at 80px 70px, rgba(0, 0, 0, 0.02) 1px, transparent 1px); - background-size: 120px 120px; + min-height: 0; + padding: 18px 28px 24px; + background: #f0f2ef; } .wechat-message-row { @@ -520,12 +1162,11 @@ body { align-self: center; margin: 0 0 12px; padding: 5px 12px; - border-radius: 999px; + border-radius: var(--wxex-radius-md); background: rgba(255, 255, 255, 0.78); - color: #7a858b; + color: var(--wxex-text-secondary); font-size: 12px; line-height: 1.4; - box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08); } .wechat-message-row { @@ -550,9 +1191,9 @@ body { .wechat-system-message { max-width: min(78%, 760px); padding: 6px 12px; - border-radius: 999px; - background: rgba(227, 86, 86, 0.1); - color: #c84a4a; + border-radius: var(--wxex-radius-md); + background: rgba(255, 255, 255, 0.72); + color: var(--wxex-text-muted); font-size: 12px; line-height: 1.5; text-align: center; @@ -567,9 +1208,9 @@ body { .message-avatar { width: 38px; height: 38px; - border-radius: 6px; - background: #d6d6d6; - color: #fff; + border-radius: var(--wxex-radius-md); + background: var(--wxex-bg-elevated); + color: var(--wxex-brand); display: flex; align-items: center; justify-content: center; @@ -579,11 +1220,16 @@ body { } .mine-avatar { - background: #607d86; + background: var(--wxex-brand-soft); +} + +.message-avatar-spacer { + width: 38px; + flex: 0 0 38px; } .message-stack { - max-width: min(68%, 720px); + max-width: min(70%, 760px); display: flex; flex-direction: column; align-items: flex-start; @@ -594,54 +1240,96 @@ body { } .message-sender-name { - color: #6f777a; + color: var(--wxex-text-secondary); font-size: 12px; margin: 0 0 4px 2px; } +.message-bubble-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: 4px; +} + +.wechat-message-row.mine .message-bubble-wrap { + align-items: flex-end; +} + +.message-bubble-wrap.is-followup { + margin-top: 2px; +} + .message-bubble { position: relative; padding: 9px 13px; - border-radius: 6px; - background: #fff; - color: #222; - font-size: 15px; + border: 1px solid rgba(221, 227, 224, 0.74); + border-radius: var(--wxex-radius-md); + background: #ffffff; + color: var(--wxex-text-primary); + font-size: 14px; line-height: 1.55; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03); word-break: break-word; } .wechat-message-row.mine .message-bubble { - background: #516d76; - color: #fff; + border-color: rgba(36, 122, 99, 0.16); + background: #e7f2ee; + color: var(--wxex-text-primary); } .message-bubble::before { - content: ''; - position: absolute; - top: 12px; - width: 0; - height: 0; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; -} - -.wechat-message-row.other .message-bubble::before { - left: -6px; - border-right: 6px solid #fff; -} - -.wechat-message-row.mine .message-bubble::before { - right: -6px; - border-left: 6px solid #516d76; + display: none; } .message-meta { - display: flex; - gap: 8px; - margin-top: 4px; - color: #9aa1a4; - font-size: 11px; + display: none; +} + +.message-hover-time, +.message-accessible-sender { + color: var(--wxex-text-muted); + font-size: 12px; +} + +.message-hover-time { + position: absolute; + top: 50%; + left: calc(100% + 8px); + z-index: 1; + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateY(-50%); + transition: opacity 0.12s ease; + white-space: nowrap; +} + +.message-bubble-wrap:hover .message-hover-time { + visibility: visible; + opacity: 1; +} + +.wechat-message-row.mine .message-hover-time { + right: calc(100% + 8px); + left: auto; +} + +.message-accessible-sender { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +.chat-time-separator { + align-self: center; + margin: 8px 0 12px; + color: var(--wxex-text-muted); + font-size: 12px; + line-height: 18px; } .message-text { @@ -681,11 +1369,11 @@ body { max-height: 220px; min-width: 96px; min-height: 72px; - border-radius: 7px; + border-radius: var(--wxex-radius-md); overflow: hidden; background: #fff; cursor: zoom-in; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + border: 1px solid var(--wxex-border); } .image-content { @@ -707,13 +1395,17 @@ body { justify-content: center; gap: 6px; padding: 14px 18px; - color: #768184; + color: var(--wxex-text-secondary); font-size: 13px; } -.image-placeholder-icon { - font-size: 22px; - line-height: 1; +.image-loading-skeleton { + width: 110px; + height: 72px; + border-radius: var(--wxex-radius-md); + background: linear-gradient(90deg, #edf1ef 0%, #f7f8f7 50%, #edf1ef 100%); + background-size: 200% 100%; + animation: image-skeleton-shimmer 1.2s linear infinite; } .image-actions { @@ -730,8 +1422,8 @@ body { .image-quality-badge { position: absolute; - left: 6px; - top: 6px; + right: 6px; + bottom: 6px; max-width: calc(100% - 12px); padding: 3px 6px; border-radius: 5px; @@ -742,6 +1434,15 @@ body { pointer-events: none; } +@keyframes image-skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + .image-action-btn { border: 0; border-radius: 5px; @@ -765,13 +1466,22 @@ body { display: inline-flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.08); + background: rgba(36, 122, 99, 0.12); + color: var(--wxex-brand); font-size: 10px; line-height: 1; } .wechat-message-row.mine .voice-icon { - background: rgba(255, 255, 255, 0.18); + background: rgba(36, 122, 99, 0.14); +} + +.voice-error { + color: var(--wxex-text-secondary); +} + +.voice-error-text { + color: var(--wxex-text-secondary); } .voice-bars { @@ -969,6 +1679,90 @@ body { color: #999; } +.chat-status-bar { + box-sizing: border-box; + min-height: 38px; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--wxex-border); + background: var(--wxex-bg-main); + color: var(--wxex-text-secondary); + padding: 0 18px; + font-size: 12px; + line-height: 18px; +} + +.chat-status-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.chat-avatar-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + white-space: nowrap; +} + +.chat-avatar-toggle input { + margin: 0; + accent-color: var(--wxex-brand); +} + +.chat-status-separator { + width: 1px; + height: 14px; + background: var(--wxex-border); +} + +.chat-status-actions button { + border: 0; + background: transparent; + color: var(--wxex-brand); + cursor: pointer; + font: 12px/18px var(--wxex-font); + padding: 0; +} + +.chat-status-actions button:hover { + color: var(--wxex-brand-hover); +} + +.chat-status-actions button:disabled { + color: var(--wxex-text-muted); + cursor: default; +} + +.empty-conversation-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--wxex-text-secondary); + text-align: center; +} + +.empty-conversation-state h2 { + margin: 0; + color: var(--wxex-text-primary); + font-size: 20px; + line-height: 28px; +} + +.empty-conversation-state p { + margin: 0; + color: var(--wxex-text-muted); + font-size: 13px; + line-height: 20px; +} + .login-modal { position: fixed; top: 0; @@ -1361,16 +2155,16 @@ body { } .voice-error { - opacity: 0.8; + opacity: 1; } .voice-loading-text { font-size: 12px; - color: #999; + color: var(--wxex-text-muted); } .voice-error-text { - color: #999; + color: var(--wxex-text-secondary); } .voice-duration { @@ -1380,7 +2174,7 @@ body { } .voice-icon.playing { - background: rgba(0, 0, 0, 0.15); + background: rgba(36, 122, 99, 0.18); } /* Rich Message Bubbles */ @@ -1394,7 +2188,14 @@ body { } .location-icon { - font-size: 28px; + box-sizing: border-box; + min-width: 34px; + padding: 4px 6px; + border-radius: var(--wxex-radius-sm); + background: var(--wxex-brand-soft); + color: var(--wxex-brand); + font-size: 12px; + font-weight: 600; flex-shrink: 0; } @@ -1412,7 +2213,7 @@ body { .location-label { font-size: 12px; - color: #666; + color: var(--wxex-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1420,7 +2221,7 @@ body { .location-coords { font-size: 11px; - color: #999; + color: var(--wxex-text-muted); margin-top: 2px; } @@ -1521,7 +2322,13 @@ body { } .voip-icon { - font-size: 16px; + box-sizing: border-box; + padding: 3px 6px; + border-radius: var(--wxex-radius-sm); + background: var(--wxex-brand-soft); + color: var(--wxex-brand); + font-size: 12px; + font-weight: 600; } .voip-status { diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index afd14bd..4a2cf25 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -1,20 +1,24 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { Message, Contact } from '../../../shared/types' -import { VoicePlayer } from './VoicePlayer' -import { RichMessageBubble } from './RichMessageBubble' -import { ImageBubble } from './ImageBubble' -import { renderWechatEmojiText } from '../utils/wechatEmojiText' import { buildGroupReportInput, GROUP_REPORT_SYSTEM_PROMPT, parseGroupDailyReport } from '../utils/group-report' +import { ChatHeader } from './chat/ChatHeader' +import { ChatStatusBar } from './chat/ChatStatusBar' +import { DataTrustBar } from './chat/DataTrustBar' +import { EmptyConversationState } from './chat/EmptyConversationState' +import { ExportRange } from './chat/ExportMenu' +import { MessageList } from './chat/MessageList' interface ChatWindowProps { contact: Contact | null messages: Message[] isLoadingMessages?: boolean contentFilter?: string + dateRange?: string + onContentFilterChange?: (keyword: string) => void onRefresh?: () => void onRefreshData?: () => void } @@ -29,6 +33,43 @@ const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [ ] const MAX_RENDERED_MESSAGES = 600 const REPORT_STEP_TIMEOUT_MS = 90_000 +const DATE_RANGE_LABELS: Record = { + today: '今天', + yesterday: '昨日', + '7': '7 天', + '30': '30 天', + all: '全部' +} + +const formatClock = (date: Date): string => + `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + +const formatRangeDate = (date: Date, now: Date): string => { + const clock = formatClock(date) + if (date.getFullYear() === now.getFullYear()) { + return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}` + } + return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}` +} + +const getChatHeaderRangeLabel = (range: string): string => { + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const endOfYesterday = new Date(startOfToday.getTime() - 60_000) + + if (range === 'today') return `今天 00:00—现在` + if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}` + if (range === '7') { + const start = new Date(Date.now() - 7 * 86400000) + return `${formatRangeDate(start, now)}—现在` + } + if (range === '30') { + const start = new Date(Date.now() - 30 * 86400000) + return `${formatRangeDate(start, now)}—现在` + } + if (range === 'all') return '全部记录' + return DATE_RANGE_LABELS[range] || '当前范围' +} const withTimeout = async (promise: Promise, label: string): Promise => { let timer: number | undefined @@ -44,7 +85,9 @@ const withTimeout = async (promise: Promise, label: string): Promise = const isInternalName = (value?: string): boolean => { const text = String(value || '').trim() - return !text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text) + return ( + !text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text) + ) } const SUMMARY_TYPE_OPTIONS: { @@ -79,12 +122,15 @@ const ChatWindow: React.FC = ({ messages, isLoadingMessages, contentFilter, + dateRange = 'today', + onContentFilterChange, onRefresh, onRefreshData }) => { const isGroupChat = Boolean( contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom') ) + const messageListRef = useRef(null) const messagesEndRef = useRef(null) const [generatedImage, setGeneratedImage] = useState(null) const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null) @@ -107,6 +153,7 @@ const ChatWindow: React.FC = ({ const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat') const [summaryDateRange, setSummaryDateRange] = useState('today') const [summaryMessageTypes, setSummaryMessageTypes] = useState(['text']) + const [isAtLatest, setIsAtLatest] = useState(true) const handleSaveSettings = (): void => { if (!summaryMessageTypes.length) { @@ -126,13 +173,20 @@ const ChatWindow: React.FC = ({ ) } - const scrollToBottom = (): void => { + const scrollToBottom = useCallback((): void => { messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }) - } + setIsAtLatest(true) + }, []) + + const handleMessageListScroll = useCallback((event: React.UIEvent): void => { + const target = event.currentTarget + const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight + setIsAtLatest(distanceToBottom <= 24) + }, []) useEffect(() => { scrollToBottom() - }, [messages]) + }, [messages, scrollToBottom]) const openImagePreview = (imageUrl: string): void => { setPreviewImage(imageUrl) @@ -203,7 +257,7 @@ const ChatWindow: React.FC = ({ } }, [previewImage]) - const handleExport = (days: number | 'all'): void => { + const handleExport = (days: ExportRange): void => { if (!messages.length) return let filtered = messages @@ -360,150 +414,52 @@ const ChatWindow: React.FC = ({ [filteredMessages] ) - if (!contact) { - return ( -
-
选择一条消息
-
- ) - } + if (!contact) return + + const dateRangeLabel = getChatHeaderRangeLabel(dateRange) return (
-
-

{contact.m_nsNickName}

-
-
- -
- {isLoadingMessages && ( -
正在加载聊天记录...
- )} - {hiddenMessageCount > 0 && ( -
-
- 已隐藏较早的 {hiddenMessageCount} 条消息,当前显示最新 {MAX_RENDERED_MESSAGES} 条 -
-
- )} - {renderedMessages.map((msg) => { - const isMine = msg.from === 'assistant' - const isSystem = msg.from === 'system' || msg.type === '系统消息' - const displayName = isMine - ? '我' - : isGroupChat - ? msg.name || msg.from - : contact.m_nsNickName - const avatarSrc = isMine ? msg.img : msg.img || contact.avatar - const isVoice = msg.type === '语音' - const isImage = msg.type === '图片' - const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes( - msg.type - ) - - if (isSystem) { - return ( -
-
{msg.content}
-
{msg.datetime}
-
- ) - } - - return ( -
- {!isMine && showAvatar && ( -
- {avatarSrc ? ( - {displayName} - ) : ( - (displayName || '?').charAt(0) - )} -
- )} -
- {!isMine && isGroupChat &&
{displayName}
} -
- {isVoice && msg.sessionId ? ( - - ) : isImage && msg.contentData && msg.contentData.type === 'image' ? ( - - ) : isRichMedia && msg.contentData ? ( - - ) : ( -
{renderWechatEmojiText(msg.content)}
- )} -
-
- {msg.datetime} - {msg.type} -
-
- {isMine && showAvatar && ( -
- {avatarSrc ? 我 : '我'} -
- )} -
- ) - })} -
-
- -
- - - - - - - - - -
+ 0} + onContentFilterChange={onContentFilterChange || (() => undefined)} + onRefresh={onRefresh} + onRefreshData={onRefreshData} + onExport={handleExport} + onOpenAiSettings={() => setShowSettingsModal(true)} + /> + + + {/* 加载模态框 */} {isLoading && (
-
🤖
正在生成群聊日报...
正在分析记录、处理头像并生成 HTML 和长图 @@ -537,7 +493,7 @@ const ChatWindow: React.FC = ({ borderRadius: '4px' }} > - 📋 复制图片 + 复制图片 {reportPaths && ( - ))} -
-
-
- {/* 群聊部分 */} -
setIsGroupsExpanded(!isGroupsExpanded)}> - {isGroupsExpanded ? '▼' : '▶'} 群聊 ({groups.length}) -
- {isGroupsExpanded && groups.map(renderContactItem)} - {isGroupsExpanded && groups.length === 0 &&
暂无群聊
} - - {/* 联系人部分 */} -
setIsContactsExpanded(!isContactsExpanded)}> - {isContactsExpanded ? '▼' : '▶'} 联系人 ({users.length}) -
- {isContactsExpanded && users.map(renderContactItem)} - {isContactsExpanded && users.length === 0 && ( -
暂无联系人
- )} -
-
-
- {selfInfo?.avatar ? ( - {selfInfo.nickname} - ) : ( - ((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0)) - )} -
-
-
- {dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '我' : '未连接'} -
-
- {dbReady && selfInfo ? selfInfo.wxid : '点击设置 →'} -
-
-
-
-
- ) -} +export const Sidebar: React.FC = (props) => ( + +) diff --git a/src/renderer/src/components/VoicePlayer.tsx b/src/renderer/src/components/VoicePlayer.tsx index 3b50639..d87778e 100644 --- a/src/renderer/src/components/VoicePlayer.tsx +++ b/src/renderer/src/components/VoicePlayer.tsx @@ -176,7 +176,7 @@ export function VoicePlayer({ return (
- [语音] + 当前版本暂不支持播放
) } diff --git a/src/renderer/src/components/account/AccountSummary.tsx b/src/renderer/src/components/account/AccountSummary.tsx index 58293a2..3f121c5 100644 --- a/src/renderer/src/components/account/AccountSummary.tsx +++ b/src/renderer/src/components/account/AccountSummary.tsx @@ -20,31 +20,48 @@ export function AccountSummary({ compact = false, onClick }: AccountSummaryProps): React.ReactElement { - const displayName = dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接' + const displayName = + dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接' const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置' + const statusText = dbReady ? '数据库已连接' : '数据库未连接' const initial = (displayName || '?').charAt(0) + const title = `${displayName}\n${subtitle}` + const avatar = ( + + {selfInfo?.avatar ? ( + {displayName} + ) : ( + initial + )} + + + ) + + if (compact) { + return ( + + ) + } return ( - + + +
) } diff --git a/src/renderer/src/components/chat/ChatHeader.tsx b/src/renderer/src/components/chat/ChatHeader.tsx new file mode 100644 index 0000000..5c09b30 --- /dev/null +++ b/src/renderer/src/components/chat/ChatHeader.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Contact } from '../../../../shared/types' +import { ConversationContentSearch } from './ConversationContentSearch' +import { ExportMenu, ExportRange } from './ExportMenu' +import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons' + +interface ChatHeaderProps { + contact: Contact + isGroupChat: boolean + dateRangeLabel: string + loadedCount: number + filteredCount: number + contentFilter: string + isAiLoading: boolean + canExport: boolean + onContentFilterChange: (value: string) => void + onRefresh?: () => void + onRefreshData?: () => void + onExport: (range: ExportRange) => void + onOpenAiSettings: () => void +} + +export function ChatHeader({ + contact, + isGroupChat, + dateRangeLabel, + loadedCount, + filteredCount, + contentFilter, + isAiLoading, + canExport, + onContentFilterChange, + onRefresh, + onRefreshData, + onExport, + onOpenAiSettings +}: ChatHeaderProps): React.ReactElement { + const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter)) + const [moreOpen, setMoreOpen] = useState(false) + const moreRef = useRef(null) + const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话' + const typeLabel = isGroupChat ? '群聊' : '联系人' + const visibleCount = contentFilter ? filteredCount : loadedCount + + useEffect(() => { + if (!moreOpen) return + const handlePointerDown = (event: PointerEvent): void => { + if (!moreRef.current?.contains(event.target as Node)) setMoreOpen(false) + } + window.addEventListener('pointerdown', handlePointerDown) + return () => window.removeEventListener('pointerdown', handlePointerDown) + }, [moreOpen]) + + const handleCloseSearch = (): void => { + onContentFilterChange('') + setSearchOpen(false) + } + + return ( +
+
+
+ {contact.avatar ? ( + {displayName} + ) : ( + displayName.charAt(0) + )} +
+
+

{displayName}

+
+ {typeLabel} + {dateRangeLabel} + {visibleCount} 条消息 +
+
+
+
+ {searchOpen ? ( + + ) : ( + + )} + + +
+ + {moreOpen && ( +
+ +
+ )} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/chat/ChatStatusBar.tsx b/src/renderer/src/components/chat/ChatStatusBar.tsx new file mode 100644 index 0000000..707cb38 --- /dev/null +++ b/src/renderer/src/components/chat/ChatStatusBar.tsx @@ -0,0 +1,39 @@ +import React from 'react' + +interface ChatStatusBarProps { + count: number + showAvatar: boolean + isAtLatest: boolean + onShowAvatarChange: (show: boolean) => void + onJumpToLatest: () => void +} + +export function ChatStatusBar({ + count, + showAvatar, + isAtLatest, + onShowAvatarChange, + onJumpToLatest +}: ChatStatusBarProps): React.ReactElement { + const jumpDisabled = isAtLatest || count === 0 + + return ( +
+
已显示当前范围 {count} 条消息
+
+ + + +
+
+ ) +} diff --git a/src/renderer/src/components/chat/ConversationContentSearch.tsx b/src/renderer/src/components/chat/ConversationContentSearch.tsx new file mode 100644 index 0000000..ac4a29b --- /dev/null +++ b/src/renderer/src/components/chat/ConversationContentSearch.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { CloseIcon, SearchIcon } from './icons' + +interface ConversationContentSearchProps { + value: string + resultCount: number + onChange: (value: string) => void + onClose: () => void +} + +export function ConversationContentSearch({ + value, + resultCount, + onChange, + onClose +}: ConversationContentSearchProps): React.ReactElement { + return ( +
+ + + + onChange(event.target.value)} + autoFocus + /> + + {value ? `${resultCount} 条结果` : '输入关键词'} + + +
+ ) +} diff --git a/src/renderer/src/components/chat/DataTrustBar.tsx b/src/renderer/src/components/chat/DataTrustBar.tsx new file mode 100644 index 0000000..72f0376 --- /dev/null +++ b/src/renderer/src/components/chat/DataTrustBar.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { CheckCircleIcon, HomeIcon } from './icons' + +interface DataTrustBarProps { + messageCount: number +} + +export function DataTrustBar({ messageCount }: DataTrustBarProps): React.ReactElement { + return ( +
+
+ + 聊天记录仅在本机读取 +
+
+ + + 数据库已连接 + + + 已加载 {messageCount} 条消息 +
+
+ ) +} diff --git a/src/renderer/src/components/chat/EmptyConversationState.tsx b/src/renderer/src/components/chat/EmptyConversationState.tsx new file mode 100644 index 0000000..3da7f93 --- /dev/null +++ b/src/renderer/src/components/chat/EmptyConversationState.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +export function EmptyConversationState(): React.ReactElement { + return ( +
+
+

选择一条消息

+

从左侧选择群聊或联系人以浏览历史记录

+
+
+ ) +} diff --git a/src/renderer/src/components/chat/ExportMenu.tsx b/src/renderer/src/components/chat/ExportMenu.tsx new file mode 100644 index 0000000..91e214f --- /dev/null +++ b/src/renderer/src/components/chat/ExportMenu.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useRef, useState } from 'react' +import { ArrowDownIcon, ExportIcon } from './icons' + +export type ExportRange = number | 'all' + +interface ExportMenuProps { + disabled: boolean + onExport: (range: ExportRange) => void +} + +const EXPORT_OPTIONS: { label: string; value: ExportRange }[] = [ + { label: '导出当前范围', value: 'all' }, + { label: '导出今天', value: 0 }, + { label: '导出昨日', value: 1 }, + { label: '导出近 7 天', value: 7 }, + { label: '导出近 30 天', value: 30 }, + { label: '导出全部', value: 'all' } +] + +export function ExportMenu({ disabled, onExport }: ExportMenuProps): React.ReactElement { + const [open, setOpen] = useState(false) + const menuRef = useRef(null) + + useEffect(() => { + if (!open) return + const handlePointerDown = (event: PointerEvent): void => { + if (!menuRef.current?.contains(event.target as Node)) setOpen(false) + } + window.addEventListener('pointerdown', handlePointerDown) + return () => window.removeEventListener('pointerdown', handlePointerDown) + }, [open]) + + return ( +
+ + {open && ( +
+ {EXPORT_OPTIONS.map((option, index) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..9d5df9c --- /dev/null +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { Contact, Message } from '../../../../shared/types' +import { ImageBubble } from '../ImageBubble' +import { RichMessageBubble } from '../RichMessageBubble' +import { VoicePlayer } from '../VoicePlayer' +import { renderWechatEmojiText } from '../../utils/wechatEmojiText' +import { formatMessageTime } from './messageGrouping' + +interface MessageBubbleProps { + message: Message + contact: Contact + isGroupChat: boolean + isMine: boolean + showAvatarSpace: boolean + onImageClick: (imageUrl: string) => void +} + +const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'] + +export function MessageBubble({ + message, + contact, + isGroupChat, + isMine, + showAvatarSpace, + onImageClick +}: MessageBubbleProps): React.ReactElement { + const isVoice = message.type === '语音' + const isImage = message.type === '图片' + const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type) + const hoverTime = formatMessageTime(message) + + return ( +
+
+ {isVoice && message.sessionId ? ( + + ) : isImage && message.contentData && message.contentData.type === 'image' ? ( + + ) : isRichMedia && message.contentData ? ( + + ) : ( +
{renderWechatEmojiText(message.content)}
+ )} +
+ {hoverTime} + {!isGroupChat && !isMine && contact.m_nsNickName && ( + {contact.m_nsNickName} + )} +
+ ) +} diff --git a/src/renderer/src/components/chat/MessageGroup.tsx b/src/renderer/src/components/chat/MessageGroup.tsx new file mode 100644 index 0000000..e713d4f --- /dev/null +++ b/src/renderer/src/components/chat/MessageGroup.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { Contact } from '../../../../shared/types' +import { MessageBubble } from './MessageBubble' +import { MessageGroupModel } from './messageGrouping' + +interface MessageGroupProps { + group: MessageGroupModel + contact: Contact + isGroupChat: boolean + showAvatar: boolean + onImageClick: (imageUrl: string) => void +} + +export function MessageGroup({ + group, + contact, + isGroupChat, + showAvatar, + onImageClick +}: MessageGroupProps): React.ReactElement { + const firstMessage = group.messages[0] + + if (group.isSystem) { + return ( + <> + {group.timeLabel &&
{group.timeLabel}
} + {group.messages.map((message) => ( +
+
{message.content}
+
+ ))} + + ) + } + + const isMine = group.isMine + const displayName = isMine + ? '我' + : isGroupChat + ? firstMessage.name || firstMessage.from + : contact.m_nsNickName + const avatarSrc = isMine ? firstMessage.img : firstMessage.img || contact.avatar + const shouldShowAvatar = showAvatar + + return ( + <> + {group.timeLabel &&
{group.timeLabel}
} +
+ {!isMine && shouldShowAvatar && ( +
+ {avatarSrc ? ( + {displayName} + ) : ( + (displayName || '?').charAt(0) + )} +
+ )} + {!isMine && !shouldShowAvatar &&
} +
+ {!isMine && isGroupChat && shouldShowAvatar && ( +
{displayName}
+ )} + {group.messages.map((message, index) => ( + + ))} +
+ {isMine && shouldShowAvatar && ( +
+ {avatarSrc ? 我 : '我'} +
+ )} + {isMine && !shouldShowAvatar &&
} +
+ + ) +} diff --git a/src/renderer/src/components/chat/MessageList.tsx b/src/renderer/src/components/chat/MessageList.tsx new file mode 100644 index 0000000..bcfedb3 --- /dev/null +++ b/src/renderer/src/components/chat/MessageList.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { Contact, Message } from '../../../../shared/types' +import { MessageGroup } from './MessageGroup' +import { buildMessageGroups } from './messageGrouping' + +interface MessageListProps { + contact: Contact + messages: Message[] + hiddenMessageCount: number + isLoadingMessages?: boolean + isGroupChat: boolean + showAvatar: boolean + listRef: React.RefObject + bottomRef: React.RefObject + onScroll: (event: React.UIEvent) => void + onImageClick: (imageUrl: string) => void +} + +export function MessageList({ + contact, + messages, + hiddenMessageCount, + isLoadingMessages, + isGroupChat, + showAvatar, + listRef, + bottomRef, + onScroll, + onImageClick +}: MessageListProps): React.ReactElement { + const groups = React.useMemo(() => buildMessageGroups(messages), [messages]) + + return ( +
+ {isLoadingMessages &&
正在加载聊天记录...
} + {hiddenMessageCount > 0 && ( +
+
+ 已隐藏较早的 {hiddenMessageCount} 条消息,当前显示最新 {messages.length} 条 +
+
+ )} + {groups.map((group) => ( + + ))} +
+
+ ) +} diff --git a/src/renderer/src/components/chat/icons.tsx b/src/renderer/src/components/chat/icons.tsx new file mode 100644 index 0000000..241185e --- /dev/null +++ b/src/renderer/src/components/chat/icons.tsx @@ -0,0 +1,89 @@ +import React from 'react' + +interface IconProps { + className?: string +} + +export function SearchIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function RefreshIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function ExportIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function MoreIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function AiIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function CloseIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function ArrowDownIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function CheckCircleIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} + +export function HomeIcon({ className }: IconProps): React.ReactElement { + return ( + + ) +} diff --git a/src/renderer/src/components/chat/messageGrouping.ts b/src/renderer/src/components/chat/messageGrouping.ts new file mode 100644 index 0000000..e1c19ea --- /dev/null +++ b/src/renderer/src/components/chat/messageGrouping.ts @@ -0,0 +1,93 @@ +import { Message } from '../../../../shared/types' + +const GROUP_INTERVAL_SECONDS = 5 * 60 +const TIME_SEPARATOR_INTERVAL_SECONDS = 10 * 60 + +export interface MessageGroupModel { + id: string + messages: Message[] + isSystem: boolean + isMine: boolean + timeLabel?: string +} + +const getMessageTime = (message: Message): number | null => { + if (typeof message.createTime === 'number' && message.createTime > 0) return message.createTime + const parsed = Date.parse(message.datetime) + if (Number.isNaN(parsed)) return null + return Math.floor(parsed / 1000) +} + +const getSenderKey = (message: Message): string => + String(message.senderId || message.name || message.from || '').trim() + +const formatClock = (date: Date): string => + `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + +export const isSystemMessage = (message: Message): boolean => + message.from === 'system' || message.type === '系统消息' + +export const isMineMessage = (message: Message): boolean => message.from === 'assistant' + +export const formatMessageTime = (message: Message): string => { + const timestamp = getMessageTime(message) + if (!timestamp) return message.datetime + const date = new Date(timestamp * 1000) + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() + const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86400000) + const clock = formatClock(date) + + if (dayDiff === 0) return `今天 ${clock}` + if (dayDiff === 1) return `昨天 ${clock}` + if (date.getFullYear() === now.getFullYear()) { + return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}` + } + return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}` +} + +export const buildMessageGroups = (messages: Message[]): MessageGroupModel[] => { + const groups: MessageGroupModel[] = [] + let previousGroupStartTime: number | null = null + + messages.forEach((message) => { + const isSystem = isSystemMessage(message) + const isMine = isMineMessage(message) + const timestamp = getMessageTime(message) + const previousGroup = groups[groups.length - 1] + const previousMessage = previousGroup?.messages[previousGroup.messages.length - 1] + const previousTimestamp = previousMessage ? getMessageTime(previousMessage) : null + const canJoinPrevious = + previousGroup && + !isSystem && + !previousGroup.isSystem && + previousGroup.isMine === isMine && + getSenderKey(previousMessage) === getSenderKey(message) && + timestamp !== null && + previousTimestamp !== null && + timestamp - previousTimestamp >= 0 && + timestamp - previousTimestamp <= GROUP_INTERVAL_SECONDS + + if (canJoinPrevious) { + previousGroup.messages.push(message) + return + } + + const needsTimeLabel = + timestamp !== null && + (previousGroupStartTime === null || + Math.abs(timestamp - previousGroupStartTime) >= TIME_SEPARATOR_INTERVAL_SECONDS) + const group: MessageGroupModel = { + id: message.id || `${groups.length}-${message.datetime}`, + messages: [message], + isSystem, + isMine, + timeLabel: needsTimeLabel ? formatMessageTime(message) : undefined + } + groups.push(group) + if (timestamp !== null && needsTimeLabel) previousGroupStartTime = timestamp + }) + + return groups +} diff --git a/src/renderer/src/components/conversation/ConversationItem.tsx b/src/renderer/src/components/conversation/ConversationItem.tsx new file mode 100644 index 0000000..9662b3d --- /dev/null +++ b/src/renderer/src/components/conversation/ConversationItem.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Contact } from '../../../../shared/types' + +interface ConversationItemProps { + contact: Contact + active: boolean + onSelect: (contact: Contact) => void +} + +export function ConversationItem({ + contact, + active, + onSelect +}: ConversationItemProps): React.ReactElement { + const nickname = contact.m_nsNickName?.trim() + const wxid = contact.m_nsUsrName + const displayName = nickname || wxid || '未命名会话' + const initial = (displayName || wxid || '?').charAt(0) + + return ( + + ) +} diff --git a/src/renderer/src/components/conversation/ConversationSearch.tsx b/src/renderer/src/components/conversation/ConversationSearch.tsx new file mode 100644 index 0000000..7c5ce65 --- /dev/null +++ b/src/renderer/src/components/conversation/ConversationSearch.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +interface ConversationSearchProps { + value: string + onChange: (value: string) => void +} + +function SearchIcon(): React.ReactElement { + return ( + + ) +} + +export function ConversationSearch({ + value, + onChange +}: ConversationSearchProps): React.ReactElement { + return ( + + ) +} diff --git a/src/renderer/src/components/conversation/ConversationSection.tsx b/src/renderer/src/components/conversation/ConversationSection.tsx new file mode 100644 index 0000000..747d5cb --- /dev/null +++ b/src/renderer/src/components/conversation/ConversationSection.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { Contact } from '../../../../shared/types' +import { ConversationItem } from './ConversationItem' + +interface ConversationSectionProps { + title: string + contacts: Contact[] + expanded: boolean + selectedContact: Contact | null + emptyText: string + onToggle: () => void + onSelectContact: (contact: Contact) => void +} + +function ChevronIcon({ expanded }: { expanded: boolean }): React.ReactElement { + return ( + + ) +} + +export function ConversationSection({ + title, + contacts, + expanded, + selectedContact, + emptyText, + onToggle, + onSelectContact +}: ConversationSectionProps): React.ReactElement { + return ( +
+ + {expanded && ( +
+ {contacts.length > 0 ? ( + contacts.map((contact) => ( + + )) + ) : ( +
{emptyText}
+ )} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/conversation/ConversationSidebar.tsx b/src/renderer/src/components/conversation/ConversationSidebar.tsx new file mode 100644 index 0000000..2e72e0e --- /dev/null +++ b/src/renderer/src/components/conversation/ConversationSidebar.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react' +import { Contact } from '../../../../shared/types' +import { AccountSummary } from '../account/AccountSummary' +import { ConversationSection } from './ConversationSection' +import { ConversationSidebarHeader } from './ConversationSidebarHeader' + +interface SelfInfo { + wxid: string + nickname: string + avatar?: string + accountRoot: string +} + +export interface ConversationSidebarProps { + contacts: Contact[] + selectedContact: Contact | null + onSelectContact: (contact: Contact) => void + onSearch: (keyword: string) => void + onContentFilter: (keyword: string) => void + width: number + dateRange: string + onDateRangeChange: (range: string) => void + selfInfo: SelfInfo | null + dbReady: boolean + onOpenSettings: () => void +} + +export function ConversationSidebar({ + contacts, + selectedContact, + onSelectContact, + onSearch, + width, + dateRange, + onDateRangeChange, + selfInfo, + dbReady, + onOpenSettings +}: ConversationSidebarProps): React.ReactElement { + const [searchTerm, setSearchTerm] = useState('') + const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups') + + const groups = contacts.filter((contact) => contact.type === 'group') + const users = contacts.filter((contact) => contact.type === 'user') + + const handleSearchChange = (term: string): void => { + setSearchTerm(term) + onSearch(term) + } + + return ( + + ) +} diff --git a/src/renderer/src/components/conversation/ConversationSidebarHeader.tsx b/src/renderer/src/components/conversation/ConversationSidebarHeader.tsx new file mode 100644 index 0000000..8cc138a --- /dev/null +++ b/src/renderer/src/components/conversation/ConversationSidebarHeader.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { ConversationSearch } from './ConversationSearch' +import { DateRangeSelector } from './DateRangeSelector' + +interface ConversationSidebarHeaderProps { + totalCount: number + searchValue: string + dateRange: string + onSearchChange: (value: string) => void + onDateRangeChange: (range: string) => void +} + +export function ConversationSidebarHeader({ + totalCount, + searchValue, + dateRange, + onSearchChange, + onDateRangeChange +}: ConversationSidebarHeaderProps): React.ReactElement { + return ( +
+
+

聊天档案

+ {totalCount} 个会话 +
+ + +
+ ) +} diff --git a/src/renderer/src/components/conversation/DateRangeSelector.tsx b/src/renderer/src/components/conversation/DateRangeSelector.tsx new file mode 100644 index 0000000..a6df65b --- /dev/null +++ b/src/renderer/src/components/conversation/DateRangeSelector.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +interface DateRangeSelectorProps { + value: string + onChange: (range: string) => void +} + +const DATE_RANGE_OPTIONS = [ + { key: 'today', label: '今天' }, + { key: 'yesterday', label: '昨日' }, + { key: '7', label: '7 天' }, + { key: '30', label: '30 天' }, + { key: 'all', label: '全部' } +] + +export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement { + return ( +
+ {DATE_RANGE_OPTIONS.map((item) => ( + + ))} +
+ ) +}