diff --git a/frontend/assets/css/chat.css b/frontend/assets/css/chat.css new file mode 100644 index 0000000..552a4b6 --- /dev/null +++ b/frontend/assets/css/chat.css @@ -0,0 +1,1177 @@ +/* LinkCard:小程序标记与无 URL 降级 */ +.wechat-link-badge { + margin-left: auto; + padding-left: 8px; + font-size: 11px; + color: #b2b2b2; + white-space: nowrap; + flex-shrink: 0; +} + +.wechat-link-cover-badge { + margin-left: auto; + padding-left: 8px; + font-size: 11px; + color: rgba(243, 243, 243, 0.92); + white-space: nowrap; + flex-shrink: 0; +} + +.wechat-link-card.wechat-link-card--disabled, +.wechat-link-card-cover.wechat-link-card--disabled { + cursor: default; +} + +.wechat-link-card.wechat-link-card--disabled:hover, +.wechat-link-card-cover.wechat-link-card--disabled:hover { + background: #fff; +} + +/* 滚动条样式 */ +.overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.overflow-y-auto::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #a1a1a1; +} + +/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */ +.session-list-panel { + width: calc(var(--session-list-width, 295px) / var(--dpr)); +} + +/* 会话列表拖动条(中间栏右侧) */ +.session-list-resizer { + position: absolute; + top: 0; + right: -3px; /* 覆盖在 border 上,便于拖动 */ + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 50; +} + +.session-list-resizer::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + width: 2px; + background: transparent; + transition: background-color 0.15s ease; +} + +.session-list-resizer:hover::after, +.session-list-resizer-active::after { + background: rgba(0, 0, 0, 0.12); +} + +/* 消息气泡样式 */ +.message-bubble { + border-radius: var(--message-radius); + position: relative; + z-index: 1; +} + +/* 发送的消息(右侧绿色气泡) */ +.sent-message { + background-color: #95EB69 !important; + border-radius: var(--message-radius); +} + +.sent-message::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: #95EB69; + border-radius: 2px; +} + +/* 接收的消息(左侧白色气泡) */ +.received-message { + background-color: white !important; + border-radius: var(--message-radius); +} + +.received-message::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: white; + border-radius: 2px; +} + +/* 聊天标签页样式 */ +.chat-tab { + cursor: pointer; + transition: all 0.2s ease; + color: #606060; +} + +.chat-tab:hover:not(.selected) { + background-color: #E5E5E5; +} + +.chat-tab.selected { + color: #07b75b !important; +} + +.chat-tab:not(.selected):hover { + color: #07b75b; +} + +/* 语音消息样式 */ +.voice-message-wrap { + display: flex; + width: 100%; +} + +.voice-bubble { + border-radius: var(--message-radius); + position: relative; + transition: opacity 0.15s ease; +} + +.voice-bubble:hover { + opacity: 0.85; +} + +.voice-bubble:active { + opacity: 0.7; +} + +.voice-sent { + border-radius: var(--message-radius); +} + +.voice-sent::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: #95EC69; + border-radius: 2px; +} + +.voice-received { + border-radius: var(--message-radius); +} + +.voice-received::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: white; + border-radius: 2px; +} + +/* 语音消息样式 - 微信风格 */ +.wechat-voice-wrapper { + display: flex; + width: 100%; + position: relative; +} + +.wechat-voice-bubble { + border-radius: var(--message-radius); + position: relative; + transition: opacity 0.15s ease; + min-width: 80px; + max-width: 200px; +} + +.wechat-voice-bubble:hover { + opacity: 0.85; +} + +.wechat-voice-bubble:active { + opacity: 0.7; +} + +.wechat-voice-sent { + background: #95EC69; +} + +.wechat-voice-sent::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: #95EC69; + border-radius: 2px; +} + +.wechat-voice-received { + background: white; +} + +.wechat-voice-received::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: white; + border-radius: 2px; +} + +.wechat-voice-content { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; +} + +/* 语音图标样式 */ +.wechat-voice-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + color: #1a1a1a; +} + +.wechat-quote-voice-icon { + width: 14px; + height: 14px; + color: inherit; +} + +.voice-icon-sent { + transform: scaleX(-1); +} + +/* 播放时的波动动画 */ +.wechat-voice-icon.voice-playing .voice-wave-2 { + animation: voice-wave-2 1s infinite; +} + +.wechat-voice-icon.voice-playing .voice-wave-3 { + animation: voice-wave-3 1s infinite; +} + +@keyframes voice-wave-2 { + 0%, 33% { opacity: 0; } + 34%, 100% { opacity: 1; } +} + +@keyframes voice-wave-3 { + 0%, 66% { opacity: 0; } + 67%, 100% { opacity: 1; } +} + +.wechat-voice-duration { + font-size: 14px; + color: #1a1a1a; +} + +.wechat-voice-unread { + position: absolute; + top: 50%; + right: -20px; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: #e75e58; +} + +/* 音视频通话消息样式 - 微信风格 */ +.wechat-voip-bubble { + border-radius: var(--message-radius); + position: relative; + min-width: 120px; +} + +.wechat-voip-sent { + background: #95EC69; +} + +.wechat-voip-sent::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: #95EC69; + border-radius: 2px; +} + +.wechat-voip-received { + background: white; +} + +.wechat-voip-received::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: white; + border-radius: 2px; +} + +.wechat-voip-content { + display: flex; + align-items: center; + padding: 8px 14px; + gap: 8px; +} + +.wechat-voip-icon { + width: 22px; + height: 14px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-voip-text { + font-size: 14px; + color: #1a1a1a; +} + +/* 统一特殊消息尾巴(红包 / 文件等) */ +:deep(.wechat-special-card) { + position: relative; + overflow: visible; +} + +:deep(.wechat-special-card)::after { + content: ''; + position: absolute; + top: 12px; + left: -4px; + width: 12px; + height: 12px; + background-color: inherit; + transform: rotate(45deg); + border-radius: 2px; +} + +:deep(.wechat-special-sent-side)::after { + left: auto; + right: -4px; +} + +.wechat-chat-history-card { + width: 210px; + background: #ffffff; + border-radius: var(--message-radius); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.wechat-chat-history-card:hover { + background: #f5f5f5; +} + +.wechat-chat-history-body { + padding: 10px 12px; +} + +.wechat-chat-history-title { + font-size: 14px; + font-weight: 400; + color: #161616; + margin-bottom: 6px; +} + +.wechat-chat-history-preview { + font-size: 12px; + color: #6b7280; + line-height: 1.4; +} + +.wechat-chat-history-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-chat-history-bottom { + height: 27px; + display: flex; + align-items: center; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-chat-history-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1.5px; + background: #e8e8e8; +} + +.wechat-chat-history-bottom span { + font-size: 12px; + color: #b2b2b2; +} + +/* 转账消息样式 - 微信风格 */ +.wechat-transfer-card { + width: 210px; + background: #f79c46; + border-radius: var(--message-radius); + overflow: visible; + position: relative; +} + +.wechat-transfer-card::after { + content: ''; + position: absolute; + top: 16px; + left: -4px; + width: 10px; + height: 10px; + background: #f79c46; + transform: rotate(45deg); + border-radius: 2px; +} + +.wechat-transfer-sent-side::after { + left: auto; + right: -4px; +} + +.wechat-transfer-content { + display: flex; + align-items: center; + padding: 10px 12px; + min-height: 58px; +} + +.wechat-transfer-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-transfer-info { + flex: 1; + margin-left: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wechat-transfer-amount { + font-size: 16px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-transfer-status { + font-size: 12px; + color: #fff; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-transfer-bottom { + height: 27px; + display: flex; + align-items: center; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-transfer-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1px; + background: rgba(255,255,255,0.2); +} + +.wechat-transfer-bottom span { + font-size: 11px; + color: #fff; +} + +/* 已领取的转账样式 */ +.wechat-transfer-received { + background: #FDCE9D; +} + +.wechat-transfer-received::after { + background: #FDCE9D; +} + +.wechat-transfer-received .wechat-transfer-amount, +.wechat-transfer-received .wechat-transfer-status { + color: #fff; +} + +.wechat-transfer-received .wechat-transfer-bottom span { + color: #fff; +} + +/* 退回的转账样式 */ +.wechat-transfer-returned { + background: #fde1c3; +} + +.wechat-transfer-returned::after { + background: #fde1c3; +} + +.wechat-transfer-returned .wechat-transfer-amount, +.wechat-transfer-returned .wechat-transfer-status { + color: #fff; +} + +.wechat-transfer-returned .wechat-transfer-bottom span { + color: #fff; +} + +/* 过期的转账样式 */ +.wechat-transfer-overdue { + background: #E9CFB3; +} + +.wechat-transfer-overdue::after { + background: #E9CFB3; +} + +.wechat-transfer-overdue .wechat-transfer-amount, +.wechat-transfer-overdue .wechat-transfer-status { + color: #fff; +} + +.wechat-transfer-overdue .wechat-transfer-bottom span { + color: #fff; +} + +/* 红包消息样式 - 微信风格 */ +.wechat-redpacket-card { + width: 210px; + background: #fa9d3b; + border-radius: var(--message-radius); + overflow: visible; + position: relative; +} + +.wechat-redpacket-content { + display: flex; + align-items: center; + padding: 10px 12px; + min-height: 58px; +} + +.wechat-redpacket-icon { + width: 32px; + height: 36px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-redpacket-info { + flex: 1; + margin-left: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wechat-redpacket-text { + font-size: 14px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-redpacket-status { + font-size: 12px; + color: #fff; + margin-top: 2px; +} + +.wechat-redpacket-bottom { + height: 27px; + display: flex; + align-items: center; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-redpacket-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1px; + background: rgba(255,255,255,0.2); +} + +.wechat-redpacket-bottom span { + font-size: 11px; + color: #faecda; +} + +/* 已领取的红包样式 */ +.wechat-redpacket-received { + background: #f8e2c6; +} + +.wechat-redpacket-received .wechat-redpacket-text, +.wechat-redpacket-received .wechat-redpacket-status { + color: #b88550; +} + +.wechat-redpacket-received .wechat-redpacket-bottom span { + color: #c9a67a; +} + +/* 文件消息样式 - 基于红包样式覆盖 */ +.wechat-file-card { + width: 210px; + background: #fff; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.wechat-file-card .wechat-redpacket-content { + padding: 10px 12px; + min-height: 58px; +} + +.wechat-file-card .wechat-redpacket-bottom { + height: 27px; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-file-card .wechat-redpacket-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1.5px; + background: #e8e8e8; +} + +.wechat-file-card:hover { + background: #f5f5f5; +} + +.wechat-file-card .wechat-file-info { + margin-left: 0; + margin-right: 10px; +} + +.wechat-file-name { + font-size: 14px; + color: #1a1a1a; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; + line-height: 1.4; +} + +.wechat-file-size { + font-size: 12px; + color: #b2b2b2; + margin-top: 4px; +} + +.wechat-file-icon { + width: 40px; + height: 40px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-file-bottom { + border-top: 1px solid #e8e8e8; +} + +.wechat-file-bottom span { + font-size: 12px; + color: #b2b2b2; +} + +.wechat-file-logo { + width: 18px; + height: 18px; + object-fit: contain; + margin-right: 4px; +} + +/* 链接消息样式 - 微信风格 */ +:deep(.wechat-link-card) { + width: 210px; + min-width: 210px; + max-width: 210px; + background: #fff; + display: flex; + flex-direction: column; + box-sizing: border-box; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +:deep(.wechat-link-card:hover) { + background: #f5f5f5; +} + +:deep(.wechat-link-content) { + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + padding: 10px 10px 8px; + flex: 1 1 auto; +} + +:deep(.wechat-link-summary) { + display: flex; + align-items: flex-start; + gap: 10px; + min-height: 42px; +} + +:deep(.wechat-link-title) { + font-size: 14px; + color: #1a1a1a; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + word-break: break-word; +} + +:deep(.wechat-link-desc) { + font-size: 12px; + color: #8c8c8c; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + word-break: break-word; + flex: 1 1 auto; + min-width: 0; +} + +:deep(.wechat-link-thumb) { + width: 42px; + height: 42px; + flex: 0 0 auto; + border-radius: 0; + overflow: hidden; + background: #f2f2f2; + align-self: flex-start; +} + +:deep(.wechat-link-thumb-img) { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +:deep(.wechat-link-card--mini-program) { + max-height: 270px; + height: 270px; +} + +:deep(.wechat-link-mini-body) { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + box-sizing: border-box; + flex: 1 1 auto; + min-height: 0; +} + +:deep(.wechat-link-mini-header) { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +:deep(.wechat-link-mini-header-avatar) { + width: 20px; + height: 20px; + border-radius: 50%; + background: #14c15f; + color: #fff; + font-size: 11px; + line-height: 20px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +:deep(.wechat-link-mini-header-avatar-img) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +:deep(.wechat-link-mini-header-name) { + font-size: 13px; + color: #7d7d7d; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +:deep(.wechat-link-mini-title) { + font-size: 13px; + line-height: 1.45; + color: #1a1a1a; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +:deep(.wechat-link-mini-preview) { + width: 100%; + height: auto; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: #f2f2f2; + margin-top: auto; +} + +:deep(.wechat-link-mini-preview--empty) { + background: #f7f7f7; +} + +:deep(.wechat-link-mini-preview-img) { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; +} + +:deep(.wechat-link-mini-footer) { + height: 23px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + box-sizing: border-box; + position: relative; + flex-shrink: 0; +} + +:deep(.wechat-link-mini-footer)::before { + content: ''; + position: absolute; + top: 0; + left: 12px; + right: 12px; + height: 1px; + background: #e8e8e8; +} + +:deep(.wechat-link-mini-footer-icon) { + width: 12px; + height: 12px; + object-fit: contain; + flex-shrink: 0; +} + +:deep(.wechat-link-mini-footer-text) { + font-size: 10px; + color: #8c8c8c; +} + +:deep(.wechat-link-from) { + height: 30px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + position: relative; + flex-shrink: 0; +} + +:deep(.wechat-link-from)::before { + content: ''; + position: absolute; + top: 0; + left: 11px; + right: 11px; + height: 1.5px; + background: #e8e8e8; +} + +:deep(.wechat-link-from-avatar) { + width: 16px; + height: 16px; + border-radius: 50%; + background: #111; + color: #fff; + font-size: 11px; + line-height: 16px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +:deep(.wechat-link-from-avatar-img) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +:deep(.wechat-link-from-name) { + font-size: 12px; + color: #b2b2b2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 链接封面卡片(170x230 图 + 60 底栏) */ +:deep(.wechat-link-card-cover) { + width: 137px; + min-width: 137px; + max-width: 137px; + background: #fff; + display: flex; + flex-direction: column; + box-sizing: border-box; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +:deep(.wechat-link-card-cover:hover) { + background: #f5f5f5; +} + +:deep(.wechat-link-cover-image-wrap) { + width: 137px; + height: 180px; + position: relative; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: #f2f2f2; + flex-shrink: 0; +} + +:deep(.wechat-link-cover-image) { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; +} + +/* 仅公众号封面卡片去掉菱形尖角,其它消息保持原样 */ +:deep(.wechat-link-card-cover.wechat-special-card)::after { + content: none !important; +} + +:deep(.wechat-link-cover-from) { + height: 30px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + box-sizing: border-box; + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: transparent; + flex-shrink: 0; +} + +:deep(.wechat-link-cover-from-avatar) { + width: 18px; + height: 18px; + border-radius: 50%; + background: #111; + color: #fff; + font-size: 11px; + line-height: 18px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +:deep(.wechat-link-cover-from-avatar-img) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +:deep(.wechat-link-cover-from-name) { + font-size: 12px; + color: #f3f3f3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +:deep(.wechat-link-cover-title) { + height: 50px; + padding: 7px 10px 0; + box-sizing: border-box; + font-size: 12px; + line-height: 1.24; + color: #1a1a1a; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + flex-shrink: 0; +} + +/* 隐私模式模糊效果 */ +.privacy-blur { + filter: blur(9px); + transition: filter 0.2s ease; +} + +.privacy-blur:hover { + filter: none; +} + +/* 定位引用消息的高亮效果 */ +.message-locate-highlight { + position: relative; + animation: locate-pulse 1.8s ease-out; +} + +.message-locate-highlight::before { + content: ''; + position: absolute; + inset: -4px -8px; + border-radius: 8px; + background: rgba(3, 193, 96, 0.12); + pointer-events: none; + animation: locate-fade 1.8s ease-out forwards; +} + +@keyframes locate-pulse { + 0% { + transform: scale(1.02); + } + 15% { + transform: scale(1); + } + 100% { + transform: scale(1); + } +} + +@keyframes locate-fade { + 0% { + opacity: 1; + background: rgba(3, 193, 96, 0.15); + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +/* 骨架屏加载动画 */ +.skeleton-pulse { + animation: skeleton-loading 1.5s ease-in-out infinite; +} + +@keyframes skeleton-loading { + 0% { + opacity: 0.6; + } + 50% { + opacity: 0.3; + } + 100% { + opacity: 0.6; + } +} diff --git a/frontend/components/chat/ChatOverlays.vue b/frontend/components/chat/ChatOverlays.vue new file mode 100644 index 0000000..6820f54 --- /dev/null +++ b/frontend/components/chat/ChatOverlays.vue @@ -0,0 +1,1625 @@ + + + diff --git a/frontend/components/chat/ConversationPane.vue b/frontend/components/chat/ConversationPane.vue new file mode 100644 index 0000000..e8bced0 --- /dev/null +++ b/frontend/components/chat/ConversationPane.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/components/chat/FileTypeIcon.vue b/frontend/components/chat/FileTypeIcon.vue new file mode 100644 index 0000000..69f7732 --- /dev/null +++ b/frontend/components/chat/FileTypeIcon.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/components/chat/LinkCard.vue b/frontend/components/chat/LinkCard.vue new file mode 100644 index 0000000..1aa9890 --- /dev/null +++ b/frontend/components/chat/LinkCard.vue @@ -0,0 +1,285 @@ + diff --git a/frontend/components/chat/MessageContent.vue b/frontend/components/chat/MessageContent.vue new file mode 100644 index 0000000..1f1064a --- /dev/null +++ b/frontend/components/chat/MessageContent.vue @@ -0,0 +1,316 @@ + + + diff --git a/frontend/components/chat/MessageItem.vue b/frontend/components/chat/MessageItem.vue new file mode 100644 index 0000000..6f39a86 --- /dev/null +++ b/frontend/components/chat/MessageItem.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/components/chat/MessageList.vue b/frontend/components/chat/MessageList.vue new file mode 100644 index 0000000..b2d5640 --- /dev/null +++ b/frontend/components/chat/MessageList.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/components/chat/SessionListPanel.vue b/frontend/components/chat/SessionListPanel.vue new file mode 100644 index 0000000..bc4e115 --- /dev/null +++ b/frontend/components/chat/SessionListPanel.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/composables/chat/useChatEditing.js b/frontend/composables/chat/useChatEditing.js new file mode 100644 index 0000000..50221ae --- /dev/null +++ b/frontend/composables/chat/useChatEditing.js @@ -0,0 +1,545 @@ +import { ref, toRaw } from 'vue' + +const initialContextMenu = () => ({ + visible: false, + x: 0, + y: 0, + message: null, + kind: '', + disabled: false, + editStatus: null, + editStatusLoading: false +}) + +const initialMessageEditModal = () => ({ + open: false, + loading: false, + saving: false, + error: '', + mode: 'content', + sessionId: '', + messageId: '', + draft: '', + rawRow: null +}) + +const initialMessageFieldsModal = () => ({ + open: false, + loading: false, + saving: false, + error: '', + sessionId: '', + messageId: '', + unsafe: false, + editsJson: '', + rawRow: null +}) + +export const useChatEditing = ({ + api, + selectedAccount, + selectedContact, + refreshSelectedMessages, + normalizeMessage, + allMessages, + locateMessageByServerId +}) => { + const contextMenu = ref(initialContextMenu()) + const messageEditModal = ref(initialMessageEditModal()) + const messageFieldsModal = ref(initialMessageFieldsModal()) + + const closeContextMenu = () => { + contextMenu.value = initialContextMenu() + } + + const loadContextMenuEditStatus = async (params) => { + if (!process.client) return + const account = String(params?.account || '').trim() + const username = String(params?.username || '').trim() + const messageId = String(params?.message_id || '').trim() + if (!account || !username || !messageId) { + contextMenu.value.editStatusLoading = false + return + } + + try { + const response = await api.getChatEditStatus({ account, username, message_id: messageId }) + const current = String(contextMenu.value?.message?.id || '').trim() + if (contextMenu.value.visible && current === messageId) { + contextMenu.value.editStatus = response || { modified: false } + } + } catch { + const current = String(contextMenu.value?.message?.id || '').trim() + if (contextMenu.value.visible && current === messageId) { + contextMenu.value.editStatus = null + } + } finally { + const current = String(contextMenu.value?.message?.id || '').trim() + if (contextMenu.value.visible && current === messageId) { + contextMenu.value.editStatusLoading = false + } + } + } + + const openMediaContextMenu = (event, message, kind) => { + if (!process.client) return + event.preventDefault() + event.stopPropagation() + + let actualKind = kind + let disabled = true + if (kind === 'voice') { + disabled = !(message?.serverIdStr || message?.serverId) + } else if (kind === 'file') { + disabled = !message?.fileMd5 + } else if (kind === 'image') { + disabled = !(message?.imageMd5 || message?.imageFileId) + } else if (kind === 'emoji') { + disabled = !message?.emojiMd5 + } else if (kind === 'video') { + if (message?.videoMd5 || message?.videoFileId) { + disabled = false + actualKind = 'video' + } else if (message?.videoThumbMd5 || message?.videoThumbFileId) { + disabled = false + actualKind = 'video_thumb' + } + } + + contextMenu.value = { + visible: true, + x: event.clientX, + y: event.clientY, + message, + kind: actualKind, + disabled, + editStatus: null, + editStatusLoading: false + } + + try { + const account = String(selectedAccount.value || '').trim() + const username = String(selectedContact.value?.username || '').trim() + const messageId = String(message?.id || '').trim() + if (account && username && messageId) { + contextMenu.value.editStatusLoading = true + void loadContextMenuEditStatus({ account, username, message_id: messageId }) + } + } catch {} + } + + const prettyJson = (value) => { + try { + return JSON.stringify(value ?? null, null, 2) + } catch { + return String(value ?? '') + } + } + + const isLikelyTextMessage = (message) => { + if (!message) return false + const renderType = String(message?.renderType || '').trim() + if (renderType && renderType !== 'text') return false + if (message?.imageUrl || message?.emojiUrl || message?.videoUrl || message?.voiceUrl) return false + return true + } + + const closeMessageEditModal = () => { + messageEditModal.value = initialMessageEditModal() + } + + const openMessageEditModal = async ({ message, mode }) => { + if (!process.client) return + const account = String(selectedAccount.value || '').trim() + const sessionId = String(selectedContact.value?.username || '').trim() + const messageId = String(message?.id || '').trim() + if (!account || !sessionId || !messageId) return + + const resolvedMode = mode === 'raw' ? 'raw' : 'content' + const initialDraft = resolvedMode === 'content' + ? (typeof message?.content === 'string' ? message.content : String(message?.content ?? '')) + : '' + + messageEditModal.value = { + open: true, + loading: true, + saving: false, + error: '', + mode: resolvedMode, + sessionId, + messageId, + draft: initialDraft, + rawRow: null + } + + try { + const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId }) + const row = response?.row || null + const rawContent = row?.message_content + const rawDraft = typeof rawContent === 'string' ? rawContent : String(rawContent ?? '') + const draft = resolvedMode === 'raw' ? rawDraft : messageEditModal.value.draft + messageEditModal.value = { ...messageEditModal.value, loading: false, rawRow: row, draft } + } catch (error) { + messageEditModal.value = { ...messageEditModal.value, loading: false, error: error?.message || '加载失败' } + } + } + + const saveMessageEditModal = async () => { + if (!process.client) return + if (messageEditModal.value.saving || messageEditModal.value.loading) return + + const account = String(selectedAccount.value || '').trim() + const sessionId = String(messageEditModal.value.sessionId || '').trim() + const messageId = String(messageEditModal.value.messageId || '').trim() + if (!account || !sessionId || !messageId) return + + messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' } + try { + const response = await api.editChatMessage({ + account, + session_id: sessionId, + message_id: messageId, + edits: { + message_content: String(messageEditModal.value.draft ?? '') + }, + unsafe: false + }) + + if (response?.updated_message) { + try { + const updated = normalizeMessage(response.updated_message) + const username = String(selectedContact.value?.username || '').trim() + const list = allMessages.value[username] || [] + const index = list.findIndex((message) => String(message?.id || '') === String(updated?.id || '')) + if (index >= 0) { + const next = [...list] + next[index] = updated + allMessages.value = { ...allMessages.value, [username]: next } + } else { + await refreshSelectedMessages() + } + } catch { + await refreshSelectedMessages() + } + } else { + await refreshSelectedMessages() + } + + closeMessageEditModal() + } catch (error) { + messageEditModal.value = { ...messageEditModal.value, saving: false, error: error?.message || '保存失败' } + return + } finally { + messageEditModal.value = { ...messageEditModal.value, saving: false } + } + } + + const closeMessageFieldsModal = () => { + messageFieldsModal.value = initialMessageFieldsModal() + } + + const openMessageFieldsModal = async (message) => { + if (!process.client) return + const account = String(selectedAccount.value || '').trim() + const sessionId = String(selectedContact.value?.username || '').trim() + const messageId = String(message?.id || '').trim() + if (!account || !sessionId || !messageId) return + + messageFieldsModal.value = { + open: true, + loading: true, + saving: false, + error: '', + sessionId, + messageId, + unsafe: false, + editsJson: '', + rawRow: null + } + + try { + const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId }) + const row = response?.row || null + const seed = {} + for (const key of ['message_content', 'local_type', 'create_time', 'server_id', 'origin_source', 'source']) { + if (row && Object.prototype.hasOwnProperty.call(row, key)) seed[key] = row[key] + } + messageFieldsModal.value = { + ...messageFieldsModal.value, + loading: false, + rawRow: row, + editsJson: JSON.stringify(seed, null, 2) + } + } catch (error) { + messageFieldsModal.value = { ...messageFieldsModal.value, loading: false, error: error?.message || '加载失败' } + } + } + + const saveMessageFieldsModal = async () => { + if (!process.client) return + if (messageFieldsModal.value.saving || messageFieldsModal.value.loading) return + + const account = String(selectedAccount.value || '').trim() + const sessionId = String(messageFieldsModal.value.sessionId || '').trim() + const messageId = String(messageFieldsModal.value.messageId || '').trim() + if (!account || !sessionId || !messageId) return + + let edits = null + try { + edits = JSON.parse(String(messageFieldsModal.value.editsJson || '').trim() || 'null') + } catch { + messageFieldsModal.value = { ...messageFieldsModal.value, error: 'JSON 格式错误' } + return + } + if (!edits || typeof edits !== 'object' || Array.isArray(edits)) { + messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 必须是 JSON 对象' } + return + } + if (!Object.keys(edits).length) { + messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 不能为空' } + return + } + + messageFieldsModal.value = { ...messageFieldsModal.value, saving: true, error: '' } + try { + await api.editChatMessage({ + account, + session_id: sessionId, + message_id: messageId, + edits, + unsafe: !!messageFieldsModal.value.unsafe + }) + await refreshSelectedMessages() + closeMessageFieldsModal() + } catch (error) { + messageFieldsModal.value = { ...messageFieldsModal.value, saving: false, error: error?.message || '保存失败' } + return + } finally { + messageFieldsModal.value = { ...messageFieldsModal.value, saving: false } + } + } + + const copyTextToClipboard = async (text) => { + if (!process.client) return false + + const value = String(text ?? '').trim() + if (!value) return false + + try { + await navigator.clipboard.writeText(value) + return true + } catch {} + + try { + const element = document.createElement('textarea') + element.value = value + element.setAttribute('readonly', 'true') + element.style.position = 'fixed' + element.style.left = '-9999px' + element.style.top = '-9999px' + document.body.appendChild(element) + element.select() + const ok = document.execCommand('copy') + document.body.removeChild(element) + if (ok) return true + } catch {} + + try { + window.prompt('复制内容:', value) + return true + } catch { + return false + } + } + + const onCopyMessageTextClick = async () => { + if (!process.client) return + const message = contextMenu.value.message + if (!message) return + try { + const text = String(message?.content || '').trim() + if (!text) { + window.alert('该消息没有可复制的文本') + return + } + const ok = await copyTextToClipboard(text) + if (!ok) window.alert('复制失败:无法写入剪贴板') + } catch { + window.alert('复制失败') + } finally { + closeContextMenu() + } + } + + const onCopyMessageJsonClick = async () => { + if (!process.client) return + const message = contextMenu.value.message + if (!message) return + try { + const raw = toRaw(message) || message + const json = JSON.stringify(raw, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2) + const ok = await copyTextToClipboard(json) + if (!ok) window.alert('复制失败:无法写入剪贴板') + } catch { + window.alert('复制失败') + } finally { + closeContextMenu() + } + } + + const onOpenFolderClick = async () => { + if (contextMenu.value.disabled) return + const message = contextMenu.value.message + const kind = contextMenu.value.kind + + try { + if (!selectedAccount.value || !selectedContact.value?.username) return + + const params = { + account: selectedAccount.value, + username: selectedContact.value.username, + kind + } + + if (kind === 'voice') { + params.server_id = message.serverIdStr || message.serverId + } else if (kind === 'file') { + params.md5 = message.fileMd5 + } else if (kind === 'image') { + if (message.imageMd5) params.md5 = message.imageMd5 + else if (message.imageFileId) params.file_id = message.imageFileId + } else if (kind === 'emoji') { + params.md5 = message.emojiMd5 + } else if (kind === 'video') { + params.md5 = message.videoMd5 + if (message.videoFileId) params.file_id = message.videoFileId + } else if (kind === 'video_thumb') { + params.md5 = message.videoThumbMd5 + if (message.videoThumbFileId) params.file_id = message.videoThumbFileId + } + + await api.openChatMediaFolder(params) + } finally { + closeContextMenu() + } + } + + const onEditMessageClick = async () => { + const message = contextMenu.value.message + if (!message) return + const mode = isLikelyTextMessage(message) ? 'content' : 'raw' + closeContextMenu() + await openMessageEditModal({ message, mode }) + } + + const onEditMessageFieldsClick = async () => { + const message = contextMenu.value.message + if (!message) return + closeContextMenu() + await openMessageFieldsModal(message) + } + + const onResetEditedMessageClick = async () => { + if (!process.client) return + const message = contextMenu.value.message + const account = String(selectedAccount.value || '').trim() + const sessionId = String(selectedContact.value?.username || '').trim() + const messageId = String(message?.id || '').trim() + if (!message || !account || !sessionId || !messageId) return + + const ok = window.confirm('确认恢复该条消息到首次快照吗?') + if (!ok) return + + try { + await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId }) + closeContextMenu() + await refreshSelectedMessages() + } catch (error) { + window.alert(error?.message || '恢复失败') + } finally { + closeContextMenu() + } + } + + const onRepairMessageSenderAsMeClick = async () => { + if (!process.client) return + const message = contextMenu.value.message + const account = String(selectedAccount.value || '').trim() + const sessionId = String(selectedContact.value?.username || '').trim() + const messageId = String(message?.id || '').trim() + if (!message || !account || !sessionId || !messageId) return + + const ok = window.confirm('确认将该消息修复为“我发送”吗?这会修改 real_sender_id 字段。') + if (!ok) return + + try { + await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' }) + closeContextMenu() + await refreshSelectedMessages() + } catch (error) { + window.alert(error?.message || '修复失败') + } finally { + closeContextMenu() + } + } + + const onFlipWechatMessageDirectionClick = async () => { + if (!process.client) return + const message = contextMenu.value.message + const account = String(selectedAccount.value || '').trim() + const sessionId = String(selectedContact.value?.username || '').trim() + const messageId = String(message?.id || '').trim() + if (!message || !account || !sessionId || !messageId) return + + const ok = window.confirm( + '确认反转该消息在微信客户端的左右气泡位置吗?\n\n这会修改 packed_info_data 字段(有风险)。\n可通过“恢复原消息”撤销。' + ) + if (!ok) return + + try { + await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId }) + closeContextMenu() + await refreshSelectedMessages() + } catch (error) { + window.alert(error?.message || '反转失败') + } finally { + closeContextMenu() + } + } + + const onLocateQuotedMessageClick = async () => { + const message = contextMenu.value.message + if (!message?.quoteServerId) return + closeContextMenu() + const ok = await locateMessageByServerId(message.quoteServerId) + if (!ok && process.client) { + window.alert('定位引用消息失败') + } + } + + return { + contextMenu, + messageEditModal, + messageFieldsModal, + closeContextMenu, + openMediaContextMenu, + prettyJson, + isLikelyTextMessage, + closeMessageEditModal, + openMessageEditModal, + saveMessageEditModal, + closeMessageFieldsModal, + openMessageFieldsModal, + saveMessageFieldsModal, + copyTextToClipboard, + onCopyMessageTextClick, + onCopyMessageJsonClick, + onOpenFolderClick, + onEditMessageClick, + onEditMessageFieldsClick, + onResetEditedMessageClick, + onRepairMessageSenderAsMeClick, + onFlipWechatMessageDirectionClick, + onLocateQuotedMessageClick + } +} diff --git a/frontend/composables/chat/useChatExport.js b/frontend/composables/chat/useChatExport.js new file mode 100644 index 0000000..bad2c48 --- /dev/null +++ b/frontend/composables/chat/useChatExport.js @@ -0,0 +1,460 @@ +import { computed, ref, watch } from 'vue' +import { reportServerErrorFromResponse } from '~/lib/server-error-logging' +import { toUnixSeconds } from '~/lib/chat/formatters' + +export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selectedContact, privacyMode }) => { + const exportModalOpen = ref(false) + const isExportCreating = ref(false) + const exportError = ref('') + + const exportScope = ref('current') + const exportFormat = ref('json') + const exportDownloadRemoteMedia = ref(true) + const exportHtmlPageSize = ref(1000) + const exportMessageTypeOptions = [ + { value: 'text', label: '文本' }, + { value: 'image', label: '图片' }, + { value: 'emoji', label: '表情' }, + { value: 'video', label: '视频' }, + { value: 'voice', label: '语音' }, + { value: 'chatHistory', label: '聊天记录' }, + { value: 'transfer', label: '转账' }, + { value: 'redPacket', label: '红包' }, + { value: 'file', label: '文件' }, + { value: 'link', label: '链接' }, + { value: 'quote', label: '引用' }, + { value: 'system', label: '系统' }, + { value: 'voip', label: '通话' } + ] + const exportMessageTypes = ref(exportMessageTypeOptions.map((item) => item.value)) + + const exportStartLocal = ref('') + const exportEndLocal = ref('') + const exportFileName = ref('') + const exportFolder = ref('') + const exportFolderHandle = ref(null) + const exportSaveBusy = ref(false) + const exportSaveMsg = ref('') + const exportAutoSavedFor = ref('') + + const exportSearchQuery = ref('') + const exportListTab = ref('all') + const exportSelectedUsernames = ref([]) + + const exportJob = ref(null) + let exportPollTimer = null + let exportEventSource = null + + const clamp01 = (value) => Math.min(1, Math.max(0, value)) + const asNumber = (value) => { + const next = Number(value) + return Number.isFinite(next) ? next : 0 + } + + const exportOverallPercent = computed(() => { + const job = exportJob.value + const progress = job?.progress || {} + const total = asNumber(progress.conversationsTotal) + const done = asNumber(progress.conversationsDone) + if (total <= 0) return 0 + + const currentTotal = asNumber(progress.currentConversationMessagesTotal) + const currentDone = asNumber(progress.currentConversationMessagesExported) + const currentFraction = currentTotal > 0 ? clamp01(currentDone / currentTotal) : 0 + const overall = clamp01((done + (job?.status === 'running' ? currentFraction : 0)) / total) + return Math.round(overall * 100) + }) + + const exportCurrentPercent = computed(() => { + const progress = exportJob.value?.progress || {} + const total = asNumber(progress.currentConversationMessagesTotal) + const done = asNumber(progress.currentConversationMessagesExported) + if (total <= 0) return null + return Math.round(clamp01(done / total) * 100) + }) + + const exportFilteredContacts = computed(() => { + const query = String(exportSearchQuery.value || '').trim().toLowerCase() + let list = Array.isArray(contacts.value) ? contacts.value : [] + + const tab = String(exportListTab.value || 'all') + if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup) + if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup) + + if (!query) return list + return list.filter((contact) => { + const name = String(contact?.name || '').toLowerCase() + const username = String(contact?.username || '').toLowerCase() + return name.includes(query) || username.includes(query) + }) + }) + + const exportContactCounts = computed(() => { + const list = Array.isArray(contacts.value) ? contacts.value : [] + const total = list.length + const groups = list.filter((contact) => !!contact?.isGroup).length + return { total, groups, singles: total - groups } + }) + + const isDesktopExportRuntime = () => { + return !!(process.client && window?.wechatDesktop?.chooseDirectory) + } + + const isWebDirectoryPickerSupported = () => { + return !!(process.client && typeof window.showDirectoryPicker === 'function') + } + + const hasWebExportFolder = computed(() => { + return !!(isWebDirectoryPickerSupported() && exportFolderHandle.value) + }) + + const chooseExportFolder = async () => { + exportError.value = '' + exportSaveMsg.value = '' + try { + if (!process.client) { + exportError.value = '当前环境不支持选择导出目录' + return + } + + if (isDesktopExportRuntime()) { + const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' }) + if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) { + exportFolder.value = String(result.filePaths[0] || '').trim() + exportFolderHandle.value = null + } + return + } + + if (isWebDirectoryPickerSupported()) { + const handle = await window.showDirectoryPicker() + if (handle) { + exportFolderHandle.value = handle + exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}` + } + return + } + + exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器' + } catch (error) { + exportError.value = error?.message || '选择导出目录失败' + } + } + + const guessExportZipName = (job) => { + const raw = String(job?.zipPath || '').trim() + if (raw) { + const name = raw.replace(/\\/g, '/').split('/').pop() + if (name && name.toLowerCase().endsWith('.zip')) return name + } + const exportId = String(job?.exportId || '').trim() || 'export' + return `wechat_chat_export_${exportId}.zip` + } + + const getExportDownloadUrl = (exportId) => { + return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download` + } + + const saveExportToSelectedFolder = async (options = {}) => { + const autoSave = !!options?.auto + exportError.value = '' + exportSaveMsg.value = '' + if (!process.client || !isWebDirectoryPickerSupported()) { + exportError.value = '当前环境不支持保存到浏览器目录' + return + } + const handle = exportFolderHandle.value + if (!handle || typeof handle.getFileHandle !== 'function') { + exportError.value = '请先选择浏览器导出目录' + return + } + + const exportId = exportJob.value?.exportId + if (!exportId || String(exportJob.value?.status || '') !== 'done') { + exportError.value = '导出任务尚未完成' + return + } + + exportSaveBusy.value = true + try { + const response = await fetch(getExportDownloadUrl(exportId)) + if (!response.ok) { + await reportServerErrorFromResponse(response, { + method: 'GET', + requestUrl: getExportDownloadUrl(exportId), + message: `下载导出文件失败(${response.status})`, + source: 'chat.exportDownload' + }) + throw new Error(`下载导出文件失败(${response.status})`) + } + const blob = await response.blob() + const fileName = guessExportZipName(exportJob.value) + const fileHandle = await handle.getFileHandle(fileName, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(blob) + await writable.close() + exportAutoSavedFor.value = String(exportId) + exportSaveMsg.value = autoSave + ? `已自动保存到已选目录:${fileName}` + : `已保存到已选目录:${fileName}` + } catch (error) { + exportError.value = error?.message || '保存到浏览器目录失败' + } finally { + exportSaveBusy.value = false + } + } + + const stopExportPolling = () => { + if (exportEventSource) { + try { + exportEventSource.close() + } catch {} + exportEventSource = null + } + if (exportPollTimer) { + clearInterval(exportPollTimer) + exportPollTimer = null + } + } + + const startExportHttpPolling = (exportId) => { + if (!exportId) return + exportPollTimer = setInterval(async () => { + try { + const response = await api.getChatExport(exportId) + exportJob.value = response?.job || exportJob.value + const status = String(exportJob.value?.status || '') + if (status === 'done' || status === 'error' || status === 'cancelled') { + stopExportPolling() + } + } catch {} + }, 1200) + } + + const startExportPolling = (exportId) => { + stopExportPolling() + if (!exportId) return + + if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') { + const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events` + try { + exportEventSource = new EventSource(url) + exportEventSource.onmessage = (event) => { + try { + const next = JSON.parse(String(event.data || '{}')) + exportJob.value = next || exportJob.value + const status = String(exportJob.value?.status || '') + if (status === 'done' || status === 'error' || status === 'cancelled') { + stopExportPolling() + } + } catch {} + } + exportEventSource.onerror = () => { + try { + exportEventSource?.close() + } catch {} + exportEventSource = null + if (!exportPollTimer) startExportHttpPolling(exportId) + } + return + } catch { + exportEventSource = null + } + } + + startExportHttpPolling(exportId) + } + + const openExportModal = () => { + exportModalOpen.value = true + exportError.value = '' + exportSaveMsg.value = '' + exportListTab.value = 'all' + exportStartLocal.value = '' + exportEndLocal.value = '' + exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value) + exportAutoSavedFor.value = '' + exportScope.value = selectedContact.value?.username ? 'current' : 'all' + } + + const closeExportModal = () => { + exportModalOpen.value = false + exportError.value = '' + } + + watch(exportModalOpen, (open) => { + if (!process.client) return + if (!open) { + stopExportPolling() + return + } + + const exportId = exportJob.value?.exportId + const status = String(exportJob.value?.status || '') + if (exportId && (status === 'queued' || status === 'running')) { + startExportPolling(exportId) + } + }) + + watch( + () => ({ + exportId: String(exportJob.value?.exportId || ''), + status: String(exportJob.value?.status || '') + }), + async ({ exportId, status }) => { + if (!process.client || status !== 'done' || !exportId) return + if (!hasWebExportFolder.value) return + if (exportAutoSavedFor.value === exportId) return + if (exportSaveBusy.value) return + await saveExportToSelectedFolder({ auto: true }) + } + ) + + const startChatExport = async () => { + exportError.value = '' + exportSaveMsg.value = '' + if (!selectedAccount.value) { + exportError.value = '未选择账号' + return + } + + let scope = exportScope.value + let usernames = [] + if (scope === 'current') { + scope = 'selected' + if (selectedContact.value?.username) { + usernames = [selectedContact.value.username] + } + } else if (scope === 'selected') { + usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : [] + } + + if (scope === 'selected' && (!usernames || usernames.length === 0)) { + exportError.value = '请选择至少一个会话' + return + } + + const hasDesktopFolder = isDesktopExportRuntime() && !!String(exportFolder.value || '').trim() + const hasWebFolder = !isDesktopExportRuntime() && !!exportFolderHandle.value + if (!hasDesktopFolder && !hasWebFolder) { + exportError.value = '请先选择导出目录' + return + } + + const startTime = toUnixSeconds(exportStartLocal.value) + const endTime = toUnixSeconds(exportEndLocal.value) + if (startTime && endTime && startTime > endTime) { + exportError.value = '时间范围不合法:开始时间不能晚于结束时间' + return + } + + const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : [] + if (messageTypes.length === 0) { + exportError.value = '请至少勾选一个消息类型' + return + } + + const selectedTypeSet = new Set(messageTypes.map((item) => String(item || '').trim())) + const mediaKindSet = new Set() + if (selectedTypeSet.has('chatHistory')) { + mediaKindSet.add('image') + mediaKindSet.add('emoji') + mediaKindSet.add('video') + mediaKindSet.add('video_thumb') + mediaKindSet.add('voice') + mediaKindSet.add('file') + } + if (selectedTypeSet.has('image')) mediaKindSet.add('image') + if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji') + if (selectedTypeSet.has('video')) { + mediaKindSet.add('video') + mediaKindSet.add('video_thumb') + } + if (selectedTypeSet.has('voice')) mediaKindSet.add('voice') + if (selectedTypeSet.has('file')) mediaKindSet.add('file') + + const mediaKinds = Array.from(mediaKindSet) + const includeMedia = !privacyMode.value && mediaKinds.length > 0 + + isExportCreating.value = true + exportAutoSavedFor.value = '' + try { + const response = await api.createChatExport({ + account: selectedAccount.value, + scope, + usernames, + format: exportFormat.value, + start_time: startTime, + end_time: endTime, + include_hidden: false, + include_official: false, + message_types: messageTypes, + include_media: includeMedia, + media_kinds: mediaKinds, + download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value, + html_page_size: Math.max(0, Math.floor(Number(exportHtmlPageSize.value || 1000))), + output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null, + privacy_mode: !!privacyMode.value, + file_name: exportFileName.value || null + }) + + exportJob.value = response?.job || null + const exportId = exportJob.value?.exportId + if (exportId) startExportPolling(exportId) + } catch (error) { + exportError.value = error?.message || '创建导出任务失败' + } finally { + isExportCreating.value = false + } + } + + const cancelCurrentExport = async () => { + const exportId = exportJob.value?.exportId + if (!exportId) return + + try { + await api.cancelChatExport(exportId) + const response = await api.getChatExport(exportId) + exportJob.value = response?.job || exportJob.value + } catch (error) { + exportError.value = error?.message || '取消导出失败' + } + } + + return { + exportModalOpen, + isExportCreating, + exportError, + exportScope, + exportFormat, + exportDownloadRemoteMedia, + exportHtmlPageSize, + exportMessageTypeOptions, + exportMessageTypes, + exportStartLocal, + exportEndLocal, + exportFileName, + exportFolder, + exportFolderHandle, + exportSaveBusy, + exportSaveMsg, + exportAutoSavedFor, + exportSearchQuery, + exportListTab, + exportSelectedUsernames, + exportJob, + exportOverallPercent, + exportCurrentPercent, + exportFilteredContacts, + exportContactCounts, + hasWebExportFolder, + chooseExportFolder, + getExportDownloadUrl, + saveExportToSelectedFolder, + openExportModal, + closeExportModal, + startChatExport, + cancelCurrentExport, + stopExportPolling + } +} diff --git a/frontend/composables/chat/useChatHistoryWindows.js b/frontend/composables/chat/useChatHistoryWindows.js new file mode 100644 index 0000000..550a32d --- /dev/null +++ b/frontend/composables/chat/useChatHistoryWindows.js @@ -0,0 +1,488 @@ +import { ref } from 'vue' +import { + buildChatHistoryWindowPayload, + createChatHistoryRecordNormalizer, + enhanceChatHistoryRecords, + formatChatHistoryVideoDuration, + getChatHistoryPreviewLines, + isChatHistoryRecordItemIncomplete, + normalizeChatHistoryUrl, + parseChatHistoryRecord, + pickFirstMd5, + stripWeChatInvisible +} from '~/lib/chat/chat-history' + +export const useChatHistoryWindows = ({ + api, + apiBase, + selectedAccount, + selectedContact, + openImagePreview, + openVideoPreview +}) => { + const floatingWindows = ref([]) + let floatingWindowSeq = 0 + let floatingWindowZ = 70 + const floatingDragState = { id: '', offsetX: 0, offsetY: 0 } + + const clampNumber = (value, min, max) => Math.min(max, Math.max(min, value)) + const normalizeRecordItem = createChatHistoryRecordNormalizer({ + apiBase, + getSelectedAccount: () => selectedAccount.value, + getSelectedContact: () => selectedContact.value + }) + + const getFloatingWindowById = (id) => { + const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : [] + return list.find((item) => String(item?.id || '') === String(id || '')) || null + } + + const focusFloatingWindow = (id) => { + const windowItem = getFloatingWindowById(id) + if (!windowItem) return + floatingWindowZ += 1 + windowItem.zIndex = floatingWindowZ + } + + const closeFloatingWindow = (id) => { + const key = String(id || '') + floatingWindows.value = (Array.isArray(floatingWindows.value) ? floatingWindows.value : []).filter((item) => String(item?.id || '') !== key) + if (floatingDragState.id && String(floatingDragState.id) === key) { + floatingDragState.id = '' + } + } + + const closeTopFloatingWindow = () => { + const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : [] + if (!list.length) return + const top = [...list].sort((a, b) => Number(b?.zIndex || 0) - Number(a?.zIndex || 0))[0] + if (top?.id) closeFloatingWindow(top.id) + } + + const openFloatingWindow = (payload) => { + if (!process.client || typeof window === 'undefined') return null + floatingWindowSeq += 1 + floatingWindowZ += 1 + const width = clampNumber(Number(payload?.width || 520), 360, Math.max(360, (window.innerWidth || 1200) - 48)) + const height = clampNumber(Number(payload?.height || 420), 320, Math.max(320, (window.innerHeight || 900) - 48)) + const x = clampNumber(Number(payload?.x || Math.round(((window.innerWidth || width) - width) / 2)), 16, Math.max(16, (window.innerWidth || width) - width - 16)) + const y = clampNumber(Number(payload?.y || Math.round(((window.innerHeight || height) - height) / 2)), 16, Math.max(16, (window.innerHeight || height) - height - 16)) + + const windowItem = { + id: `chat-floating-${floatingWindowSeq}`, + kind: String(payload?.kind || 'chatHistory'), + title: String(payload?.title || ''), + info: payload?.info || { isChatRoom: false }, + records: Array.isArray(payload?.records) ? payload.records : [], + url: String(payload?.url || ''), + content: String(payload?.content || ''), + preview: String(payload?.preview || ''), + from: String(payload?.from || ''), + fromAvatar: String(payload?.fromAvatar || ''), + loading: !!payload?.loading, + width, + height, + x, + y, + zIndex: floatingWindowZ + } + floatingWindows.value = [...floatingWindows.value, windowItem] + return windowItem + } + + const startFloatingWindowDrag = (id, event) => { + if (!process.client) return + const windowItem = getFloatingWindowById(id) + if (!windowItem) return + focusFloatingWindow(id) + const point = 'touches' in event ? event.touches?.[0] : event + floatingDragState.id = id + floatingDragState.offsetX = Number(point?.clientX || 0) - Number(windowItem.x || 0) + floatingDragState.offsetY = Number(point?.clientY || 0) - Number(windowItem.y || 0) + } + + const onFloatingWindowMouseMove = (event) => { + if (!process.client) return + if (!floatingDragState.id) return + const windowItem = getFloatingWindowById(floatingDragState.id) + if (!windowItem) return + const point = 'touches' in event ? event.touches?.[0] : event + const nextX = Number(point?.clientX || 0) - floatingDragState.offsetX + const nextY = Number(point?.clientY || 0) - floatingDragState.offsetY + windowItem.x = clampNumber(nextX, 8, Math.max(8, (window.innerWidth || nextX) - windowItem.width - 8)) + windowItem.y = clampNumber(nextY, 8, Math.max(8, (window.innerHeight || nextY) - windowItem.height - 8)) + } + + const onFloatingWindowMouseUp = () => { + floatingDragState.id = '' + } + + const chatHistoryModalVisible = ref(false) + const chatHistoryModalTitle = ref('') + const chatHistoryModalRecords = ref([]) + const chatHistoryModalInfo = ref({ isChatRoom: false }) + const chatHistoryModalStack = ref([]) + const goBackChatHistoryModal = () => {} + const closeChatHistoryModal = () => { + chatHistoryModalVisible.value = false + chatHistoryModalTitle.value = '' + chatHistoryModalRecords.value = [] + chatHistoryModalInfo.value = { isChatRoom: false } + chatHistoryModalStack.value = [] + } + + const onChatHistoryVideoThumbError = (record) => { + if (!record) return + const candidates = record._videoThumbCandidates + if (!Array.isArray(candidates) || candidates.length <= 1) { + record._videoThumbError = true + return + } + const current = Math.max(0, Number(record._videoThumbCandidateIndex || 0)) + const next = current + 1 + if (next < candidates.length) { + record._videoThumbCandidateIndex = next + record.videoThumbUrl = candidates[next] + return + } + record._videoThumbError = true + } + + const onChatHistoryLinkPreviewError = (record) => { + if (!record) return + const candidates = record._linkPreviewCandidates + if (!Array.isArray(candidates) || candidates.length <= 1) { + record._linkPreviewError = true + return + } + const current = Math.max(0, Number(record._linkPreviewCandidateIndex || 0)) + const next = current + 1 + if (next < candidates.length) { + record._linkPreviewCandidateIndex = next + record.preview = candidates[next] + record._linkPreviewError = false + return + } + record._linkPreviewError = true + } + + const onChatHistoryFromAvatarLoad = (record) => { + try { + if (record) { + record._fromAvatarImgOk = true + record._fromAvatarImgError = false + record._fromAvatarLast = String(record.fromAvatar || '').trim() + } + } catch {} + } + + const onChatHistoryFromAvatarError = (record) => { + try { + if (record) { + record._fromAvatarImgOk = false + record._fromAvatarImgError = true + record._fromAvatarLast = String(record.fromAvatar || '').trim() + } + } catch {} + } + + const onChatHistoryQuoteThumbError = (record) => { + if (!record || !record.quote) return + const candidates = record._quoteThumbCandidates + if (!Array.isArray(candidates) || candidates.length <= 1) { + record._quoteThumbError = true + return + } + const current = Math.max(0, Number(record._quoteThumbCandidateIndex || 0)) + const next = current + 1 + if (next < candidates.length) { + record._quoteThumbCandidateIndex = next + record.quote.thumbUrl = candidates[next] + return + } + record._quoteThumbError = true + } + + const openChatHistoryQuote = (record) => { + if (!process.client) return + const quote = record?.quote + if (!quote) return + const kind = String(quote.kind || '') + const url = String(quote.url || '').trim() + if (!url) return + + if (kind === 'video') { + openVideoPreview(url, quote?.thumbUrl) + return + } + if (kind === 'image' || kind === 'emoji') { + openImagePreview(url) + } + } + + const getChatHistoryLinkFromText = (record) => { + const from = String(record?.from || '').trim() + if (from) return from + const url = String(record?.url || '').trim() + if (!url) return '' + try { return new URL(url).hostname || '' } catch { return '' } + } + + const getChatHistoryLinkFromAvatarText = (record) => { + const text = String(getChatHistoryLinkFromText(record) || '').trim() + return text ? (Array.from(text)[0] || '') : '' + } + + const openUrlInBrowser = (url) => { + const next = String(url || '').trim() + if (!next) return + try { window.open(next, '_blank', 'noopener,noreferrer') } catch {} + } + + const resolveChatHistoryLinkRecord = async (record) => { + if (!process.client || !record || !selectedAccount.value) return null + const serverId = String(record?.fromnewmsgid || '').trim() + if (!serverId || record._linkResolving) return null + + record._linkResolving = true + try { + const response = await api.resolveAppMsg({ + account: selectedAccount.value, + server_id: serverId + }) + if (response && typeof response === 'object') { + const title = String(response.title || '').trim() + const content = String(response.content || '').trim() + const url = String(response.url || '').trim() + const from = String(response.from || '').trim() + + const normalizePreviewUrl = (value) => { + const raw = String(value || '').trim() + if (!raw) return '' + if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw + if (!/^https?:\/\//i.test(raw)) return '' + try { + const host = new URL(raw).hostname.toLowerCase() + if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) { + return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}` + } + } catch {} + return raw + } + + if (title) record.title = title + if (content && !stripWeChatInvisible(record.content)) record.content = content + if (url) record.url = url + if (from) record.from = from + if (response.linkStyle) record.linkStyle = String(response.linkStyle || '').trim() + if (response.linkType) record.linkType = String(response.linkType || '').trim() + + const fromUsername = String(response.fromUsername || '').trim() + if (fromUsername) record.fromUsername = fromUsername + const fromAvatarUrl = fromUsername + ? `${apiBase}/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}` + : (url ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(url)}` : '') + if (fromAvatarUrl) { + const last = String(record._fromAvatarLast || '').trim() + record.fromAvatar = fromAvatarUrl + if (String(fromAvatarUrl).trim() !== last) { + record._fromAvatarLast = String(fromAvatarUrl).trim() + record._fromAvatarImgOk = false + record._fromAvatarImgError = false + } + } + + const style = String(response.linkStyle || '').trim() + const thumb = String(response.thumbUrl || '').trim() + const cover = String(response.coverUrl || '').trim() + const picked = style === 'cover' ? (cover || thumb) : (thumb || cover) + const previewResolved = normalizePreviewUrl(picked) + if (previewResolved) { + const currentPreview = String(record.preview || '').trim() + const candidates = Array.isArray(record._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : [] + if (currentPreview && !candidates.includes(currentPreview)) candidates.push(currentPreview) + if (!candidates.includes(previewResolved)) candidates.push(previewResolved) + record._linkPreviewCandidates = candidates + if (!currentPreview || record._linkPreviewError) { + record.preview = previewResolved + record._linkPreviewCandidateIndex = candidates.indexOf(previewResolved) + record._linkPreviewError = false + } + } + return response + } + } catch {} + finally { + try { record._linkResolving = false } catch {} + } + return null + } + + const resolveChatHistoryLinkRecords = (windowItem) => { + if (!process.client) return + const records = Array.isArray(windowItem?.records) ? windowItem.records : [] + const targets = records.filter((record) => { + if (!record) return false + if (String(record.renderType || '') !== 'link') return false + if (!String(record.fromnewmsgid || '').trim()) return false + const fromMissing = String(record.from || '').trim() === '' + const previewMissing = !String(record.preview || '').trim() + const urlMissing = !String(record.url || '').trim() + const fromAvatarMissing = !String(record.fromAvatar || '').trim() + return fromMissing || previewMissing || urlMissing || fromAvatarMissing + }) + if (!targets.length) return + ;(async () => { + for (const target of targets.slice(0, 12)) { + await resolveChatHistoryLinkRecord(target) + } + })() + } + + const openChatHistoryLinkWindow = (record) => { + if (!process.client) return + const title = String(record?.title || record?.content || '链接').trim() + const url = String(record?.url || '').trim() + const preview = String(record?.preview || '').trim() + const from = String(record?.from || '').trim() + const fromAvatar = String(record?.fromAvatar || '').trim() + const needResolve = !!String(record?.fromnewmsgid || '').trim() && (!url || !from || !preview || !fromAvatar) + const windowItem = openFloatingWindow({ + kind: 'link', + title: title || '链接', + url, + content: String(record?.content || '').trim(), + preview, + from, + fromAvatar, + width: 520, + height: 420, + loading: needResolve + }) + if (!windowItem) return + focusFloatingWindow(windowItem.id) + try { + windowItem._linkPreviewCandidates = Array.isArray(record?._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : (preview ? [preview] : []) + windowItem._linkPreviewCandidateIndex = Math.max(0, Number(record?._linkPreviewCandidateIndex || 0)) + windowItem._linkPreviewError = false + windowItem._fromAvatarLast = fromAvatar + windowItem._fromAvatarImgOk = !!record?._fromAvatarImgOk + windowItem._fromAvatarImgError = !!record?._fromAvatarImgError + windowItem.fromnewmsgid = String(record?.fromnewmsgid || '').trim() + } catch {} + if (needResolve) { + ;(async () => { + await resolveChatHistoryLinkRecord(windowItem) + windowItem.loading = false + })() + } + } + + const openChatHistoryModal = (message) => { + if (!process.client) return + const { title0, info0, records0 } = buildChatHistoryWindowPayload(message, normalizeRecordItem) + const windowItem = openFloatingWindow({ + kind: 'chatHistory', + title: title0 || '聊天记录', + info: info0, + records: records0, + width: 560, + height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)) + }) + if (!windowItem) return + try { resolveChatHistoryLinkRecords(windowItem) } catch {} + } + + const openNestedChatHistory = (record) => { + if (!process.client) return + const title = String(record?.title || '聊天记录') + const content = String(record?.content || '') + const recordItem = String(record?.recordItem || '').trim() + const serverId = String(record?.fromnewmsgid || '').trim() + + const { info0, records0 } = buildChatHistoryWindowPayload({ title, content, recordItem }, normalizeRecordItem) + const windowItem = openFloatingWindow({ + kind: 'chatHistory', + title: title || '聊天记录', + info: info0, + records: records0, + width: 560, + height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)), + loading: false + }) + if (!windowItem) return + try { resolveChatHistoryLinkRecords(windowItem) } catch {} + + if (!serverId || !selectedAccount.value || record?._nestedResolving || !isChatHistoryRecordItemIncomplete(recordItem)) return + record._nestedResolving = true + windowItem.loading = true + + ;(async () => { + try { + const response = await api.resolveNestedChatHistory({ + account: selectedAccount.value, + server_id: serverId + }) + const resolved = String(response?.recordItem || '').trim() + if (!resolved) return + windowItem.title = String(response?.title || title || '聊天记录') + const parsed = parseChatHistoryRecord(resolved) + windowItem.info = parsed?.info || { isChatRoom: false, count: 0 } + const items = Array.isArray(parsed?.items) ? parsed.items : [] + windowItem.records = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : [] + if (!windowItem.records.length) { + const lines = String(response?.content || content || '').trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean) + windowItem.info = { isChatRoom: false, count: 0 } + windowItem.records = lines.map((line, idx) => normalizeRecordItem({ + id: String(idx), + datatype: '1', + sourcename: '', + sourcetime: '', + content: line, + renderType: 'text' + })) + } + try { resolveChatHistoryLinkRecords(windowItem) } catch {} + } catch {} + finally { + windowItem.loading = false + try { record._nestedResolving = false } catch {} + } + })() + } + + return { + floatingWindows, + chatHistoryModalVisible, + chatHistoryModalTitle, + chatHistoryModalRecords, + chatHistoryModalInfo, + chatHistoryModalStack, + goBackChatHistoryModal, + closeChatHistoryModal, + getFloatingWindowById, + focusFloatingWindow, + closeFloatingWindow, + closeTopFloatingWindow, + openFloatingWindow, + startFloatingWindowDrag, + onFloatingWindowMouseMove, + onFloatingWindowMouseUp, + formatChatHistoryVideoDuration, + getChatHistoryPreviewLines, + onChatHistoryVideoThumbError, + onChatHistoryLinkPreviewError, + onChatHistoryFromAvatarLoad, + onChatHistoryFromAvatarError, + onChatHistoryQuoteThumbError, + openChatHistoryQuote, + getChatHistoryLinkFromText, + getChatHistoryLinkFromAvatarText, + openUrlInBrowser, + resolveChatHistoryLinkRecord, + resolveChatHistoryLinkRecords, + openChatHistoryLinkWindow, + openChatHistoryModal, + openNestedChatHistory + } +} diff --git a/frontend/composables/chat/useChatMessages.js b/frontend/composables/chat/useChatMessages.js new file mode 100644 index 0000000..a491578 --- /dev/null +++ b/frontend/composables/chat/useChatMessages.js @@ -0,0 +1,786 @@ +import { computed, nextTick, onUnmounted, ref, watch } from 'vue' +import { + formatFileSize, + formatTimeDivider, + getVoiceDurationInSeconds, + getVoiceWidth +} from '~/lib/chat/formatters' +import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer' + +export const useChatMessages = ({ + api, + apiBase, + selectedAccount, + selectedContact, + realtimeStore, + realtimeEnabled, + desktopAutoRealtime, + privacyMode, + searchContext +}) => { + const messagePageSize = 50 + + const allMessages = ref({}) + const messagesMeta = ref({}) + const isLoadingMessages = ref(false) + const messagesError = ref('') + const messageContainerRef = ref(null) + const activeMessagesFor = ref('') + const showJumpToBottom = ref(false) + + const previewImageUrl = ref(null) + const previewVideoUrl = ref(null) + const previewVideoPosterUrl = ref('') + const previewVideoError = ref('') + + const voiceRefs = ref({}) + const currentPlayingVoice = ref(null) + const playingVoiceId = ref(null) + + const highlightServerIdStr = ref('') + const highlightMessageId = ref('') + let highlightTimer = null + + const messageTypeFilter = ref('all') + const messageTypeFilterOptions = [ + { value: 'all', label: '全部' }, + { value: 'text', label: '文本' }, + { value: 'image', label: '图片' }, + { value: 'emoji', label: '表情' }, + { value: 'video', label: '视频' }, + { value: 'voice', label: '语音' }, + { value: 'file', label: '文件' }, + { value: 'link', label: '链接' }, + { value: 'quote', label: '引用' }, + { value: 'chatHistory', label: '聊天记录' }, + { value: 'transfer', label: '转账' }, + { value: 'redPacket', label: '红包' }, + { value: 'location', label: '位置' }, + { value: 'voip', label: '通话' }, + { value: 'system', label: '系统' } + ] + + const normalizeMessage = createMessageNormalizer({ + apiBase, + getSelectedAccount: () => selectedAccount.value, + getSelectedContact: () => selectedContact.value + }) + + const messages = computed(() => { + if (!selectedContact.value) return [] + return allMessages.value[selectedContact.value.username] || [] + }) + + const hasMoreMessages = computed(() => { + if (!selectedContact.value) return false + const key = selectedContact.value.username + const meta = messagesMeta.value[key] + if (!meta) return false + if (meta.hasMore != null) return !!meta.hasMore + const total = Number(meta.total || 0) + const loaded = messages.value.length + return total > loaded + }) + + const reverseMessageSides = ref(false) + const reverseSidesStorageKey = computed(() => { + const account = String(selectedAccount.value || '').trim() + const username = String(selectedContact.value?.username || '').trim() + if (account && username) return `wechatda:reverse_message_sides:${account}:${username}` + return 'wechatda:reverse_message_sides:global' + }) + + const loadReverseMessageSides = () => { + if (!process.client) return + try { + const value = localStorage.getItem(reverseSidesStorageKey.value) + reverseMessageSides.value = value === '1' + } catch {} + } + + watch(reverseSidesStorageKey, () => loadReverseMessageSides(), { immediate: true }) + watch(reverseMessageSides, (value) => { + if (!process.client) return + try { + localStorage.setItem(reverseSidesStorageKey.value, value ? '1' : '0') + } catch {} + }) + + const toggleReverseMessageSides = () => { + reverseMessageSides.value = !reverseMessageSides.value + } + + const renderMessages = computed(() => { + const list = messages.value || [] + const reverseSides = !!reverseMessageSides.value + let previousTs = 0 + return list.map((message) => { + const ts = Number(message.createTime || 0) + const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300) + if (ts) previousTs = ts + const originalIsSent = !!message?.isSent + return { + ...message, + _originalIsSent: originalIsSent, + isSent: reverseSides ? !originalIsSent : originalIsSent, + showTimeDivider: !!show, + timeDivider: formatTimeDivider(ts) + } + }) + }) + + const updateJumpToBottomState = () => { + const container = messageContainerRef.value + if (!container) { + showJumpToBottom.value = false + return + } + const distance = container.scrollHeight - container.scrollTop - container.clientHeight + showJumpToBottom.value = distance > 160 + } + + const scrollToBottom = () => { + const container = messageContainerRef.value + if (!container) return + container.scrollTop = container.scrollHeight + updateJumpToBottomState() + } + + const flashMessage = (id) => { + highlightMessageId.value = String(id || '').trim() + if (highlightTimer) clearTimeout(highlightTimer) + highlightTimer = setTimeout(() => { + highlightMessageId.value = '' + highlightServerIdStr.value = '' + highlightTimer = null + }, 2200) + } + + const scrollToMessageId = async (id) => { + const target = String(id || '').trim() + if (!target) return false + await nextTick() + const container = messageContainerRef.value + const element = container?.querySelector?.(`[data-msg-id="${CSS.escape(target)}"]`) + if (!element || typeof element.scrollIntoView !== 'function') return false + element.scrollIntoView({ block: 'center', behavior: 'smooth' }) + return true + } + + const openImagePreview = (url) => { + previewImageUrl.value = String(url || '').trim() || null + } + + const closeImagePreview = () => { + previewImageUrl.value = null + } + + const openVideoPreview = (url, poster) => { + previewVideoUrl.value = String(url || '').trim() || null + previewVideoPosterUrl.value = String(poster || '').trim() + previewVideoError.value = '' + } + + const closeVideoPreview = () => { + previewVideoUrl.value = null + previewVideoPosterUrl.value = '' + previewVideoError.value = '' + } + + const onPreviewVideoError = () => { + previewVideoError.value = '视频加载失败,可能是资源不存在或无法访问。' + } + + const setVoiceRef = (id, element) => { + const key = String(id || '').trim() + if (!key) return + if (element) { + voiceRefs.value = { ...voiceRefs.value, [key]: element } + } else if (voiceRefs.value[key]) { + const next = { ...voiceRefs.value } + delete next[key] + voiceRefs.value = next + } + } + + const playVoiceById = async (voiceId) => { + const key = String(voiceId || '').trim() + if (!key) return + const audio = voiceRefs.value[key] + if (!audio) return + + try { + if (currentPlayingVoice.value && currentPlayingVoice.value !== audio) { + currentPlayingVoice.value.pause() + currentPlayingVoice.value.currentTime = 0 + } + } catch {} + + if (currentPlayingVoice.value === audio && !audio.paused) { + try { + audio.pause() + audio.currentTime = 0 + } catch {} + currentPlayingVoice.value = null + playingVoiceId.value = null + return + } + + try { + await audio.play() + currentPlayingVoice.value = audio + playingVoiceId.value = key + audio.onended = () => { + if (playingVoiceId.value === key) { + currentPlayingVoice.value = null + playingVoiceId.value = null + } + } + } catch {} + } + + const playVoice = async (message) => { + await playVoiceById(message?.id) + } + + const getQuoteVoiceId = (message) => `quote-${String(message?.quoteServerId || message?.id || '')}` + + const playQuoteVoice = async (message) => { + await playVoiceById(getQuoteVoiceId(message)) + } + + const isQuotedVoice = (message) => String(message?.quoteType || '').trim() === '34' + const isQuotedImage = (message) => { + return !!String(message?.quoteImageUrl || '').trim() || String(message?.quoteContent || '').trim() === '[图片]' + } + const isQuotedLink = (message) => { + return String(message?.quoteType || '').trim() === '5' || !!String(message?.quoteThumbUrl || '').trim() + } + const getQuotedLinkText = (message) => { + const title = String(message?.quoteTitle || '').trim() + const content = String(message?.quoteContent || '').trim() + return content || title || '' + } + + const onQuoteImageError = (message) => { + if (message) message._quoteImageError = true + } + + const onQuoteThumbError = (message) => { + if (message) message._quoteThumbError = true + } + + const onAvatarError = (event, target) => { + try { event?.target && (event.target.style.display = 'none') } catch {} + try { if (target) target.avatar = null } catch {} + } + + const shouldShowEmojiDownload = (message) => { + if (!message?.emojiMd5) return false + const url = String(message?.emojiRemoteUrl || '').trim() + if (!url) return false + if (!/^https?:\/\//i.test(url)) return false + return true + } + + const onEmojiDownloadClick = async (message) => { + if (!process.client) return + if (!message?.emojiMd5) return + if (!selectedAccount.value) return + + const emojiUrl = String(message?.emojiRemoteUrl || '').trim() + if (!emojiUrl) { + window.alert('该表情没有可用的下载地址') + return + } + if (message._emojiDownloading) return + + message._emojiDownloading = true + try { + await api.downloadChatEmoji({ + account: selectedAccount.value, + md5: message.emojiMd5, + emoji_url: emojiUrl, + force: false + }) + message._emojiDownloaded = true + if (message.emojiLocalUrl) { + message.emojiUrl = message.emojiLocalUrl + } + } catch (error) { + window.alert(error?.message || '下载失败') + } finally { + message._emojiDownloading = false + } + } + + const onFileClick = async (message) => { + if (!message?.fileMd5) return + try { + if (!selectedAccount.value) return + if (!selectedContact.value?.username) return + await api.openChatMediaFolder({ + account: selectedAccount.value, + username: selectedContact.value.username, + kind: 'file', + md5: message.fileMd5 + }) + } catch (error) { + console.error('打开文件夹失败:', error) + } + } + + const loadMessages = async ({ username, reset }) => { + if (!username || !selectedAccount.value) return + + messagesError.value = '' + isLoadingMessages.value = true + activeMessagesFor.value = username + + try { + const existing = allMessages.value[username] || [] + const container = messageContainerRef.value + const beforeScrollHeight = container ? container.scrollHeight : 0 + const beforeScrollTop = container ? container.scrollTop : 0 + const offset = reset ? 0 : existing.length + + const params = { + account: selectedAccount.value, + username, + limit: messagePageSize, + offset, + order: 'asc' + } + if (messageTypeFilter.value && messageTypeFilter.value !== 'all') { + params.render_types = messageTypeFilter.value + } + if (realtimeEnabled.value) { + params.source = 'realtime' + } + const response = await api.listChatMessages(params) + + const raw = response?.messages || [] + const mapped = dedupeMessagesById(raw.map(normalizeMessage)) + + if (activeMessagesFor.value !== username) return + + if (reset) { + allMessages.value = { ...allMessages.value, [username]: mapped } + } else { + const existingIds = new Set(existing.map((message) => String(message?.id || ''))) + const older = mapped.filter((message) => { + const id = String(message?.id || '') + if (!id) return true + if (existingIds.has(id)) return false + existingIds.add(id) + return true + }) + allMessages.value = { + ...allMessages.value, + [username]: [...older, ...existing] + } + } + + messagesMeta.value = { + ...messagesMeta.value, + [username]: { + total: Number(response?.total || 0), + hasMore: response?.hasMore + } + } + + await nextTick() + const nextContainer = messageContainerRef.value + if (nextContainer) { + if (reset) { + nextContainer.scrollTop = nextContainer.scrollHeight + } else { + const afterScrollHeight = nextContainer.scrollHeight + nextContainer.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight) + } + } + updateJumpToBottomState() + } catch (error) { + messagesError.value = error?.message || '加载聊天记录失败' + } finally { + isLoadingMessages.value = false + } + } + + const loadMoreMessages = async () => { + if (!selectedContact.value) return + if (searchContext.value?.active) return + await loadMessages({ username: selectedContact.value.username, reset: false }) + } + + const refreshSelectedMessages = async () => { + if (!selectedContact.value) return + await loadMessages({ username: selectedContact.value.username, reset: true }) + } + + const refreshRealtimeIncremental = async () => { + if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return + if (searchContext.value?.active || isLoadingMessages.value) return + + const username = selectedContact.value.username + const existing = allMessages.value[username] || [] + if (!existing.length) return + + const container = messageContainerRef.value + const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80 + + const params = { + account: selectedAccount.value, + username, + limit: 30, + offset: 0, + order: 'asc', + source: 'realtime' + } + if (messageTypeFilter.value && messageTypeFilter.value !== 'all') { + params.render_types = messageTypeFilter.value + } + + const response = await api.listChatMessages(params) + if (selectedContact.value?.username !== username) return + + const latest = (response?.messages || []).map(normalizeMessage) + const seenIds = new Set(existing.map((message) => String(message?.id || ''))) + const newOnes = [] + for (const message of latest) { + const id = String(message?.id || '') + if (!id || seenIds.has(id)) continue + seenIds.add(id) + newOnes.push(message) + } + if (!newOnes.length) return + + allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] } + + await nextTick() + const nextContainer = messageContainerRef.value + if (nextContainer && atBottom) { + nextContainer.scrollTop = nextContainer.scrollHeight + } + updateJumpToBottomState() + } + + let realtimeRefreshFuture = null + let realtimeRefreshQueued = false + + const queueRealtimeRefresh = () => { + if (realtimeRefreshFuture) { + realtimeRefreshQueued = true + return + } + + realtimeRefreshFuture = refreshRealtimeIncremental().finally(() => { + realtimeRefreshFuture = null + if (realtimeRefreshQueued) { + realtimeRefreshQueued = false + queueRealtimeRefresh() + } + }) + } + + const tryEnableRealtimeAuto = async () => { + if (!process.client || typeof window === 'undefined') return + if (!desktopAutoRealtime.value || realtimeEnabled.value || !selectedAccount.value) return + try { + await realtimeStore.enable({ silent: true }) + } catch {} + } + + const resetMessageState = () => { + allMessages.value = {} + messagesMeta.value = {} + messagesError.value = '' + highlightMessageId.value = '' + highlightServerIdStr.value = '' + } + + const contactProfileCardOpen = ref(false) + const contactProfileCardMessageId = ref('') + const contactProfileLoading = ref(false) + const contactProfileError = ref('') + const contactProfileData = ref(null) + let contactProfileHoverHideTimer = null + + const contactProfileResolvedName = computed(() => { + const profile = contactProfileData.value || {} + const displayName = String(profile?.displayName || '').trim() + if (displayName) return displayName + const contactName = String(selectedContact.value?.name || '').trim() + if (contactName) return contactName + return String(profile?.username || selectedContact.value?.username || '').trim() + }) + + const contactProfileResolvedUsername = computed(() => { + const profile = contactProfileData.value || {} + return String(profile?.username || selectedContact.value?.username || '').trim() + }) + + const contactProfileResolvedNickname = computed(() => String(contactProfileData.value?.nickname || '').trim()) + const contactProfileResolvedAlias = computed(() => String(contactProfileData.value?.alias || '').trim()) + const contactProfileResolvedRegion = computed(() => String(contactProfileData.value?.region || '').trim()) + const contactProfileResolvedRemark = computed(() => String(contactProfileData.value?.remark || '').trim()) + const contactProfileResolvedSignature = computed(() => String(contactProfileData.value?.signature || '').trim()) + const contactProfileResolvedSource = computed(() => String(contactProfileData.value?.source || '').trim()) + const contactProfileResolvedAvatar = computed(() => { + const avatar = String(contactProfileData.value?.avatar || '').trim() + if (avatar) return avatar + return String(selectedContact.value?.avatar || '').trim() + }) + + const contactProfileResolvedGender = computed(() => { + const value = contactProfileData.value?.gender + if (value == null || value === '') return '' + const gender = Number(value) + if (!Number.isFinite(gender)) return '' + if (gender === 1) return '男' + if (gender === 2) return '女' + if (gender === 0) return '未知' + return String(gender) + }) + + const contactProfileResolvedSourceScene = computed(() => { + const value = contactProfileData.value?.sourceScene + if (value == null || value === '') return null + const scene = Number(value) + return Number.isFinite(scene) ? scene : null + }) + + const fetchContactProfile = async (options = {}) => { + const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim() + const displayNameFallback = String(options?.displayName || '').trim() + const avatarFallback = String(options?.avatar || '').trim() + const account = String(selectedAccount.value || '').trim() + if (!username || !account) { + contactProfileData.value = null + return + } + + contactProfileLoading.value = true + contactProfileError.value = '' + try { + const response = await api.listChatContacts({ + account, + include_friends: true, + include_groups: true, + include_officials: true + }) + const list = Array.isArray(response?.contacts) ? response.contacts : [] + const matched = list.find((item) => String(item?.username || '').trim() === username) + if (matched) { + const normalized = { ...matched, username } + if (!String(normalized.displayName || '').trim() && displayNameFallback) { + normalized.displayName = displayNameFallback + } + if (!String(normalized.avatar || '').trim() && avatarFallback) { + normalized.avatar = avatarFallback + } + contactProfileData.value = normalized + } else { + contactProfileData.value = { + username, + displayName: displayNameFallback || selectedContact.value?.name || username, + avatar: avatarFallback || selectedContact.value?.avatar || '', + nickname: '', + alias: '', + gender: null, + region: '', + remark: '', + signature: '', + source: '', + sourceScene: null + } + } + } catch (error) { + contactProfileData.value = { + username, + displayName: displayNameFallback || selectedContact.value?.name || username, + avatar: avatarFallback || selectedContact.value?.avatar || '', + nickname: '', + alias: '', + gender: null, + region: '', + remark: '', + signature: '', + source: '', + sourceScene: null + } + contactProfileError.value = error?.message || '加载联系人资料失败' + } finally { + contactProfileLoading.value = false + } + } + + const clearContactProfileHoverHideTimer = () => { + if (contactProfileHoverHideTimer) { + clearTimeout(contactProfileHoverHideTimer) + contactProfileHoverHideTimer = null + } + } + + const closeContactProfileCard = () => { + contactProfileCardOpen.value = false + contactProfileCardMessageId.value = '' + } + + const onMessageAvatarMouseEnter = async (message) => { + if (!!message?.isSent) return + const messageId = String(message?.id ?? '').trim() + if (!messageId) return + const username = String(message?.senderUsername || '').trim() + if (!username || username === 'self') return + + const senderName = String(message?.senderDisplayName || message?.sender || '').trim() + const senderAvatar = String(message?.avatar || '').trim() + if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) { + contactProfileData.value = { + username, + displayName: senderName || username, + avatar: senderAvatar, + nickname: '', + alias: '', + gender: null, + region: '', + remark: '', + signature: '', + source: '', + sourceScene: null + } + } else { + if (!String(contactProfileData.value?.displayName || '').trim() && senderName) { + contactProfileData.value.displayName = senderName + } + if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) { + contactProfileData.value.avatar = senderAvatar + } + } + + clearContactProfileHoverHideTimer() + contactProfileCardMessageId.value = messageId + contactProfileCardOpen.value = true + await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar }) + } + + const onMessageAvatarMouseLeave = () => { + clearContactProfileHoverHideTimer() + contactProfileHoverHideTimer = setTimeout(() => { + closeContactProfileCard() + }, 120) + } + + const onContactCardMouseEnter = () => { + clearContactProfileHoverHideTimer() + } + + watch( + () => selectedContact.value?.username, + () => { + clearContactProfileHoverHideTimer() + closeContactProfileCard() + contactProfileError.value = '' + contactProfileData.value = null + } + ) + + watch( + () => selectedAccount.value, + () => { + clearContactProfileHoverHideTimer() + closeContactProfileCard() + contactProfileError.value = '' + contactProfileData.value = null + } + ) + + onUnmounted(() => { + if (highlightTimer) clearTimeout(highlightTimer) + highlightTimer = null + clearContactProfileHoverHideTimer() + }) + + return { + allMessages, + messagesMeta, + messages, + renderMessages, + hasMoreMessages, + isLoadingMessages, + messagesError, + messageContainerRef, + showJumpToBottom, + messagePageSize, + messageTypeFilter, + messageTypeFilterOptions, + reverseMessageSides, + previewImageUrl, + previewVideoUrl, + previewVideoPosterUrl, + previewVideoError, + voiceRefs, + currentPlayingVoice, + playingVoiceId, + highlightServerIdStr, + highlightMessageId, + contactProfileCardOpen, + contactProfileCardMessageId, + contactProfileLoading, + contactProfileError, + contactProfileData, + contactProfileResolvedName, + contactProfileResolvedUsername, + contactProfileResolvedNickname, + contactProfileResolvedAlias, + contactProfileResolvedGender, + contactProfileResolvedRegion, + contactProfileResolvedRemark, + contactProfileResolvedSignature, + contactProfileResolvedSource, + contactProfileResolvedSourceScene, + contactProfileResolvedAvatar, + normalizeMessage, + updateJumpToBottomState, + scrollToBottom, + flashMessage, + scrollToMessageId, + openImagePreview, + closeImagePreview, + openVideoPreview, + closeVideoPreview, + onPreviewVideoError, + setVoiceRef, + playVoice, + playQuoteVoice, + getQuoteVoiceId, + getVoiceDurationInSeconds, + getVoiceWidth, + isQuotedVoice, + isQuotedImage, + isQuotedLink, + getQuotedLinkText, + onQuoteImageError, + onQuoteThumbError, + onAvatarError, + shouldShowEmojiDownload, + onEmojiDownloadClick, + onFileClick, + toggleReverseMessageSides, + loadMessages, + loadMoreMessages, + refreshSelectedMessages, + refreshRealtimeIncremental, + queueRealtimeRefresh, + tryEnableRealtimeAuto, + resetMessageState, + fetchContactProfile, + clearContactProfileHoverHideTimer, + closeContactProfileCard, + onMessageAvatarMouseEnter, + onMessageAvatarMouseLeave, + onContactCardMouseEnter, + formatFileSize + } +} diff --git a/frontend/composables/chat/useChatSearch.js b/frontend/composables/chat/useChatSearch.js new file mode 100644 index 0000000..8a92e75 --- /dev/null +++ b/frontend/composables/chat/useChatSearch.js @@ -0,0 +1,1678 @@ +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { + dateToUnixSeconds, + formatMessageFullTime, + highlightKeyword +} from '~/lib/chat/formatters' + +export const createEmptySearchContext = () => ({ + active: false, + kind: 'search', + label: '', + username: '', + anchorId: '', + anchorIndex: -1, + hasMoreBefore: false, + hasMoreAfter: false, + loadingBefore: false, + loadingAfter: false, + savedMessages: null, + savedMeta: null +}) + +export const useChatSearch = ({ + api, + heatColor, + contacts, + selectedAccount, + selectedContact, + privacyMode, + allMessages, + messagesMeta, + messages, + messageContainerRef, + messagePageSize, + hasMoreMessages, + isLoadingMessages, + normalizeMessage, + updateJumpToBottomState, + scrollToMessageId, + flashMessage, + highlightMessageId, + searchContext, + selectContact, + loadMoreMessages +}) => { +const messageSearchOpen = ref(false) +const messageSearchQuery = ref('') +const messageSearchScope = ref('global') // conversation | global +const messageSearchRangeDays = ref('') // empty means no time filter +const messageSearchSessionType = ref('') // empty means all (global only): group | single +const messageSearchSender = ref('') // 发送者筛选 +const messageSearchSenderOptions = ref([]) +const messageSearchSenderLoading = ref(false) +const messageSearchSenderError = ref('') +const messageSearchSenderOptionsKey = ref('') +const messageSearchSenderDropdownOpen = ref(false) +const messageSearchSenderDropdownRef = ref(null) +const messageSearchSenderDropdownInputRef = ref(null) +const messageSearchSenderDropdownQuery = ref('') +const messageSearchStartDate = ref('') // 自定义开始日期 +const messageSearchEndDate = ref('') // 自定义结束日期 +const messageSearchResults = ref([]) +const messageSearchLoading = ref(false) +const messageSearchError = ref('') +const messageSearchBackendStatus = ref('') +const messageSearchIndexInfo = ref(null) +const messageSearchHasMore = ref(false) +const messageSearchOffset = ref(0) +const messageSearchLimit = 50 +const messageSearchTotal = ref(0) +const messageSearchSelectedIndex = ref(-1) +const messageSearchInputRef = ref(null) +let messageSearchDebounceTimer = null +let messageSearchIndexPollTimer = null + +// 搜索UI增强 +const searchInputFocused = ref(false) +const showAdvancedFilters = ref(false) +const searchHistory = ref([]) +const SEARCH_HISTORY_KEY = 'wechat_search_history' +const MAX_SEARCH_HISTORY = 10 + +// 加载搜索历史 +const loadSearchHistory = () => { +if (!process.client) return +try { + const saved = localStorage.getItem(SEARCH_HISTORY_KEY) + if (saved) { + searchHistory.value = JSON.parse(saved) || [] + } +} catch (e) { + searchHistory.value = [] +} +} + +// 保存搜索历史 +const saveSearchHistory = (query) => { +if (!process.client) return +if (!query || !query.trim()) return +const q = query.trim() +try { + let history = [...searchHistory.value] + // 移除重复项 + history = history.filter(item => item !== q) + // 添加到开头 + history.unshift(q) + // 限制数量 + if (history.length > MAX_SEARCH_HISTORY) { + history = history.slice(0, MAX_SEARCH_HISTORY) + } + searchHistory.value = history + localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)) +} catch (e) { + // ignore +} +} + +// 清空搜索历史 +const clearSearchHistory = () => { +if (!process.client) return +searchHistory.value = [] +try { + localStorage.removeItem(SEARCH_HISTORY_KEY) +} catch (e) { + // ignore +} +} + +// 应用搜索历史 +const applySearchHistory = async (query) => { +messageSearchQuery.value = query +await runMessageSearch({ reset: true }) +} + +const messageSearchIndexExists = computed(() => !!messageSearchIndexInfo.value?.exists) +const messageSearchIndexReady = computed(() => !!messageSearchIndexInfo.value?.ready) +const messageSearchIndexBuildStatus = computed(() => String(messageSearchIndexInfo.value?.build?.status || '')) +const messageSearchIndexBuildIndexed = computed(() => Number(messageSearchIndexInfo.value?.build?.indexedMessages || 0)) +const messageSearchIndexMetaCount = computed(() => { +const meta = messageSearchIndexInfo.value?.meta || {} +const v = meta.message_count ?? meta.messageCount ?? meta.message_count ?? 0 +return Number(v || 0) +}) + +const messageSearchIndexProgressText = computed(() => { +if (messageSearchIndexBuildStatus.value !== 'building') return '' +const n = Number(messageSearchIndexBuildIndexed.value || 0) +return n > 0 ? `已索引 ${n.toLocaleString()} 条` : '准备中...' +}) + +const messageSearchIndexText = computed(() => { +if (!messageSearchIndexInfo.value) return '' +if (!messageSearchIndexExists.value) return '索引未建立' +if (messageSearchIndexBuildStatus.value === 'error') return '索引异常' +if (!messageSearchIndexReady.value) return '索引未完成,需重建' +const n = Number(messageSearchIndexMetaCount.value || 0) +return n > 0 ? `索引已就绪(${n.toLocaleString()} 条)` : '索引已就绪' +}) + +const messageSearchIndexActionText = computed(() => { +if (messageSearchIndexBuildStatus.value === 'building') return '建立中' +return messageSearchIndexExists.value ? '重建索引' : '建立索引' +}) + +const messageSearchIndexActionDisabled = computed(() => { +return messageSearchIndexBuildStatus.value === 'building' || messageSearchLoading.value +}) + +const formatCount = (n) => { +const v = Number(n || 0) +if (!Number.isFinite(v) || v <= 0) return '' +try { + return v.toLocaleString() +} catch { + return String(v) +} +} + +const messageSearchSenderDisabled = computed(() => { +if (!selectedAccount.value) return true +const scope = String(messageSearchScope.value || 'conversation') +if (scope === 'conversation') { + return !selectedContact.value?.username +} +const q = String(messageSearchQuery.value || '').trim() +if (q.length >= 2) return false +return !String(messageSearchSender.value || '').trim() +}) + +const messageSearchSelectedSenderInfo = computed(() => { +const u = String(messageSearchSender.value || '').trim() +if (!u) return null +const list = Array.isArray(messageSearchSenderOptions.value) ? messageSearchSenderOptions.value : [] +const found = list.find((s) => String(s?.username || '').trim() === u) +if (found) return found +return { username: u, displayName: u, avatar: null, count: null } +}) + +const messageSearchSelectedSenderInitial = computed(() => { +const info = messageSearchSelectedSenderInfo.value +if (!info) return '人' +const n = String(info.displayName || info.username || '').trim() +return n ? n.charAt(0) : '人' +}) + +const messageSearchSenderLabel = computed(() => { +const cur = String(messageSearchSender.value || '').trim() +if (!cur) { + if (String(messageSearchScope.value || '') === 'global' && String(messageSearchQuery.value || '').trim().length < 2) { + return '发送者' + } + return '不限发送者' +} +const info = messageSearchSelectedSenderInfo.value +return String(info?.displayName || info?.username || cur) +}) + +const filteredMessageSearchSenderOptions = computed(() => { +const list = Array.isArray(messageSearchSenderOptions.value) ? messageSearchSenderOptions.value : [] +const q = String(messageSearchSenderDropdownQuery.value || '').trim().toLowerCase() +if (!q) return list +return list.filter((s) => { + const u = String(s?.username || '').toLowerCase() + const n = String(s?.displayName || '').toLowerCase() + return u.includes(q) || n.includes(q) +}) +}) + +const closeMessageSearchSenderDropdown = () => { +messageSearchSenderDropdownOpen.value = false +messageSearchSenderDropdownQuery.value = '' +} + +const getMessageSearchSenderFacetKey = () => { +const acc = String(selectedAccount.value || '').trim() +if (!acc) return '' +const scope = String(messageSearchScope.value || 'conversation') +const conv = scope === 'conversation' ? String(selectedContact.value?.username || '') : '' +const q = String(messageSearchQuery.value || '').trim() +const range = String(messageSearchRangeDays.value || '') +const sd = String(messageSearchStartDate.value || '') +const ed = String(messageSearchEndDate.value || '') +const st = scope === 'global' ? String(messageSearchSessionType.value || '').trim() : '' +return [acc, scope, conv, q, range, sd, ed, st].join('|') +} + +const ensureMessageSearchSendersLoaded = async () => { +const key = getMessageSearchSenderFacetKey() +if (!key) return +if (messageSearchSenderOptionsKey.value === key && !messageSearchSenderLoading.value) return +const list = await fetchMessageSearchSenders() +messageSearchSenderOptionsKey.value = key +return list +} + +const toggleMessageSearchSenderDropdown = async () => { +if (messageSearchSenderDisabled.value) return +if (messageSearchSenderDropdownOpen.value) { + closeMessageSearchSenderDropdown() + return +} +messageSearchSenderDropdownOpen.value = true +await ensureMessageSearchSendersLoaded() +await nextTick() +try { + messageSearchSenderDropdownInputRef.value?.focus?.() +} catch {} +} + +const selectMessageSearchSender = (username) => { +messageSearchSender.value = String(username || '') +closeMessageSearchSenderDropdown() +} + +const fetchMessageSearchIndexStatus = async () => { +if (!selectedAccount.value) return null +try { + const resp = await api.getChatSearchIndexStatus({ account: selectedAccount.value }) + messageSearchIndexInfo.value = resp?.index || null + return messageSearchIndexInfo.value +} catch (e) { + return null +} +} + +const fetchMessageSearchSenders = async () => { +messageSearchSenderError.value = '' +if (!selectedAccount.value) { + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + return [] +} + +const scope = String(messageSearchScope.value || 'conversation') +const msgQ = String(messageSearchQuery.value || '').trim() + +const params = { + account: selectedAccount.value, + limit: 200 +} + +if (scope === 'conversation') { + if (!selectedContact.value?.username) { + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + return [] + } + params.username = selectedContact.value.username +} else { + if (msgQ.length < 2) { + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + return [] + } +} + +if (msgQ) { + params.message_q = msgQ +} + +params.render_types = 'text' + +const range = String(messageSearchRangeDays.value || '') +if (range === 'custom') { + const start = dateToUnixSeconds(messageSearchStartDate.value, false) + const end = dateToUnixSeconds(messageSearchEndDate.value, true) + if (start != null) params.start_time = start + if (end != null) params.end_time = end + if (start != null && end != null && start > end) { + messageSearchSenderError.value = '时间范围不合法:开始日期不能晚于结束日期' + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + return [] + } +} else { + const days = Number(range || 0) + if (days > 0 && Number.isFinite(days)) { + const end = Math.floor(Date.now() / 1000) + const start = Math.max(0, end - Math.floor(days * 24 * 3600)) + params.start_time = start + params.end_time = end + } +} + +if (scope === 'global') { + const st = String(messageSearchSessionType.value || '').trim() + if (st) params.session_type = st +} + +messageSearchSenderLoading.value = true +try { + const resp = await api.listChatSearchSenders(params) + const status = String(resp?.status || 'success') + if (status !== 'success') { + if (status !== 'index_building') { + messageSearchSenderError.value = String(resp?.message || '加载发送者失败') + } + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + return [] + } + const list = Array.isArray(resp?.senders) ? resp.senders : [] + messageSearchSenderOptions.value = list + messageSearchSenderOptionsKey.value = getMessageSearchSenderFacetKey() + const cur = String(messageSearchSender.value || '').trim() + if (cur && !list.some((s) => String(s?.username || '').trim() === cur)) { + messageSearchSender.value = '' + } + return list +} catch (e) { + messageSearchSenderError.value = e?.message || '加载发送者失败' + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + return [] +} finally { + messageSearchSenderLoading.value = false +} +} + +const stopMessageSearchIndexPolling = () => { +if (messageSearchIndexPollTimer) clearInterval(messageSearchIndexPollTimer) +messageSearchIndexPollTimer = null +} + +const ensureMessageSearchIndexPolling = () => { +if (messageSearchIndexPollTimer) return +messageSearchIndexPollTimer = setInterval(async () => { + if (!messageSearchOpen.value) { + stopMessageSearchIndexPolling() + return + } + + const info = await fetchMessageSearchIndexStatus() + const exists = !!info?.exists + const ready = !!info?.ready + const bs = String(info?.build?.status || '') + const done = exists && ready && bs !== 'building' + if (done) { + stopMessageSearchIndexPolling() + if (String(messageSearchScope.value || '') === 'conversation') { + await fetchMessageSearchSenders() + } + if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) + } + } +}, 1200) +} + +const onMessageSearchIndexAction = async () => { +if (!selectedAccount.value) return +const rebuild = messageSearchIndexExists.value +try { + const resp = await api.buildChatSearchIndex({ account: selectedAccount.value, rebuild }) + messageSearchIndexInfo.value = resp?.index || null + messageSearchBackendStatus.value = 'index_building' + ensureMessageSearchIndexPolling() +} catch (e) { + messageSearchError.value = e?.message || '建立索引失败' +} +} +const getMessageSearchHitAvatarUrl = (hit) => { +if (!hit) return '' +const scope = String(messageSearchScope.value || '') +const url = + scope === 'global' + ? (hit.conversationAvatar || hit.senderAvatar || '') + : (hit.senderAvatar || hit.conversationAvatar || '') +return String(url || '').trim() +} + +const getMessageSearchHitAvatarAlt = (hit) => { +if (!hit) return '头像' +const scope = String(messageSearchScope.value || '') +if (scope === 'global') { + const name = String(hit.conversationName || hit.username || '').trim() + return name ? `${name} 头像` : '头像' +} +let name = String(hit.senderDisplayName || '').trim() +if (!name) { + name = hit.isSent ? '我' : String(hit.senderUsername || '').trim() +} +return name ? `${name} 头像` : '头像' +} + +const getMessageSearchHitAvatarInitial = (hit) => { +if (!hit) return '?' +const scope = String(messageSearchScope.value || '') +let text = '' +if (scope === 'global') { + text = String(hit.conversationName || hit.username || '').trim() +} else { + text = String(hit.senderDisplayName || '').trim() + if (!text) { + text = hit.isSent ? '我' : String(hit.senderUsername || '').trim() + } +} +return (text.charAt(0) || '?').toString() +} +const searchContextBannerText = computed(() => { +if (!searchContext.value?.active) return '' +const kind = String(searchContext.value.kind || 'search') +if (kind === 'date') { + const label = String(searchContext.value.label || '').trim() + return label ? `已定位到 ${label}(上下文模式)` : '已定位到指定日期(上下文模式)' +} +if (kind === 'first') { + return '已定位到会话顶部(上下文模式)' +} +return '已定位到搜索结果(上下文模式)' +}) + +// 回到最新按钮 +const showJumpToBottom = ref(false) + +// 时间侧边栏(按日期定位) +const timeSidebarOpen = ref(false) +const timeSidebarYear = ref(null) +const timeSidebarMonth = ref(null) // 1-12 +const timeSidebarCounts = ref({}) // { 'YYYY-MM-DD': count } +const timeSidebarMax = ref(0) +const timeSidebarTotal = ref(0) +const timeSidebarLoading = ref(false) +const timeSidebarError = ref('') +const timeSidebarSelectedDate = ref('') // YYYY-MM-DD (current/selected day) +// Simple in-memory cache per (account|username|YYYY-MM) +const timeSidebarCache = ref({}) +const timeSidebarWeekdays = ['一', '二', '三', '四', '五', '六', '日'] + +const timeSidebarMonthLabel = computed(() => { +const y = Number(timeSidebarYear.value || 0) +const m = Number(timeSidebarMonth.value || 0) +if (!y || !m) return '' +return `${y}年${m}月` +}) + +const timeSidebarYearOptions = computed(() => { +// WeChat history normally starts after 2011, but keep a broader range for safety. +const nowY = new Date().getFullYear() +const minY = 2000 +const maxY = Math.max(nowY, Number(timeSidebarYear.value || 0) || nowY) +const years = [] +for (let y = maxY; y >= minY; y--) years.push(y) +return years +}) + +const timeSidebarActiveDays = computed(() => { +const counts = timeSidebarCounts.value || {} +const keys = Object.keys(counts) +return keys.length +}) + +const _pad2 = (n) => String(n).padStart(2, '0') + +const _dateStrFromEpochSeconds = (ts) => { +const t = Number(ts || 0) +if (!t) return '' +try { + const d = new Date(t * 1000) + return `${d.getFullYear()}-${_pad2(d.getMonth() + 1)}-${_pad2(d.getDate())}` +} catch { + return '' +} +} + +// Calendar heatmap color: reuse Wrapped heat palette, but bucket to Wrapped-like legend levels +// so ">=1 message" is always visibly tinted (instead of being almost white when max is huge). +const _calendarHeatColor = (count, maxV) => { +const v = Math.max(0, Number(count || 0)) +const m = Math.max(0, Number(maxV || 0)) +if (!(v > 0)) return '' +if (!(m > 0)) return heatColor(1, 1) +const levels = 6 +const ratio = Math.max(0, Math.min(1, v / m)) +const level = Math.min(levels, Math.max(1, Math.ceil(ratio * levels))) +const valueForLevel = Math.max(1, Math.round(level * (m / levels))) +return heatColor(valueForLevel, m) +} + +const timeSidebarCalendarCells = computed(() => { +const y = Number(timeSidebarYear.value || 0) +const m = Number(timeSidebarMonth.value || 0) // 1-12 +if (!y || !m) return [] + +const daysInMonth = new Date(y, m, 0).getDate() +const firstDow = new Date(y, m - 1, 1).getDay() // 0=Sun..6=Sat +const offset = (firstDow + 6) % 7 // Monday=0 + +const maxV = Math.max(0, Number(timeSidebarMax.value || 0)) +const counts = timeSidebarCounts.value || {} +const selected = String(timeSidebarSelectedDate.value || '').trim() + +const out = [] +for (let i = 0; i < 42; i++) { + const dayNum = i - offset + 1 + const inMonth = dayNum >= 1 && dayNum <= daysInMonth + if (!inMonth) { + out.push({ + key: `e:${y}-${m}:${i}`, + day: '', + dateStr: '', + count: 0, + disabled: true, + className: 'calendar-day-outside', + style: null, + title: '' + }) + continue + } + + const dateStr = `${y}-${_pad2(m)}-${_pad2(dayNum)}` + const count = Math.max(0, Number(counts[dateStr] || 0)) + const disabled = count <= 0 + + const style = !disabled + ? { backgroundColor: _calendarHeatColor(count, Math.max(maxV, count)) } + : null + + const className = [ + disabled ? 'calendar-day-empty' : '', + (selected && dateStr === selected) ? 'calendar-day-selected' : '' + ].filter(Boolean).join(' ') + + out.push({ + key: dateStr, + day: String(dayNum), + dateStr, + count, + disabled, + // NOTE: heatmap bg color is applied via inline style (reusing Wrapped heatmap palette). + // Dynamic class names like `calendar-day-l${level}` may be purged by Tailwind and lead to no bg color. + className, + style, + title: `${dateStr}:${count} 条` + }) +} +return out +}) +const closeMessageSearch = () => { +messageSearchOpen.value = false +closeMessageSearchSenderDropdown() +messageSearchError.value = '' +messageSearchLoading.value = false +messageSearchBackendStatus.value = '' +stopMessageSearchIndexPolling() +if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer) +messageSearchDebounceTimer = null +} + +let timeSidebarReqId = 0 + +const closeTimeSidebar = () => { +timeSidebarOpen.value = false +timeSidebarError.value = '' +} + +const _timeSidebarCacheKey = ({ account, username, year, month }) => { +const acc = String(account || '').trim() +const u = String(username || '').trim() +const y = Number(year || 0) +const m = Number(month || 0) +return `${acc}|${u}|${y}-${_pad2(m)}` +} + +const _applyTimeSidebarMonthData = (data) => { +const counts = (data && typeof data.counts === 'object' && !Array.isArray(data.counts)) ? data.counts : {} +timeSidebarCounts.value = counts +timeSidebarMax.value = Math.max(0, Number(data?.max || 0)) +timeSidebarTotal.value = Math.max(0, Number(data?.total || 0)) +} + +const loadTimeSidebarMonth = async ({ year, month, force } = {}) => { +if (!selectedAccount.value) return +if (!selectedContact.value?.username) return + +const y = Number(year || timeSidebarYear.value || 0) +const m = Number(month || timeSidebarMonth.value || 0) +if (!y || !m) return + +timeSidebarYear.value = y +timeSidebarMonth.value = m + +const key = _timeSidebarCacheKey({ + account: selectedAccount.value, + username: selectedContact.value.username, + year: y, + month: m +}) + +if (!force) { + const cached = timeSidebarCache.value[key] + if (cached) { + timeSidebarError.value = '' + _applyTimeSidebarMonthData(cached) + return + } +} + +const reqId = ++timeSidebarReqId +timeSidebarLoading.value = true +timeSidebarError.value = '' + +try { + const resp = await api.getChatMessageDailyCounts({ + account: selectedAccount.value, + username: selectedContact.value.username, + year: y, + month: m + }) + if (reqId !== timeSidebarReqId) return + if (String(resp?.status || '') !== 'success') { + throw new Error(String(resp?.message || '加载日历失败')) + } + + const data = { + counts: resp?.counts || {}, + max: Number(resp?.max || 0), + total: Number(resp?.total || 0) + } + + _applyTimeSidebarMonthData(data) + timeSidebarCache.value = { ...timeSidebarCache.value, [key]: data } +} catch (e) { + if (reqId !== timeSidebarReqId) return + timeSidebarError.value = e?.message || '加载日历失败' + _applyTimeSidebarMonthData({ counts: {}, max: 0, total: 0 }) +} finally { + if (reqId === timeSidebarReqId) { + timeSidebarLoading.value = false + } +} +} + +const _pickTimeSidebarInitialYearMonth = () => { +const list = messages.value || [] +const last = Array.isArray(list) && list.length ? list[list.length - 1] : null +const ts = Number(last?.createTime || 0) +const d = ts ? new Date(ts * 1000) : new Date() +return { year: d.getFullYear(), month: d.getMonth() + 1 } +} + +const _applyTimeSidebarSelectedDate = async (dateStr, { syncMonth } = {}) => { +const ds = String(dateStr || '').trim() +if (!ds) return +if (timeSidebarSelectedDate.value !== ds) { + timeSidebarSelectedDate.value = ds +} +if (!syncMonth || !timeSidebarOpen.value) return + +const parts = ds.split('-') +const y = Number(parts?.[0] || 0) +const m = Number(parts?.[1] || 0) +if (!y || !m) return + +if (Number(timeSidebarYear.value || 0) !== y || Number(timeSidebarMonth.value || 0) !== m) { + timeSidebarYear.value = y + timeSidebarMonth.value = m + // Fire and forget; request id guard + cache inside loadTimeSidebarMonth will handle racing. + await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} +} + +const toggleTimeSidebar = async () => { +timeSidebarOpen.value = !timeSidebarOpen.value +if (!timeSidebarOpen.value) return +closeMessageSearch() + +const { year, month } = _pickTimeSidebarInitialYearMonth() +timeSidebarYear.value = year +timeSidebarMonth.value = month + +// Default selected day: current viewport's latest loaded message day (usually "latest"). +const list = messages.value || [] +const last = Array.isArray(list) && list.length ? list[list.length - 1] : null +const ds = _dateStrFromEpochSeconds(Number(last?.createTime || 0)) +if (ds) await _applyTimeSidebarSelectedDate(ds, { syncMonth: false }) + +await loadTimeSidebarMonth({ year, month, force: false }) +} + +const prevTimeSidebarMonth = async () => { +const y0 = Number(timeSidebarYear.value || 0) +const m0 = Number(timeSidebarMonth.value || 0) +if (!y0 || !m0) return +const y = m0 === 1 ? (y0 - 1) : y0 +const m = m0 === 1 ? 12 : (m0 - 1) +await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} + +const nextTimeSidebarMonth = async () => { +const y0 = Number(timeSidebarYear.value || 0) +const m0 = Number(timeSidebarMonth.value || 0) +if (!y0 || !m0) return +const y = m0 === 12 ? (y0 + 1) : y0 +const m = m0 === 12 ? 1 : (m0 + 1) +await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} + +const onTimeSidebarYearMonthChange = async () => { +if (!timeSidebarOpen.value) return +const y = Number(timeSidebarYear.value || 0) +const m = Number(timeSidebarMonth.value || 0) +if (!y || !m) return +await loadTimeSidebarMonth({ year: y, month: m, force: false }) +} + +const ensureMessageSearchScopeValid = () => { +if (messageSearchScope.value === 'conversation' && !selectedContact.value) { + messageSearchScope.value = 'global' +} +} + +const toggleMessageSearch = async () => { +messageSearchOpen.value = !messageSearchOpen.value +ensureMessageSearchScopeValid() +if (!messageSearchOpen.value) return +closeTimeSidebar() +await nextTick() +try { + messageSearchInputRef.value?.focus?.() +} catch {} +await fetchMessageSearchIndexStatus() +await fetchMessageSearchSenders() +if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) +} +} + +let messageSearchReqId = 0 + +const runMessageSearch = async ({ reset } = {}) => { +if (!selectedAccount.value) return +ensureMessageSearchScopeValid() + +const q = String(messageSearchQuery.value || '').trim() +if (!q) { + messageSearchResults.value = [] + messageSearchHasMore.value = false + messageSearchError.value = '' + messageSearchSelectedIndex.value = -1 + messageSearchBackendStatus.value = '' + messageSearchTotal.value = 0 + stopMessageSearchIndexPolling() + return +} + +if (reset) { + messageSearchOffset.value = 0 + messageSearchResults.value = [] + messageSearchSelectedIndex.value = -1 +} + +const reqId = ++messageSearchReqId +messageSearchLoading.value = true +messageSearchError.value = '' +messageSearchBackendStatus.value = '' + +const scope = String(messageSearchScope.value || 'conversation') + +const params = { + account: selectedAccount.value, + q, + limit: messageSearchLimit, + offset: messageSearchOffset.value +} + +params.render_types = 'text' + +const range = String(messageSearchRangeDays.value || '') +if (range === 'custom') { + const start = dateToUnixSeconds(messageSearchStartDate.value, false) + const end = dateToUnixSeconds(messageSearchEndDate.value, true) + if (start != null) params.start_time = start + if (end != null) params.end_time = end + if (start != null && end != null && start > end) { + messageSearchLoading.value = false + messageSearchError.value = '时间范围不合法:开始日期不能晚于结束日期' + return + } +} else { + const days = Number(range || 0) + if (days > 0 && Number.isFinite(days)) { + const end = Math.floor(Date.now() / 1000) + const start = Math.max(0, end - Math.floor(days * 24 * 3600)) + params.start_time = start + params.end_time = end + } +} + +if (scope === 'global') { + const st = String(messageSearchSessionType.value || '').trim() + if (st) params.session_type = st +} + +if (String(messageSearchSender.value || '').trim()) { + params.sender = String(messageSearchSender.value || '').trim() +} + +if (scope === 'conversation') { + if (!selectedContact.value?.username) { + messageSearchLoading.value = false + messageSearchError.value = '请选择一个会话再搜索' + return + } + params.username = selectedContact.value.username +} + +try { + const resp = await api.searchChatMessages(params) + if (reqId !== messageSearchReqId) return + + if (resp?.index) { + messageSearchIndexInfo.value = resp.index + } + + const status = String(resp?.status || 'success') + messageSearchBackendStatus.value = status + + if (status === 'index_building') { + if (reset) { + messageSearchResults.value = [] + messageSearchSelectedIndex.value = -1 + } + messageSearchHasMore.value = false + messageSearchTotal.value = 0 + ensureMessageSearchIndexPolling() + return + } + + if (status === 'index_error') { + if (reset) { + messageSearchResults.value = [] + messageSearchSelectedIndex.value = -1 + } + messageSearchHasMore.value = false + messageSearchTotal.value = 0 + messageSearchError.value = String(resp?.message || '索引错误') + stopMessageSearchIndexPolling() + return + } + + if (status !== 'success') { + if (reset) { + messageSearchResults.value = [] + messageSearchSelectedIndex.value = -1 + } + messageSearchHasMore.value = false + messageSearchTotal.value = 0 + messageSearchError.value = String(resp?.message || '搜索失败') + stopMessageSearchIndexPolling() + return + } + + const hits = Array.isArray(resp?.hits) ? resp.hits : [] + if (reset) { + messageSearchResults.value = hits + } else { + messageSearchResults.value = [...messageSearchResults.value, ...hits] + } + messageSearchHasMore.value = !!resp?.hasMore + messageSearchTotal.value = Number(resp?.total ?? resp?.totalInScan ?? 0) + stopMessageSearchIndexPolling() + + if (messageSearchSelectedIndex.value < 0 && messageSearchResults.value.length) { + messageSearchSelectedIndex.value = 0 + } + + // 保存搜索历史(仅在有结果时保存) + if (!privacyMode.value && reset && hits.length > 0) { + saveSearchHistory(q) + } +} catch (e) { + if (reqId !== messageSearchReqId) return + messageSearchError.value = e?.message || '搜索失败' +} finally { + if (reqId === messageSearchReqId) { + messageSearchLoading.value = false + } +} +} + +const loadMoreSearchResults = async () => { +if (!messageSearchHasMore.value) return +if (messageSearchLoading.value) return +messageSearchOffset.value = Number(messageSearchOffset.value || 0) + messageSearchLimit +await runMessageSearch({ reset: false }) +} + +const exitSearchContext = async () => { +if (!searchContext.value?.active) return +const u = String(searchContext.value.username || '').trim() +const saved = searchContext.value.savedMessages +const savedMeta = searchContext.value.savedMeta + +if (u && saved) { + allMessages.value = { ...allMessages.value, [u]: saved } +} +if (u && savedMeta) { + messagesMeta.value = { ...messagesMeta.value, [u]: savedMeta } +} + +searchContext.value = { + active: false, + kind: 'search', + label: '', + username: '', + anchorId: '', + anchorIndex: -1, + hasMoreBefore: false, + hasMoreAfter: false, + loadingBefore: false, + loadingAfter: false, + savedMessages: null, + savedMeta: null +} +highlightMessageId.value = '' +await nextTick() +updateJumpToBottomState() +} + +const locateSearchHit = async (hit) => { +if (!process.client) return +if (!selectedAccount.value) return +if (!hit?.id) return + +const targetUsername = String(hit?.username || selectedContact.value?.username || '').trim() +if (!targetUsername) return + +const targetContact = contacts.value.find((c) => c?.username === targetUsername) +if (targetContact && selectedContact.value?.username !== targetUsername) { + await selectContact(targetContact, { skipLoadMessages: true }) +} + +if (searchContext.value?.active && searchContext.value.username !== targetUsername) { + await exitSearchContext() +} + +if (!searchContext.value?.active) { + searchContext.value = { + active: true, + kind: 'search', + label: '', + username: targetUsername, + anchorId: String(hit.id), + anchorIndex: -1, + hasMoreBefore: true, + hasMoreAfter: true, + loadingBefore: false, + loadingAfter: false, + savedMessages: allMessages.value[targetUsername] || [], + savedMeta: messagesMeta.value[targetUsername] || null + } +} else { + searchContext.value.kind = 'search' + searchContext.value.label = '' + searchContext.value.anchorId = String(hit.id) + searchContext.value.hasMoreBefore = true + searchContext.value.hasMoreAfter = true + searchContext.value.loadingBefore = false + searchContext.value.loadingAfter = false +} + +try { + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: targetUsername, + anchor_id: String(hit.id), + before: 35, + after: 35 + }) + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + allMessages.value = { ...allMessages.value, [targetUsername]: mapped } + messagesMeta.value = { ...messagesMeta.value, [targetUsername]: { total: mapped.length, hasMore: false } } + + searchContext.value.anchorId = String(resp?.anchorId || hit.id) + searchContext.value.anchorIndex = Number(resp?.anchorIndex ?? -1) + + const ok = await scrollToMessageId(searchContext.value.anchorId) + if (ok) flashMessage(searchContext.value.anchorId) +} catch (e) { + window.alert(e?.message || '定位失败') +} +} + +const locateByAnchorId = async ({ targetUsername, anchorId, kind, label } = {}) => { +if (!process.client) return +if (!selectedAccount.value) return +const u = String(targetUsername || selectedContact.value?.username || '').trim() +const anchor = String(anchorId || '').trim() +if (!u || !anchor) return + +const targetContact = contacts.value.find((c) => c?.username === u) +if (targetContact && selectedContact.value?.username !== u) { + await selectContact(targetContact, { skipLoadMessages: true }) +} + +if (searchContext.value?.active && searchContext.value.username !== u) { + await exitSearchContext() +} + +const kindNorm = String(kind || 'search').trim() || 'search' +const labelNorm = String(label || '').trim() +const hasMoreBeforeInit = kindNorm === 'first' ? false : true + +if (!searchContext.value?.active) { + searchContext.value = { + active: true, + kind: kindNorm, + label: labelNorm, + username: u, + anchorId: anchor, + anchorIndex: -1, + hasMoreBefore: hasMoreBeforeInit, + hasMoreAfter: true, + loadingBefore: false, + loadingAfter: false, + savedMessages: allMessages.value[u] || [], + savedMeta: messagesMeta.value[u] || null + } +} else { + searchContext.value.kind = kindNorm + searchContext.value.label = labelNorm + searchContext.value.anchorId = anchor + searchContext.value.username = u + searchContext.value.hasMoreBefore = hasMoreBeforeInit + searchContext.value.hasMoreAfter = true + searchContext.value.loadingBefore = false + searchContext.value.loadingAfter = false +} + +try { + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: u, + anchor_id: anchor, + before: 35, + after: 35 + }) + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + allMessages.value = { ...allMessages.value, [u]: mapped } + messagesMeta.value = { ...messagesMeta.value, [u]: { total: mapped.length, hasMore: false } } + + searchContext.value.anchorId = String(resp?.anchorId || anchor) + searchContext.value.anchorIndex = Number(resp?.anchorIndex ?? -1) + + const ok = await scrollToMessageId(searchContext.value.anchorId) + if (ok) flashMessage(searchContext.value.anchorId) +} catch (e) { + window.alert(e?.message || '定位失败') +} +} + +const locateByDate = async (dateStr) => { +if (!process.client) return +if (!selectedAccount.value) return +if (!selectedContact.value?.username) return + +const ds = String(dateStr || '').trim() +if (!ds) return +await _applyTimeSidebarSelectedDate(ds, { syncMonth: true }) + +try { + const resp = await api.getChatMessageAnchor({ + account: selectedAccount.value, + username: selectedContact.value.username, + kind: 'day', + date: ds + }) + const status = String(resp?.status || '') + const anchorId = String(resp?.anchorId || '').trim() + if (status !== 'success' || !anchorId) { + window.alert('当日暂无聊天记录') + return + } + await locateByAnchorId({ targetUsername: selectedContact.value.username, anchorId, kind: 'date', label: ds }) +} catch (e) { + window.alert(e?.message || '定位失败') +} +} + +const jumpToConversationFirst = async () => { +if (!process.client) return +if (!selectedAccount.value) return +if (!selectedContact.value?.username) return + +try { + const resp = await api.getChatMessageAnchor({ + account: selectedAccount.value, + username: selectedContact.value.username, + kind: 'first' + }) + const status = String(resp?.status || '') + const anchorId = String(resp?.anchorId || '').trim() + if (status !== 'success' || !anchorId) { + window.alert('暂无聊天记录') + return + } + const ds = _dateStrFromEpochSeconds(Number(resp?.createTime || 0)) + if (ds) await _applyTimeSidebarSelectedDate(ds, { syncMonth: true }) + await locateByAnchorId({ targetUsername: selectedContact.value.username, anchorId, kind: 'first', label: '' }) +} catch (e) { + window.alert(e?.message || '定位失败') +} +} + +const onTimeSidebarDayClick = async (cell) => { +if (!cell || cell.disabled) return +const ds = String(cell.dateStr || '').trim() +if (!ds) return +await locateByDate(ds) +} + +const _mergeContextMessages = (username, nextList) => { +const u = String(username || '').trim() +if (!u) return +const list = Array.isArray(nextList) ? nextList : [] +allMessages.value = { ...allMessages.value, [u]: list } +// Keep meta aligned; context mode doesn't rely on hasMore from meta. +const prevMeta = messagesMeta.value[u] || null +messagesMeta.value = { + ...messagesMeta.value, + [u]: { + total: Math.max(Number(prevMeta?.total || 0), list.length), + hasMore: false + } +} +} + +const loadMoreSearchContextAfter = async () => { +if (!process.client) return +if (!selectedAccount.value) return +if (!searchContext.value?.active) return +if (searchContext.value.loadingAfter) return +if (!searchContext.value.hasMoreAfter) return + +const u = String(searchContext.value.username || selectedContact.value?.username || '').trim() +if (!u) return +const existing = allMessages.value[u] || [] +const last = Array.isArray(existing) && existing.length ? existing[existing.length - 1] : null +const anchorId = String(last?.id || '').trim() +if (!anchorId) { + searchContext.value.hasMoreAfter = false + return +} + +const ctxUsername = u +searchContext.value.loadingAfter = true +try { + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: ctxUsername, + anchor_id: anchorId, + before: 0, + after: messagePageSize + }) + + if (!searchContext.value?.active || String(searchContext.value.username || '').trim() !== ctxUsername) return + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + + const existingIds = new Set(existing.map((m) => String(m?.id || ''))) + const appended = [] + for (const m of mapped) { + const id = String(m?.id || '').trim() + if (!id) continue + if (existingIds.has(id)) continue + existingIds.add(id) + appended.push(m) + } + + if (!appended.length) { + searchContext.value.hasMoreAfter = false + return + } + + _mergeContextMessages(ctxUsername, [...existing, ...appended]) +} catch (e) { + window.alert(e?.message || '加载更多消息失败') +} finally { + if (searchContext.value?.active && String(searchContext.value.username || '').trim() === ctxUsername) { + searchContext.value.loadingAfter = false + } +} +} + +const loadMoreSearchContextBefore = async () => { +if (!process.client) return +if (!selectedAccount.value) return +if (!searchContext.value?.active) return +if (searchContext.value.loadingBefore) return +if (!searchContext.value.hasMoreBefore) return + +const u = String(searchContext.value.username || selectedContact.value?.username || '').trim() +if (!u) return +const existing = allMessages.value[u] || [] +const first = Array.isArray(existing) && existing.length ? existing[0] : null +const anchorId = String(first?.id || '').trim() +if (!anchorId) { + searchContext.value.hasMoreBefore = false + return +} + +const c = messageContainerRef.value +const beforeScrollHeight = c ? c.scrollHeight : 0 +const beforeScrollTop = c ? c.scrollTop : 0 + +const ctxUsername = u +searchContext.value.loadingBefore = true +try { + const resp = await api.getChatMessagesAround({ + account: selectedAccount.value, + username: ctxUsername, + anchor_id: anchorId, + before: messagePageSize, + after: 0 + }) + + if (!searchContext.value?.active || String(searchContext.value.username || '').trim() !== ctxUsername) return + + const raw = resp?.messages || [] + const mapped = raw.map(normalizeMessage) + + const existingIds = new Set(existing.map((m) => String(m?.id || ''))) + const prepended = [] + for (const m of mapped) { + const id = String(m?.id || '').trim() + if (!id) continue + if (existingIds.has(id)) continue + existingIds.add(id) + prepended.push(m) + } + + if (!prepended.length) { + searchContext.value.hasMoreBefore = false + return + } + + _mergeContextMessages(ctxUsername, [...prepended, ...existing]) + + await nextTick() + const c2 = messageContainerRef.value + if (c2) { + const afterScrollHeight = c2.scrollHeight + c2.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight) + } +} catch (e) { + window.alert(e?.message || '加载更多消息失败') +} finally { + if (searchContext.value?.active && String(searchContext.value.username || '').trim() === ctxUsername) { + searchContext.value.loadingBefore = false + } +} +} + +const onSearchHitClick = async (hit, idx) => { +messageSearchSelectedIndex.value = Number(idx || 0) +await locateSearchHit(hit) +} + +const onSearchNext = async () => { +const q = String(messageSearchQuery.value || '').trim() +if (!q) return + +if (!messageSearchResults.value.length && !messageSearchLoading.value) { + await runMessageSearch({ reset: true }) +} +if (!messageSearchResults.value.length) return + +const cur = Number(messageSearchSelectedIndex.value || 0) +const next = (cur + 1) % messageSearchResults.value.length +messageSearchSelectedIndex.value = next +await locateSearchHit(messageSearchResults.value[next]) +} + +const onSearchPrev = async () => { +const q = String(messageSearchQuery.value || '').trim() +if (!q) return + +if (!messageSearchResults.value.length && !messageSearchLoading.value) { + await runMessageSearch({ reset: true }) +} +if (!messageSearchResults.value.length) return + +const cur = Number(messageSearchSelectedIndex.value || 0) +const prev = (cur - 1 + messageSearchResults.value.length) % messageSearchResults.value.length +messageSearchSelectedIndex.value = prev +await locateSearchHit(messageSearchResults.value[prev]) +} +const openMessageSearch = async () => { +closeTimeSidebar() +messageSearchOpen.value = true +ensureMessageSearchScopeValid() +await nextTick() +try { + messageSearchInputRef.value?.focus?.() +} catch {} +await fetchMessageSearchIndexStatus() +} +watch(messageSearchScope, async () => { +if (!messageSearchOpen.value) return +ensureMessageSearchScopeValid() +closeMessageSearchSenderDropdown() +messageSearchSender.value = '' +messageSearchSenderOptions.value = [] +messageSearchSenderOptionsKey.value = '' +await fetchMessageSearchSenders() +messageSearchOffset.value = 0 +messageSearchResults.value = [] +messageSearchSelectedIndex.value = -1 +if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) +} +}) + +watch(messageSearchRangeDays, async () => { +if (!messageSearchOpen.value) return +closeMessageSearchSenderDropdown() +messageSearchOffset.value = 0 +messageSearchResults.value = [] +messageSearchSelectedIndex.value = -1 +if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) +} +}) + +watch(messageSearchSessionType, async () => { +if (!messageSearchOpen.value) return +if (String(messageSearchScope.value || '') !== 'global') return +closeMessageSearchSenderDropdown() +messageSearchSender.value = '' +messageSearchSenderOptions.value = [] +messageSearchSenderOptionsKey.value = '' +await fetchMessageSearchSenders() +messageSearchOffset.value = 0 +messageSearchResults.value = [] +messageSearchSelectedIndex.value = -1 +if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) +} +}) + +watch([messageSearchStartDate, messageSearchEndDate], async () => { +if (!messageSearchOpen.value) return +if (String(messageSearchRangeDays.value || '') !== 'custom') return +closeMessageSearchSenderDropdown() +messageSearchOffset.value = 0 +messageSearchResults.value = [] +messageSearchSelectedIndex.value = -1 +if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) +} +}) + +watch(messageSearchSender, async () => { +if (!messageSearchOpen.value) return +messageSearchOffset.value = 0 +messageSearchResults.value = [] +messageSearchSelectedIndex.value = -1 +if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) +} +}) + +watch(messageSearchQuery, () => { +if (!messageSearchOpen.value) return +if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer) +messageSearchDebounceTimer = null +const q = String(messageSearchQuery.value || '').trim() +if (q.length < 2) return +messageSearchDebounceTimer = setTimeout(() => { + runMessageSearch({ reset: true }) +}, 280) +}) + +watch( +() => selectedContact.value?.username, +async () => { + if (!messageSearchOpen.value) return + if (String(messageSearchScope.value || '') !== 'conversation') return + closeMessageSearchSenderDropdown() + messageSearchSender.value = '' + messageSearchSenderOptions.value = [] + messageSearchSenderOptionsKey.value = '' + await fetchMessageSearchSenders() + if (String(messageSearchQuery.value || '').trim()) { + await runMessageSearch({ reset: true }) + } +} +) + +const autoLoadReady = ref(true) + +let timeSidebarScrollSyncRaf = null +const syncTimeSidebarSelectedDateFromScroll = () => { +if (!process.client) return +if (!timeSidebarOpen.value) return +if (!selectedContact.value) return + +const c = messageContainerRef.value +if (!c) return + +if (timeSidebarScrollSyncRaf) return +timeSidebarScrollSyncRaf = requestAnimationFrame(() => { + timeSidebarScrollSyncRaf = null + try { + const containerRect = c.getBoundingClientRect() + const targetY = containerRect.top + 24 + const els = c.querySelectorAll?.('[data-msg-id][data-create-time]') || [] + if (!els || !els.length) return + + let chosen = null + for (const el of els) { + const r = el.getBoundingClientRect?.() + if (!r) continue + if (r.bottom >= targetY) { + chosen = el + break + } + } + if (!chosen) chosen = els[els.length - 1] + const ts = Number(chosen?.getAttribute?.('data-create-time') || 0) + const ds = _dateStrFromEpochSeconds(ts) + if (!ds) return + // Don't await inside rAF; keep scroll handler snappy. + _applyTimeSidebarSelectedDate(ds, { syncMonth: true }) + } catch {} +}) +} + +const contextAutoLoadTopReady = ref(true) +const contextAutoLoadBottomReady = ref(true) + +const onMessageScrollInContextMode = async () => { +const c = messageContainerRef.value +if (!c) return +if (!searchContext.value?.active) return + +const distBottom = c.scrollHeight - c.scrollTop - c.clientHeight + +// Reset "ready" gates when user scrolls away from edges. +if (c.scrollTop > 160) contextAutoLoadTopReady.value = true +if (distBottom > 160) contextAutoLoadBottomReady.value = true + +if (c.scrollTop <= 60 && contextAutoLoadTopReady.value && searchContext.value.hasMoreBefore && !searchContext.value.loadingBefore) { + contextAutoLoadTopReady.value = false + await loadMoreSearchContextBefore() + return +} + +if (distBottom <= 80 && contextAutoLoadBottomReady.value && searchContext.value.hasMoreAfter && !searchContext.value.loadingAfter) { + contextAutoLoadBottomReady.value = false + await loadMoreSearchContextAfter() +} +} + +const onMessageScroll = async () => { +const c = messageContainerRef.value +if (!c) return +updateJumpToBottomState() +if (!selectedContact.value) return + +// Keep the time sidebar selection in sync with the current viewport. +syncTimeSidebarSelectedDateFromScroll() + +if (searchContext.value?.active) { + await onMessageScrollInContextMode() + return +} + +if (c.scrollTop > 120) { + autoLoadReady.value = true + return +} + +if (c.scrollTop <= 60 && autoLoadReady.value && hasMoreMessages.value && !isLoadingMessages.value) { + autoLoadReady.value = false + await loadMoreMessages() +} +} + + const resetSearchState = () => { + closeMessageSearch() + closeTimeSidebar() + timeSidebarYear.value = null + timeSidebarMonth.value = null + _applyTimeSidebarMonthData({ counts: {}, max: 0, total: 0 }) + timeSidebarError.value = '' + timeSidebarSelectedDate.value = '' + messageSearchResults.value = [] + messageSearchOffset.value = 0 + messageSearchHasMore.value = false + messageSearchBackendStatus.value = '' + messageSearchTotal.value = 0 + messageSearchIndexInfo.value = null + messageSearchSelectedIndex.value = -1 + searchContext.value = createEmptySearchContext() + highlightMessageId.value = '' + } + + onMounted(() => { + loadSearchHistory() + }) + + onUnmounted(() => { + if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer) + messageSearchDebounceTimer = null + stopMessageSearchIndexPolling() + if (timeSidebarScrollSyncRaf) { + cancelAnimationFrame(timeSidebarScrollSyncRaf) + timeSidebarScrollSyncRaf = null + } + }) + + return { + messageSearchOpen, + messageSearchQuery, + messageSearchScope, + messageSearchRangeDays, + messageSearchSessionType, + messageSearchSender, + messageSearchSenderOptions, + messageSearchSenderLoading, + messageSearchSenderError, + messageSearchSenderOptionsKey, + messageSearchSenderDropdownOpen, + messageSearchSenderDropdownRef, + messageSearchSenderDropdownInputRef, + messageSearchSenderDropdownQuery, + messageSearchStartDate, + messageSearchEndDate, + messageSearchResults, + messageSearchLoading, + messageSearchError, + messageSearchBackendStatus, + messageSearchIndexInfo, + messageSearchHasMore, + messageSearchOffset, + messageSearchTotal, + messageSearchSelectedIndex, + messageSearchInputRef, + searchInputFocused, + showAdvancedFilters, + searchHistory, + messageSearchIndexExists, + messageSearchIndexReady, + messageSearchIndexBuildStatus, + messageSearchIndexBuildIndexed, + messageSearchIndexMetaCount, + messageSearchIndexProgressText, + messageSearchIndexText, + messageSearchIndexActionText, + messageSearchIndexActionDisabled, + messageSearchSenderDisabled, + messageSearchSelectedSenderInfo, + messageSearchSelectedSenderInitial, + messageSearchSenderLabel, + filteredMessageSearchSenderOptions, + searchContextBannerText, + timeSidebarOpen, + timeSidebarYear, + timeSidebarMonth, + timeSidebarCounts, + timeSidebarMax, + timeSidebarTotal, + timeSidebarLoading, + timeSidebarError, + timeSidebarSelectedDate, + timeSidebarWeekdays, + timeSidebarMonthLabel, + timeSidebarYearOptions, + timeSidebarActiveDays, + timeSidebarCalendarCells, + getMessageSearchHitAvatarUrl, + getMessageSearchHitAvatarAlt, + getMessageSearchHitAvatarInitial, + closeMessageSearchSenderDropdown, + ensureMessageSearchSendersLoaded, + toggleMessageSearchSenderDropdown, + selectMessageSearchSender, + fetchMessageSearchIndexStatus, + fetchMessageSearchSenders, + onMessageSearchIndexAction, + closeMessageSearch, + closeTimeSidebar, + loadTimeSidebarMonth, + toggleTimeSidebar, + prevTimeSidebarMonth, + nextTimeSidebarMonth, + onTimeSidebarYearMonthChange, + toggleMessageSearch, + openMessageSearch, + runMessageSearch, + loadMoreSearchResults, + exitSearchContext, + locateSearchHit, + locateByAnchorId, + locateByDate, + jumpToConversationFirst, + onTimeSidebarDayClick, + loadMoreSearchContextAfter, + loadMoreSearchContextBefore, + onSearchHitClick, + onSearchNext, + onSearchPrev, + syncTimeSidebarSelectedDateFromScroll, + onMessageScrollInContextMode, + onMessageScroll, + clearSearchHistory, + applySearchHistory, + ensureMessageSearchScopeValid, + resetSearchState + } +} diff --git a/frontend/composables/chat/useChatSessions.js b/frontend/composables/chat/useChatSessions.js new file mode 100644 index 0000000..7b62b8f --- /dev/null +++ b/frontend/composables/chat/useChatSessions.js @@ -0,0 +1,362 @@ +import { computed, onMounted, ref } from 'vue' +import { normalizeSessionPreview } from '~/lib/chat/formatters' + +const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical' +const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width' +const SESSION_LIST_WIDTH_DEFAULT = 295 +const SESSION_LIST_WIDTH_MIN = 220 +const SESSION_LIST_WIDTH_MAX = 520 + +export const useChatSessions = async ({ chatAccounts, selectedAccount, realtimeEnabled, api }) => { + const showSearchAccountSwitcher = false + + const contacts = ref([]) + const selectedContact = ref(null) + const searchQuery = ref('') + const isLoadingContacts = ref(false) + const contactsError = ref('') + + const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT) + const sessionListResizing = ref(false) + + let sessionListResizeStartX = 0 + let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT + let sessionListResizeStartDpr = 1 + let sessionListResizePrevCursor = '' + let sessionListResizePrevUserSelect = '' + + const availableAccounts = computed(() => { + return Array.isArray(chatAccounts?.accounts) ? chatAccounts.accounts : [] + }) + + const clampSessionListWidth = (value) => { + const next = Number.isFinite(value) ? value : SESSION_LIST_WIDTH_DEFAULT + return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(next))) + } + + const loadSessionListWidth = () => { + if (!process.client) return + try { + const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY) + const value = parseInt(String(raw || ''), 10) + if (!Number.isNaN(value)) { + sessionListWidth.value = clampSessionListWidth(value) + return + } + + const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY) + const legacyValue = parseInt(String(legacy || ''), 10) + if (!Number.isNaN(legacyValue)) { + const dpr = window.devicePixelRatio || 1 + const converted = clampSessionListWidth(legacyValue * dpr) + sessionListWidth.value = converted + try { + localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted)) + localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY) + } catch {} + } + } catch {} + } + + const saveSessionListWidth = () => { + if (!process.client) return + try { + localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value))) + } catch {} + } + + const setSessionListResizingActive = (active) => { + if (!process.client) return + try { + const body = document.body + if (!body) return + if (active) { + sessionListResizePrevCursor = body.style.cursor || '' + sessionListResizePrevUserSelect = body.style.userSelect || '' + body.style.cursor = 'col-resize' + body.style.userSelect = 'none' + } else { + body.style.cursor = sessionListResizePrevCursor + body.style.userSelect = sessionListResizePrevUserSelect + sessionListResizePrevCursor = '' + sessionListResizePrevUserSelect = '' + } + } catch {} + } + + const onSessionListResizerPointerMove = (event) => { + if (!sessionListResizing.value) return + const clientX = Number(event?.clientX || 0) + sessionListWidth.value = clampSessionListWidth( + sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1) + ) + } + + const stopSessionListResize = () => { + if (!process.client) return + if (!sessionListResizing.value) return + sessionListResizing.value = false + setSessionListResizingActive(false) + try { + window.removeEventListener('pointermove', onSessionListResizerPointerMove) + } catch {} + saveSessionListWidth() + } + + const onSessionListResizerPointerUp = () => { + stopSessionListResize() + } + + const onSessionListResizerPointerDown = (event) => { + if (!process.client) return + try { + event?.preventDefault?.() + } catch {} + + sessionListResizing.value = true + sessionListResizeStartX = Number(event?.clientX || 0) + sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT) + sessionListResizeStartDpr = window.devicePixelRatio || 1 + setSessionListResizingActive(true) + + try { + window.addEventListener('pointermove', onSessionListResizerPointerMove) + window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true }) + } catch {} + } + + const resetSessionListWidth = () => { + sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT + saveSessionListWidth() + } + + onMounted(() => { + loadSessionListWidth() + }) + + const apiBase = useApiBase() + + const { data: prefetchedAccounts } = await useAsyncData('chat-accounts', () => { + if (process.server) { + const port = process.env.WECHAT_TOOL_PORT || '10392' + return $fetch('/api/chat/accounts', { baseURL: `http://127.0.0.1:${port}` }) + } + return $fetch('/chat/accounts', { baseURL: apiBase }) + }, { watch: false, lazy: true }) + + if (prefetchedAccounts.value?.accounts?.length && !chatAccounts.loaded) { + const response = prefetchedAccounts.value + chatAccounts.accounts = response.accounts + const preferred = chatAccounts.selectedAccount + const fallback = response.default_account || response.accounts[0] || '' + chatAccounts.selectedAccount = (preferred && response.accounts.includes(preferred)) ? preferred : fallback + chatAccounts.loaded = true + } + + const ssrSelectedAccount = chatAccounts.selectedAccount || '' + const { data: prefetchedSessions } = await useAsyncData(`chat-sessions-${ssrSelectedAccount}`, () => { + if (!ssrSelectedAccount) return Promise.resolve(null) + const params = new URLSearchParams({ + account: ssrSelectedAccount, + limit: '400', + include_hidden: 'false', + include_official: 'false' + }) + if (process.server) { + const port = process.env.WECHAT_TOOL_PORT || '10392' + return $fetch(`/api/chat/sessions?${params}`, { baseURL: `http://127.0.0.1:${port}` }) + } + return $fetch(`/chat/sessions?${params}`, { baseURL: apiBase }) + }, { watch: false, lazy: true }) + + if (prefetchedSessions.value?.sessions?.length) { + const ssrAvatars = new Map() + contacts.value = prefetchedSessions.value.sessions.map((session) => { + if (session.avatar) ssrAvatars.set(session.username || session.id, session.avatar) + return { + id: session.id, + name: session.name || session.username || session.id, + avatar: null, + lastMessage: normalizeSessionPreview(session.lastMessage || ''), + lastMessageTime: session.lastMessageTime || '', + unreadCount: session.unreadCount || 0, + isGroup: !!session.isGroup, + isTop: !!session.isTop, + username: session.username + } + }) + + if (process.client && ssrAvatars.size) { + const applySsrAvatars = () => { + const entries = Array.from(ssrAvatars.entries()) + const batchSize = 6 + let index = 0 + const next = () => { + const batch = entries.slice(index, index + batchSize) + if (!batch.length) return + for (const [key, url] of batch) { + const contact = contacts.value.find((item) => (item.username || item.id) === key) + if (contact) contact.avatar = url + } + index += batchSize + if (index < entries.length) setTimeout(next, 150) + } + next() + } + setTimeout(applySsrAvatars, 500) + } + } + + const filteredContacts = computed(() => { + const query = String(searchQuery.value || '').trim().toLowerCase() + if (!query) return contacts.value + return contacts.value.filter((contact) => { + const name = String(contact?.name || '').toLowerCase() + const username = String(contact?.username || '').toLowerCase() + return name.includes(query) || username.includes(query) + }) + }) + + const mapSessions = (sessions) => { + return sessions.map((session) => ({ + id: session.id, + name: session.name || session.username || session.id, + avatar: session.avatar || null, + lastMessage: normalizeSessionPreview(session.lastMessage || ''), + lastMessageTime: session.lastMessageTime || '', + unreadCount: session.unreadCount || 0, + isGroup: !!session.isGroup, + isTop: !!session.isTop, + username: session.username + })) + } + + const clearContactsState = (errorMessage = '') => { + contacts.value = [] + selectedContact.value = null + contactsError.value = errorMessage + } + + const loadSessionsForSelectedAccount = async () => { + if (!selectedAccount.value) { + clearContactsState('') + return [] + } + + const fetchSessions = async (source) => { + const params = { + account: selectedAccount.value, + limit: 400, + include_hidden: false, + include_official: false + } + if (source) params.source = source + return api.listChatSessions(params) + } + + let sessionsResp = null + if (realtimeEnabled?.value) { + try { + sessionsResp = await fetchSessions('realtime') + } catch { + sessionsResp = null + } + } + if (!sessionsResp) { + sessionsResp = await fetchSessions('') + } + + const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : [] + contacts.value = mapSessions(sessions) + contactsError.value = '' + return contacts.value + } + + const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => { + if (!process.client || typeof window === 'undefined') return + if (!selectedAccount.value) return + if (isLoadingContacts.value) return + + const previousUsername = selectedContact.value?.username || '' + const desiredSource = (sourceOverride != null) + ? String(sourceOverride || '').trim() + : (realtimeEnabled?.value ? 'realtime' : '') + + const params = { + account: selectedAccount.value, + limit: 400, + include_hidden: false, + include_official: false + } + + let sessionsResp = null + if (desiredSource) { + try { + sessionsResp = await api.listChatSessions({ ...params, source: desiredSource }) + } catch { + sessionsResp = null + } + } + if (!sessionsResp) { + try { + sessionsResp = await api.listChatSessions(params) + } catch { + return + } + } + + const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : [] + const nextContacts = mapSessions(sessions) + contacts.value = nextContacts + + if (previousUsername) { + const matched = nextContacts.find((contact) => contact.username === previousUsername) + if (matched) selectedContact.value = matched + } + } + + const loadContacts = async () => { + if (contacts.value.length && !isLoadingContacts.value) { + return { usedPrefetched: true } + } + + isLoadingContacts.value = true + contactsError.value = '' + try { + await chatAccounts.ensureLoaded() + + if (!selectedAccount.value) { + clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。') + return { usedPrefetched: false } + } + + await loadSessionsForSelectedAccount() + return { usedPrefetched: false } + } catch (error) { + clearContactsState(error?.message || '加载联系人失败') + return { usedPrefetched: false } + } finally { + isLoadingContacts.value = false + } + } + + return { + showSearchAccountSwitcher, + availableAccounts, + contacts, + selectedContact, + searchQuery, + filteredContacts, + isLoadingContacts, + contactsError, + sessionListWidth, + sessionListResizing, + clearContactsState, + loadContacts, + loadSessionsForSelectedAccount, + refreshSessionsForSelectedAccount, + onSessionListResizerPointerDown, + stopSessionListResize, + resetSessionListWidth + } +} diff --git a/frontend/lib/chat/chat-history.js b/frontend/lib/chat/chat-history.js new file mode 100644 index 0000000..6d1d388 --- /dev/null +++ b/frontend/lib/chat/chat-history.js @@ -0,0 +1,474 @@ +import { getChatHistoryPreviewLines } from '~/lib/chat/formatters' + +export const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim()) + +export const pickFirstMd5 = (...values) => { + for (const value of values) { + const text = String(value || '').trim() + if (isMaybeMd5(text)) return text.toLowerCase() + } + return '' +} + +export const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '') + +export const stripWeChatInvisible = (value) => { + return String(value || '').replace(/[\u3164\u2800]/g, '').trim() +} + +export const parseChatHistoryRecord = (recordItemXml) => { + if (!process.client) return { info: null, items: [] } + const xml = String(recordItemXml || '').trim() + if (!xml) return { info: null, items: [] } + + const normalized = xml + .replace(/ /g, ' ') + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '') + .replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[\da-fA-F]+;)/g, '&') + + let doc + try { + doc = new DOMParser().parseFromString(normalized, 'text/xml') + } catch { + return { info: null, items: [] } + } + + const parserErrors = doc.getElementsByTagName('parsererror') + if (parserErrors && parserErrors.length) return { info: null, items: [] } + + const getText = (node, tag) => { + try { + if (!node) return '' + const elements = Array.from(node.getElementsByTagName(tag) || []) + const direct = elements.find((el) => el && el.parentNode === node) + const target = direct || elements[0] + return String(target?.textContent || '').trim() + } catch { + return '' + } + } + + const getDirectChildXml = (node, tag) => { + try { + if (!node) return '' + const children = Array.from(node.children || []) + const target = children.find((child) => String(child?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()) + if (!target) return '' + const raw = String(target.textContent || '').trim() + if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw + if (typeof XMLSerializer !== 'undefined') { + return new XMLSerializer().serializeToString(target) + } + } catch {} + return '' + } + + const getAnyXml = (node, tag) => { + try { + if (!node) return '' + const elements = Array.from(node.getElementsByTagName(tag) || []) + const direct = elements.find((el) => el && el.parentNode === node) + const target = direct || elements[0] + if (!target) return '' + const raw = String(target.textContent || '').trim() + if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw + if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(target) + } catch {} + return '' + } + + const sameTag = (element, tag) => String(element?.tagName || '').toLowerCase() === String(tag || '').toLowerCase() + + const closestAncestorByTag = (node, tag) => { + const lower = String(tag || '').toLowerCase() + let current = node + while (current) { + if (current.nodeType === 1 && String(current.tagName || '').toLowerCase() === lower) return current + current = current.parentNode + } + return null + } + + const root = doc?.documentElement + const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1' + const title = getText(root, 'title') + const desc = getText(root, 'desc') || getText(root, 'info') + + const datalist = (() => { + try { + const all = Array.from(doc.getElementsByTagName('datalist') || []) + const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null + return top || all[0] || null + } catch { + return null + } + })() + + const datalistCount = (() => { + try { + if (!datalist) return 0 + const value = String(datalist.getAttribute('count') || '').trim() + return Math.max(0, parseInt(value, 10) || 0) + } catch { + return 0 + } + })() + + const itemNodes = (() => { + if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem')) + return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem')) + })() + + const parsed = itemNodes.map((node, idx) => { + const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim() + const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx) + + const sourcename = getText(node, 'sourcename') + const sourcetime = getText(node, 'sourcetime') + const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl')) + const datatitle = getText(node, 'datatitle') + const datadesc = getText(node, 'datadesc') + const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url')) + const datafmt = getText(node, 'datafmt') + const duration = getText(node, 'duration') + + const fullmd5 = getText(node, 'fullmd5') + const thumbfullmd5 = getText(node, 'thumbfullmd5') + const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5') + const fromnewmsgid = getText(node, 'fromnewmsgid') + const srcMsgLocalid = getText(node, 'srcMsgLocalid') || getText(node, 'srcMsgLocalId') + const srcMsgCreateTime = getText(node, 'srcMsgCreateTime') + const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring')) + const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring')) + const externurl = normalizeChatHistoryUrl(getText(node, 'externurl')) + const aeskey = getText(node, 'aeskey') + const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem') + + let content = datatitle || datadesc + if (!content) { + if (datatype === '4') content = '[视频]' + else if (datatype === '2' || datatype === '3') content = '[图片]' + else if (datatype === '47' || datatype === '37') content = '[表情]' + else if (datatype) content = `[消息 ${datatype}]` + else content = '[消息]' + } + + const fmt = String(datafmt || '').trim().toLowerCase().replace(/^\./, '') + const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif']) + + let renderType = 'text' + if (datatype === '17') { + renderType = 'chatHistory' + } else if (datatype === '5' || link) { + renderType = 'link' + } else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') { + renderType = 'video' + } else if (datatype === '47' || datatype === '37') { + renderType = 'emoji' + } else if ( + datatype === '2' + || datatype === '3' + || imageFormats.has(fmt) + || (datatype !== '1' && isMaybeMd5(fullmd5)) + ) { + renderType = 'image' + } else if (isMaybeMd5(md5) && /表情/.test(String(content || ''))) { + renderType = 'emoji' + } + + let outTitle = '' + let outUrl = '' + let recordItem = '' + if (renderType === 'chatHistory') { + outTitle = datatitle || content || '聊天记录' + content = datadesc || '' + recordItem = nestedRecordItem + } else if (renderType === 'link') { + outTitle = datatitle || content || '' + outUrl = link || externurl || '' + const cleanDesc = stripWeChatInvisible(datadesc) + const cleanTitle = stripWeChatInvisible(outTitle) + if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) { + content = '' + } else { + content = String(datadesc || '').trim() + } + } + + return { + id: dataid, + datatype, + sourcename, + sourcetime, + sourceheadurl, + datafmt, + duration, + fullmd5, + thumbfullmd5, + md5, + fromnewmsgid, + srcMsgLocalid, + srcMsgCreateTime, + cdnurlstring, + encrypturlstring, + externurl, + aeskey, + renderType, + title: outTitle, + recordItem, + url: outUrl, + content + } + }) + + return { + info: { isChatRoom, title, desc, count: datalistCount }, + items: parsed + } +} + +export const formatChatHistoryVideoDuration = (value) => { + const total = Math.max(0, parseInt(String(value || '').trim(), 10) || 0) + const minutes = Math.floor(total / 60) + const seconds = total % 60 + if (minutes <= 0) return `0:${String(seconds).padStart(2, '0')}` + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + +export const createChatHistoryRecordNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => { + return (record) => { + const account = encodeURIComponent(String(getSelectedAccount?.() || '').trim()) + const username = encodeURIComponent(String(getSelectedContact?.()?.username || '').trim()) + const output = { ...(record || {}) } + + output.senderDisplayName = String(output.sourcename || '').trim() + output.senderAvatar = normalizeChatHistoryUrl(output.sourceheadurl) + output.fullTime = String(output.sourcetime || '').trim() + + if (output.renderType === 'link') { + const linkUrl = String(output.url || output.externurl || '').trim() + output.url = linkUrl + output.from = String(output.from || '').trim() + const previewCandidates = [] + const fileId = (() => { + const localId = parseInt(String(output.srcMsgLocalid || '').trim(), 10) || 0 + const createTime = parseInt(String(output.srcMsgCreateTime || '').trim(), 10) || 0 + if (localId > 0 && createTime > 0) return `${localId}_${createTime}` + return '' + })() + if (fileId) { + previewCandidates.push( + `${apiBase}/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}` + ) + } + + output.previewMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5) + const srcServerId = String(output.fromnewmsgid || '').trim() + if (output.previewMd5) { + const previewParts = [ + `account=${account}`, + `md5=${encodeURIComponent(output.previewMd5)}`, + srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '', + `username=${username}` + ].filter(Boolean) + previewCandidates.push(`${apiBase}/chat/media/image?${previewParts.join('&')}`) + } + + output._linkPreviewCandidates = previewCandidates + output._linkPreviewCandidateIndex = 0 + output._linkPreviewError = false + output.preview = previewCandidates[0] || '' + + const fromUsername = String(output.fromUsername || '').trim() + output.fromUsername = fromUsername + output.fromAvatar = fromUsername + ? `${apiBase}/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}` + : (linkUrl ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '') + output._fromAvatarLast = output.fromAvatar + output._fromAvatarImgOk = false + output._fromAvatarImgError = false + } else if (output.renderType === 'video') { + output.videoMd5 = pickFirstMd5(output.fullmd5, output.md5) + output.videoThumbMd5 = pickFirstMd5(output.thumbfullmd5) + output.videoDuration = String(output.duration || '').trim() + const thumbCandidates = [] + if (output.videoMd5) { + thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`) + } + if (output.videoThumbMd5 && output.videoThumbMd5 !== output.videoMd5) { + thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoThumbMd5)}&username=${username}`) + } + output._videoThumbCandidates = thumbCandidates + output._videoThumbCandidateIndex = 0 + output._videoThumbError = false + output.videoThumbUrl = thumbCandidates[0] || '' + output.videoUrl = output.videoMd5 + ? `${apiBase}/chat/media/video?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}` + : '' + if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[视频]' + } else if (output.renderType === 'emoji') { + output.emojiMd5 = pickFirstMd5(output.md5, output.fullmd5, output.thumbfullmd5) + const remoteEmojiUrl = String(output.cdnurlstring || output.externurl || output.encrypturlstring || '').trim() + const remoteAesKey = String(output.aeskey || '').trim() + output.emojiRemoteUrl = remoteEmojiUrl + output.emojiUrl = output.emojiMd5 + ? `${apiBase}/chat/media/emoji?account=${account}&md5=${encodeURIComponent(output.emojiMd5)}&username=${username}${remoteEmojiUrl ? `&emoji_url=${encodeURIComponent(remoteEmojiUrl)}` : ''}${remoteAesKey ? `&aes_key=${encodeURIComponent(remoteAesKey)}` : ''}` + : '' + if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[表情]' + } else if (output.renderType === 'image') { + output.imageMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5) + const srcServerId = String(output.fromnewmsgid || '').trim() + const imageParts = [ + `account=${account}`, + output.imageMd5 ? `md5=${encodeURIComponent(output.imageMd5)}` : '', + srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '', + `username=${username}` + ].filter(Boolean) + output.imageUrl = imageParts.length ? `${apiBase}/chat/media/image?${imageParts.join('&')}` : '' + if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[图片]' + } + + return output + } +} + +export const enhanceChatHistoryRecords = (records) => { + const list = Array.isArray(records) ? records : [] + const videoByThumbMd5 = new Map() + const videoByMd5 = new Map() + const imageByMd5 = new Map() + const emojiByMd5 = new Map() + + for (const record of list) { + if (!record) continue + if (record.renderType === 'video' && record.videoThumbMd5) { + videoByThumbMd5.set(String(record.videoThumbMd5).toLowerCase(), record) + } + if (record.renderType === 'video' && record.videoMd5) { + videoByMd5.set(String(record.videoMd5).toLowerCase(), record) + } + if (record.renderType === 'image') { + const keys = [ + pickFirstMd5(record.imageMd5), + pickFirstMd5(record.fullmd5), + pickFirstMd5(record.thumbfullmd5) + ].filter(Boolean) + for (const key of keys) imageByMd5.set(key, record) + } + if (record.renderType === 'emoji') { + const keys = [ + pickFirstMd5(record.emojiMd5), + pickFirstMd5(record.md5), + pickFirstMd5(record.fullmd5), + pickFirstMd5(record.thumbfullmd5) + ].filter(Boolean) + for (const key of keys) emojiByMd5.set(key, record) + } + } + + for (const record of list) { + if (!record || String(record.renderType || '') !== 'text') continue + + const refKey = pickFirstMd5(record.thumbfullmd5) || pickFirstMd5(record.fullmd5) + if (!refKey) continue + + const video = videoByThumbMd5.get(refKey) || videoByMd5.get(refKey) + if (video) { + const quoteThumbCandidates = Array.isArray(video._videoThumbCandidates) ? video._videoThumbCandidates.slice() : [] + record._quoteThumbCandidates = quoteThumbCandidates + record._quoteThumbCandidateIndex = 0 + record._quoteThumbError = false + const quoteThumbUrl = quoteThumbCandidates[0] || video.videoThumbUrl || '' + record.renderType = 'quote' + record.quote = { + kind: 'video', + thumbUrl: quoteThumbUrl, + url: video.videoUrl || '', + duration: video.videoDuration || '', + label: video.content || '[视频]', + targetId: video.id || '' + } + record.quoteMedia = { + videoMd5: video.videoMd5, + videoThumbMd5: video.videoThumbMd5, + videoUrl: video.videoUrl, + videoThumbUrl: quoteThumbUrl + } + continue + } + + const image = imageByMd5.get(refKey) + if (image) { + record.renderType = 'quote' + record.quote = { + kind: 'image', + thumbUrl: image.imageUrl || '', + url: image.imageUrl || '', + label: image.content || '[图片]', + targetId: image.id || '' + } + record.quoteMedia = { + imageMd5: image.imageMd5, + imageUrl: image.imageUrl + } + continue + } + + const emoji = emojiByMd5.get(refKey) + if (emoji) { + record.renderType = 'quote' + record.quote = { + kind: 'emoji', + thumbUrl: emoji.emojiUrl || '', + url: emoji.emojiUrl || '', + label: emoji.content || '[表情]', + targetId: emoji.id || '' + } + record.quoteMedia = { + emojiMd5: emoji.emojiMd5, + emojiUrl: emoji.emojiUrl + } + } + } + + return list +} + +export const isChatHistoryRecordItemIncomplete = (recordItemXml) => { + const recordItem = String(recordItemXml || '').trim() + if (!recordItem) return true + try { + const parsed = parseChatHistoryRecord(recordItem) + const got = Array.isArray(parsed?.items) ? parsed.items.length : 0 + const expected = Math.max(0, parseInt(String(parsed?.info?.count || '0'), 10) || 0) + if (expected > 0 && got < expected) return true + if (got <= 0) return true + } catch { + return true + } + return false +} + +export const buildChatHistoryWindowPayload = (payload, normalizeRecordItem) => { + const title0 = String(payload?.title || '聊天记录') + const content0 = String(payload?.content || '') + const recordItem0 = String(payload?.recordItem || '').trim() + const parsed = parseChatHistoryRecord(recordItem0) + const info0 = parsed?.info || { isChatRoom: false, count: 0 } + const items = Array.isArray(parsed?.items) ? parsed.items : [] + let records0 = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : [] + if (!records0.length) { + const lines = content0.trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean) + records0 = lines.map((line, idx) => normalizeRecordItem({ + id: String(idx), + datatype: '1', + sourcename: '', + sourcetime: '', + content: line, + renderType: 'text' + })) + } + return { title0, content0, recordItem0, info0, records0 } +} + +export { getChatHistoryPreviewLines } diff --git a/frontend/lib/chat/file-icons.js b/frontend/lib/chat/file-icons.js new file mode 100644 index 0000000..090fc82 --- /dev/null +++ b/frontend/lib/chat/file-icons.js @@ -0,0 +1,50 @@ +import zipIconUrl from '~/assets/images/wechat/zip.png' +import pdfIconUrl from '~/assets/images/wechat/pdf.png' +import wordIconUrl from '~/assets/images/wechat/word.png' +import excelIconUrl from '~/assets/images/wechat/excel.png' + +export const getFileIconKind = (fileName) => { + if (!fileName) return 'default' + const ext = String(fileName).split('.').pop()?.toLowerCase() || '' + switch (ext) { + case 'pdf': + return 'pdf' + case 'zip': + case 'rar': + case '7z': + case 'tar': + case 'gz': + return 'zip' + case 'doc': + case 'docx': + return 'doc' + case 'xls': + case 'xlsx': + case 'csv': + return 'xls' + case 'ppt': + case 'pptx': + return 'ppt' + case 'txt': + case 'md': + case 'log': + return 'txt' + default: + return 'default' + } +} + +export const getFileIconUrl = (fileName) => { + switch (getFileIconKind(fileName)) { + case 'pdf': + return pdfIconUrl + case 'doc': + return wordIconUrl + case 'xls': + return excelIconUrl + case 'zip': + return zipIconUrl + default: + return '' + } +} diff --git a/frontend/lib/chat/formatters.js b/frontend/lib/chat/formatters.js new file mode 100644 index 0000000..1d85f3d --- /dev/null +++ b/frontend/lib/chat/formatters.js @@ -0,0 +1,211 @@ +export const normalizeSessionPreview = (value) => { + const text = String(value || '').trim() + if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]') + if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]') + return text +} + +export const formatSmartTime = (ts) => { + if (!ts) return '' + try { + const date = new Date(Number(ts) * 1000) + const now = new Date() + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + const timeStr = `${hh}:${mm}` + + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const targetStart = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const dayDiff = Math.floor((todayStart - targetStart) / (1000 * 60 * 60 * 24)) + + if (dayDiff === 0) return timeStr + if (dayDiff === 1) return `昨天 ${timeStr}` + if (dayDiff >= 2 && dayDiff <= 6) { + const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] + return `${weekdays[date.getDay()]} ${timeStr}` + } + + const month = date.getMonth() + 1 + const day = date.getDate() + if (date.getFullYear() === now.getFullYear()) { + return `${month}月${day}日 ${timeStr}` + } + + return `${date.getFullYear()}年${month}月${day}日 ${timeStr}` + } catch { + return '' + } +} + +export const formatTimeDivider = (ts) => formatSmartTime(ts) + +export const formatMessageTime = (ts) => { + if (!ts) return '' + try { + const date = new Date(Number(ts) * 1000) + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + return `${hh}:${mm}` + } catch { + return '' + } +} + +export const formatMessageFullTime = (ts) => { + if (!ts) return '' + try { + const date = new Date(Number(ts) * 1000) + const yyyy = String(date.getFullYear()) + const MM = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const hh = String(date.getHours()).padStart(2, '0') + const mm = String(date.getMinutes()).padStart(2, '0') + const ss = String(date.getSeconds()).padStart(2, '0') + return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}` + } catch { + return '' + } +} + +export const formatFileSize = (size) => { + if (!size) return '' + const text = String(size).trim() + const value = parseFloat(text) + if (Number.isNaN(value)) return text + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB` + return `${(value / 1024 / 1024).toFixed(2)} MB` +} + +export const formatTransferAmount = (amount) => { + const text = String(amount ?? '').trim() + if (!text) return '' + return text.replace(/[¥¥]/g, '').trim() +} + +export const getRedPacketText = (message) => { + const text = String(message?.content ?? '').trim() + if (!text || text === '[Red Packet]') return '恭喜发财,大吉大利' + return text +} + +export const isTransferReturned = (message) => { + const paySubType = String(message?.paySubType || '').trim() + if (paySubType === '4' || paySubType === '9') return true + const status = String(message?.transferStatus || '').trim() + const content = String(message?.content || '').trim() + const text = `${status} ${content}`.trim() + if (!text) return false + return text.includes('退回') || text.includes('退还') +} + +export const isTransferOverdue = (message) => { + const paySubType = String(message?.paySubType || '').trim() + if (paySubType === '10') return true + const status = String(message?.transferStatus || '').trim() + const content = String(message?.content || '').trim() + const text = `${status} ${content}`.trim() + if (!text) return false + return text.includes('过期') +} + +export const getTransferTitle = (message) => { + const paySubType = String(message?.paySubType || '').trim() + if (message?.transferStatus) return message.transferStatus + switch (paySubType) { + case '1': + return '转账' + case '3': + return message?.isSent ? '已被接收' : '已收款' + case '8': + return '发起转账' + case '4': + return '已退还' + case '9': + return '已被退还' + case '10': + return '已过期' + default: + break + } + if (message?.content && message.content !== '转账' && message.content !== '[转账]') { + return message.content + } + return '转账' +} + +export const formatCount = (count) => { + const value = Number(count || 0) + if (!Number.isFinite(value) || value <= 0) return '' + try { + return value.toLocaleString() + } catch { + return String(value) + } +} + +export const escapeHtml = (value) => { + if (!value) return '' + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export const highlightKeyword = (text, keyword) => { + if (!text || !keyword) return escapeHtml(text || '') + const escaped = escapeHtml(text) + const kw = String(keyword || '').trim() + if (!kw) return escaped + try { + const escapedKw = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`(${escapedKw})`, 'gi') + return escaped.replace(regex, '$1') + } catch { + return escaped + } +} + +export const getVoiceDurationInSeconds = (durationMs) => { + const value = Number(durationMs || 0) + if (!Number.isFinite(value) || value <= 0) return 0 + return Math.max(1, Math.round(value / 1000)) +} + +export const getVoiceWidth = (durationMs) => { + const seconds = getVoiceDurationInSeconds(durationMs) + const clamped = Math.min(60, Math.max(1, seconds)) + return `${80 + clamped * 4}px` +} + +export const toUnixSeconds = (datetimeLocal) => { + const value = String(datetimeLocal || '').trim() + if (!value) return null + const date = new Date(value) + const ms = date.getTime() + if (Number.isNaN(ms)) return null + return Math.floor(ms / 1000) +} + +export const dateToUnixSeconds = (dateStr, endOfDay = false) => { + const value = String(dateStr || '').trim() + if (!value) return null + const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!matched) return null + const year = Number(matched[1]) + const month = Number(matched[2]) + const day = Number(matched[3]) + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null + const date = new Date(year, month - 1, day, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0) + const ms = date.getTime() + if (Number.isNaN(ms)) return null + return Math.floor(ms / 1000) +} + +export const getChatHistoryPreviewLines = (message) => { + const raw = String(message?.content || '').trim() + if (!raw) return [] + return raw.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).slice(0, 4) +} diff --git a/frontend/lib/chat/message-normalizer.js b/frontend/lib/chat/message-normalizer.js new file mode 100644 index 0000000..5ac676c --- /dev/null +++ b/frontend/lib/chat/message-normalizer.js @@ -0,0 +1,265 @@ +import { formatMessageFullTime, formatMessageTime } from '~/lib/chat/formatters' + +const normalizeMaybeUrl = (value) => (typeof value === 'string' ? value.trim() : '') + +const isUsableMediaUrl = (value) => { + const text = normalizeMaybeUrl(value) + if (!text) return false + return ( + /^https?:\/\//i.test(text) + || /^blob:/i.test(text) + || /^data:/i.test(text) + || /^\/api\/chat\/media\//i.test(text) + ) +} + +const buildAccountMediaUrl = (apiBase, path, parts) => { + return `${apiBase}${path}?${parts.filter(Boolean).join('&')}` +} + +export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => { + return (msg) => { + const account = String(getSelectedAccount?.() || '').trim() + const contact = getSelectedContact?.() || null + const username = String(contact?.username || '').trim() + const isSent = !!msg.isSent + const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '') + const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null + + const normalizedThumbUrl = (() => { + const candidates = [msg.thumbUrl, msg.preview] + for (const candidate of candidates) { + if (isUsableMediaUrl(candidate)) return normalizeMaybeUrl(candidate) + } + return '' + })() + + const normalizedLinkPreviewUrl = (() => { + const url = normalizedThumbUrl + if (!url) return '' + if (/^\/api\/chat\/media\//i.test(url) || /^blob:/i.test(url) || /^data:/i.test(url)) return url + if (!/^https?:\/\//i.test(url)) return url + try { + const host = new URL(url).hostname.toLowerCase() + if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) { + return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(url)}` + } + } catch {} + return url + })() + + const fromUsername = String(msg.fromUsername || '').trim() + const fromAvatar = fromUsername + ? `${apiBase}/chat/avatar?account=${encodeURIComponent(account)}&username=${encodeURIComponent(fromUsername)}` + : (() => { + const href = String(msg.url || '').trim() + return href ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(href)}` : '' + })() + + const localEmojiUrl = msg.emojiMd5 + ? `${apiBase}/chat/media/emoji?account=${encodeURIComponent(account)}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(username)}` + : '' + + const localImageUrl = (() => { + if (!msg.imageMd5 && !msg.imageFileId) return '' + return buildAccountMediaUrl(apiBase, '/chat/media/image', [ + `account=${encodeURIComponent(account)}`, + msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '', + msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '', + `username=${encodeURIComponent(username)}` + ]) + })() + + const normalizedImageUrl = (() => { + const current = isUsableMediaUrl(msg.imageUrl) ? normalizeMaybeUrl(msg.imageUrl) : '' + if (current && /\/api\/chat\/media\/image\b/i.test(current) && localImageUrl) { + return localImageUrl + } + return current || localImageUrl || '' + })() + + const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl + + const localVideoThumbUrl = (() => { + if (!msg.videoThumbMd5 && !msg.videoThumbFileId) return '' + return buildAccountMediaUrl(apiBase, '/chat/media/video_thumb', [ + `account=${encodeURIComponent(account)}`, + msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '', + msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '', + `username=${encodeURIComponent(username)}` + ]) + })() + + const localVideoUrl = (() => { + if (!msg.videoMd5 && !msg.videoFileId) return '' + return buildAccountMediaUrl(apiBase, '/chat/media/video', [ + `account=${encodeURIComponent(account)}`, + msg.videoMd5 ? `md5=${encodeURIComponent(msg.videoMd5)}` : '', + msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '', + `username=${encodeURIComponent(username)}` + ]) + })() + + const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl + const normalizedVideoUrl = (isUsableMediaUrl(msg.videoUrl) ? normalizeMaybeUrl(msg.videoUrl) : '') || localVideoUrl + const serverIdStr = String(msg.serverIdStr || (msg.serverId != null ? String(msg.serverId) : '')).trim() + const normalizedVoiceUrl = (() => { + if (msg.voiceUrl) return msg.voiceUrl + if (!serverIdStr) return '' + if (String(msg.renderType || '') !== 'voice') return '' + return `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(serverIdStr)}` + })() + + const remoteFromServer = ( + typeof msg.emojiRemoteUrl === 'string' + && /^https?:\/\//i.test(msg.emojiRemoteUrl) + && !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiRemoteUrl) + && !/\blocalhost\b/i.test(msg.emojiRemoteUrl) + && !/\b127\.0\.0\.1\b/i.test(msg.emojiRemoteUrl) + ) ? msg.emojiRemoteUrl : '' + + const remoteFromEmojiUrl = ( + typeof msg.emojiUrl === 'string' + && /^https?:\/\//i.test(msg.emojiUrl) + && !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiUrl) + && !/\blocalhost\b/i.test(msg.emojiUrl) + && !/\b127\.0\.0\.1\b/i.test(msg.emojiUrl) + ) ? msg.emojiUrl : '' + + const emojiRemoteUrl = remoteFromServer || remoteFromEmojiUrl + const emojiIsLocal = typeof normalizedEmojiUrl === 'string' && /\/api\/chat\/media\/emoji\b/i.test(normalizedEmojiUrl) + const emojiDownloaded = !!emojiRemoteUrl && !!emojiIsLocal + + const replyText = String(msg.content || '').trim() + let quoteContent = String(msg.quoteContent || '') + const trimmedQuoteContent = quoteContent.trim() + if (replyText && trimmedQuoteContent) { + if (trimmedQuoteContent === replyText) { + quoteContent = '' + } else { + const lines = trimmedQuoteContent.split(/\r?\n/).map((item) => item.trim()) + if (lines.length && (lines[0] === replyText || lines[0] === replyText.split(/\r?\n/)[0]?.trim())) { + quoteContent = trimmedQuoteContent.split(/\r?\n/).slice(1).join('\n').trim() + } else if (trimmedQuoteContent.startsWith(replyText)) { + quoteContent = trimmedQuoteContent.slice(replyText.length).trim() + } + } + } + + const quoteServerIdStr = String(msg.quoteServerId || '').trim() + const quoteTypeStr = String(msg.quoteType || '').trim() + const quoteVoiceUrl = quoteServerIdStr + ? `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(quoteServerIdStr)}` + : '' + + const quoteImageUrl = (() => { + if (!quoteServerIdStr) return '' + if (quoteTypeStr !== '3' && String(msg.quoteContent || '').trim() !== '[图片]') return '' + return buildAccountMediaUrl(apiBase, '/chat/media/image', [ + `account=${encodeURIComponent(account)}`, + `server_id=${encodeURIComponent(quoteServerIdStr)}`, + username ? `username=${encodeURIComponent(username)}` : '' + ]) + })() + + const quoteThumbUrl = (() => { + const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : '' + if (!raw) return '' + if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw + if (!/^https?:\/\//i.test(raw)) return raw + try { + const host = new URL(raw).hostname.toLowerCase() + if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) { + return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}` + } + } catch {} + return raw + })() + + return { + id: msg.id, + serverId: msg.serverId || 0, + serverIdStr, + sender, + senderUsername: msg.senderUsername || '', + senderDisplayName: msg.senderDisplayName || '', + content: msg.content || '', + time: formatMessageTime(msg.createTime), + fullTime: formatMessageFullTime(msg.createTime), + createTime: Number(msg.createTime || 0), + isSent, + type: 'text', + renderType: msg.renderType || 'text', + voipType: msg.voipType || '', + title: msg.title || '', + url: msg.url || '', + recordItem: msg.recordItem || '', + imageMd5: msg.imageMd5 || '', + imageFileId: msg.imageFileId || '', + emojiMd5: msg.emojiMd5 || '', + emojiUrl: normalizedEmojiUrl || '', + emojiLocalUrl: localEmojiUrl || '', + emojiRemoteUrl, + _emojiDownloaded: !!emojiDownloaded, + thumbUrl: msg.thumbUrl || '', + imageUrl: normalizedImageUrl || '', + videoMd5: msg.videoMd5 || '', + videoThumbMd5: msg.videoThumbMd5 || '', + videoFileId: msg.videoFileId || '', + videoThumbFileId: msg.videoThumbFileId || '', + videoThumbUrl: normalizedVideoThumbUrl || '', + videoUrl: normalizedVideoUrl || '', + quoteTitle: msg.quoteTitle || '', + quoteContent, + quoteUsername: msg.quoteUsername || '', + quoteServerId: quoteServerIdStr, + quoteType: quoteTypeStr, + quoteVoiceLength: msg.quoteVoiceLength || '', + quoteVoiceUrl, + quoteImageUrl: quoteImageUrl || '', + quoteThumbUrl: quoteThumbUrl || '', + _quoteImageError: false, + _quoteThumbError: false, + amount: msg.amount || '', + coverUrl: msg.coverUrl || '', + fileSize: msg.fileSize || '', + fileMd5: msg.fileMd5 || '', + paySubType: msg.paySubType || '', + transferStatus: msg.transferStatus || '', + transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收', + voiceUrl: normalizedVoiceUrl || '', + voiceDuration: msg.voiceLength || msg.voiceDuration || '', + locationLat: msg.locationLat ?? null, + locationLng: msg.locationLng ?? null, + locationPoiname: String(msg.locationPoiname || '').trim(), + locationLabel: String(msg.locationLabel || '').trim(), + preview: normalizedLinkPreviewUrl || '', + linkType: String(msg.linkType || '').trim(), + linkStyle: String(msg.linkStyle || '').trim(), + linkCardVariant: String(msg.linkStyle || '').trim() === 'cover' ? 'cover' : 'default', + from: String(msg.from || '').trim(), + fromUsername, + fromAvatar, + isGroup: !!contact?.isGroup, + avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null, + avatarColor: null + } + } +} + +export const dedupeMessagesById = (list) => { + const input = Array.isArray(list) ? list : [] + const seen = new Set() + const output = [] + for (const item of input) { + const id = String(item?.id || '') + if (!id) { + output.push(item) + continue + } + if (seen.has(id)) continue + seen.add(id) + output.push(item) + } + return output +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 7472053..7fe1eae 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -40,6 +40,11 @@ export default defineNuxtConfig({ } }, + // 应用配置 + css: [ + '~/assets/css/chat.css' + ], + // 应用配置 app: { head: { diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index e2b74e7..6ca5736 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -1,2550 +1,57 @@ - -