mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 支持微信 4.0 数据库解密与富媒体消息查看
- 接入微信 4.0 WCDB 数据库解密,兼容微信 3.0 解密方式 - 支持图片解密及图片预览、缩放、旋转和拖动 - 支持语音解密与播放 - 支持表情包、引用、分享、名片、位置及通话消息解析 - 支持联系人和群聊真实头像 - 优化群聊、联系人分类及排序 - 修复复合消息类型和压缩消息内容解析 - 完善原生解密库打包及数据库错误提示
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
|
||||
/>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ function App(): React.ReactElement {
|
||||
const keyToUse = keyInput || dbKey
|
||||
if (!keyToUse) return
|
||||
try {
|
||||
const success = await window.api.initDb(keyToUse)
|
||||
const result = await window.api.initDb(keyToUse)
|
||||
const success = typeof result === 'boolean' ? result : result.success
|
||||
if (success) {
|
||||
setIsAuthenticated(true)
|
||||
loadContacts()
|
||||
} else {
|
||||
alert('Failed to open database. Check your key.')
|
||||
const error = typeof result === 'boolean' ? '' : result.error
|
||||
alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -133,6 +133,12 @@ body {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.section-empty {
|
||||
padding: 10px 14px 12px 27px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
@@ -159,6 +165,16 @@ body {
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-avatar img,
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
@@ -215,6 +231,319 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wechat-message-list {
|
||||
padding: 18px 28px;
|
||||
gap: 14px;
|
||||
background-color: #edf1f2;
|
||||
background-image:
|
||||
radial-gradient(circle at 20px 20px, rgba(0, 0, 0, 0.025) 1px, transparent 1px),
|
||||
radial-gradient(circle at 80px 70px, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 120px 120px;
|
||||
}
|
||||
|
||||
.wechat-message-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
background: #d6d6d6;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex: 0 0 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mine-avatar {
|
||||
background: #607d86;
|
||||
}
|
||||
|
||||
.message-stack {
|
||||
max-width: min(68%, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-stack {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-sender-name {
|
||||
color: #6f777a;
|
||||
font-size: 12px;
|
||||
margin: 0 0 4px 2px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
position: relative;
|
||||
padding: 9px 13px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-bubble {
|
||||
background: #516d76;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
}
|
||||
|
||||
.wechat-message-row.other .message-bubble::before {
|
||||
left: -6px;
|
||||
border-right: 6px solid #fff;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-bubble::before {
|
||||
right: -6px;
|
||||
border-left: 6px solid #516d76;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
color: #9aa1a4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-bubble {
|
||||
min-width: 138px;
|
||||
}
|
||||
|
||||
.image-message-bubble {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .image-message-bubble {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.image-message-bubble::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-bubble {
|
||||
position: relative;
|
||||
max-width: min(260px, 46vw);
|
||||
max-height: 220px;
|
||||
min-width: 96px;
|
||||
min-height: 72px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.image-content {
|
||||
display: block;
|
||||
max-width: min(260px, 46vw);
|
||||
max-height: 220px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.image-loading,
|
||||
.image-placeholder,
|
||||
.image-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
color: #768184;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.image-placeholder-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.image-bubble:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-action-btn {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.voice-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voice-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .voice-icon {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.voice-bars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.voice-bars i {
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: currentColor;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.voice-bars i:nth-child(1) {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.voice-bars i:nth-child(2) {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.voice-bars i:nth-child(3) {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.sticker-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sticker-image {
|
||||
display: block;
|
||||
max-width: 140px;
|
||||
max-height: 140px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sticker-placeholder {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sticker-md5 {
|
||||
max-width: 180px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quote-message {
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wechat-message-row.other .quoted-message {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quoted-sender {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quoted-sender::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.quoted-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.quote-reply {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -272,7 +601,8 @@ body {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.col-content {}
|
||||
.col-content {
|
||||
}
|
||||
|
||||
.chat-toolbar {
|
||||
padding: 10px;
|
||||
@@ -377,4 +707,278 @@ body {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-viewer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(16, 24, 28, 0.28);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.image-viewer-window {
|
||||
width: min(900px, 86vw);
|
||||
height: min(700px, 82vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: 8px;
|
||||
background: #eaf0f1;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.image-viewer-titlebar {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 0 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-viewer-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-viewer-title {
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-viewer-zoom {
|
||||
min-width: 44px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-viewer-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.image-viewer-titlebar button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image-viewer-titlebar button:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.image-viewer-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.12);
|
||||
transform-origin: center center;
|
||||
transition: transform 0.08s ease-out;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Voice Player */
|
||||
.voice-message {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voice-loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.voice-error {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.voice-loading-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.voice-error-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.voice-icon.playing {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Rich Message Bubbles */
|
||||
.location-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.location-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.location-coords {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .location-message {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.card-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-nickname {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-username {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-username:hover {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.share-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.share-appname {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.share-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.share-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.share-url {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.voip-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voip-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.voip-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { toPng } from 'html-to-image'
|
||||
import { Message, Contact } from '../../../shared/types'
|
||||
import { VoicePlayer } from './VoicePlayer'
|
||||
import { RichMessageBubble } from './RichMessageBubble'
|
||||
import { ImageBubble } from './ImageBubble'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
@@ -43,12 +46,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [showAvatar, setShowAvatar] = useState(false)
|
||||
|
||||
const [colWidths, setColWidths] = useState([150, 100, 180, 400])
|
||||
const [resizingColIndex, setResizingColIndex] = useState<number | null>(null)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [imageScale, setImageScale] = useState(0.75)
|
||||
const [imageRotation, setImageRotation] = useState(0)
|
||||
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
|
||||
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
|
||||
null
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
|
||||
// AI Settings
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
@@ -74,32 +79,54 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
const startResizing = (index: number, e: React.MouseEvent): void => {
|
||||
e.preventDefault()
|
||||
setResizingColIndex(index)
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = colWidths[index]
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
setImageScale(0.75)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent): void => {
|
||||
if (resizingColIndex === null) return
|
||||
const diff = e.clientX - startXRef.current
|
||||
const newWidth = Math.max(50, startWidthRef.current + diff)
|
||||
const closeImagePreview = (): void => {
|
||||
setPreviewImage(null)
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
setColWidths((prev) => {
|
||||
const newCols = [...prev]
|
||||
newCols[resizingColIndex] = newWidth
|
||||
return newCols
|
||||
const zoomImage = (delta: number): void => {
|
||||
setImageScale((prev) => Math.min(3, Math.max(0.25, Number((prev + delta).toFixed(2)))))
|
||||
}
|
||||
|
||||
const resetImageTransform = (): void => {
|
||||
setImageScale(0.75)
|
||||
setImageRotation(0)
|
||||
setImageOffset({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleViewerWheel = (event: React.WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
|
||||
}
|
||||
|
||||
const handleViewerMouseDown = (event: React.MouseEvent): void => {
|
||||
event.preventDefault()
|
||||
imageDragRef.current = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
offsetX: imageOffset.x,
|
||||
offsetY: imageOffset.y
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewerMouseMove = (event: React.MouseEvent): void => {
|
||||
if (!imageDragRef.current) return
|
||||
const drag = imageDragRef.current
|
||||
setImageOffset({
|
||||
x: drag.offsetX + event.clientX - drag.x,
|
||||
y: drag.offsetY + event.clientY - drag.y
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
setResizingColIndex(null)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
const handleViewerMouseUp = (): void => {
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
const handleExport = (days: number | 'all'): void => {
|
||||
@@ -172,8 +199,13 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const filteredMessages = messages
|
||||
.filter((msg) => !'分享消息,图片,表情包,视频'.split(',').includes(msg.type))
|
||||
.map((msg) => {
|
||||
const { img, id, isSender, ...rest } = msg
|
||||
return rest
|
||||
return {
|
||||
from: msg.from,
|
||||
type: msg.type,
|
||||
datetime: msg.datetime,
|
||||
content: msg.content,
|
||||
name: msg.name
|
||||
}
|
||||
})
|
||||
const recentMessages = filteredMessages
|
||||
.map((msg) => {
|
||||
@@ -248,8 +280,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '分享消息,图片,表情包,视频')
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
const typeMatch = !filterTypes.includes(msg.type)
|
||||
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||
@@ -274,74 +307,67 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
<div className="window-controls"></div>
|
||||
</div>
|
||||
|
||||
<div className="message-list">
|
||||
<table className="chat-table" style={{ tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: colWidths[0], position: 'relative' }}>
|
||||
发送者
|
||||
<div className="column-resizer" onMouseDown={(e) => startResizing(0, e)} />
|
||||
</th>
|
||||
<th style={{ width: colWidths[1], position: 'relative' }}>
|
||||
类型
|
||||
<div className="column-resizer" onMouseDown={(e) => startResizing(1, e)} />
|
||||
</th>
|
||||
<th style={{ width: colWidths[2], position: 'relative' }}>
|
||||
时间
|
||||
<div className="column-resizer" onMouseDown={(e) => startResizing(2, e)} />
|
||||
</th>
|
||||
<th style={{ width: colWidths[3] }}>内容</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleMessages.map((msg) => (
|
||||
<tr key={msg.id}>
|
||||
<td
|
||||
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={msg.from}
|
||||
>
|
||||
{msg.from}
|
||||
</td>
|
||||
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{msg.type}
|
||||
</td>
|
||||
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{msg.datetime}
|
||||
</td>
|
||||
<td style={{ wordBreak: 'break-all', display: 'flex', alignItems: 'center' }}>
|
||||
{showAvatar && msg?.img && (
|
||||
<img style={{ width: '40px', height: '40px' }} src={msg?.img}></img>
|
||||
<div className="message-list wechat-message-list">
|
||||
{visibleMessages.map((msg) => {
|
||||
const isMine = msg.from === 'assistant'
|
||||
const displayName = isMine
|
||||
? '我'
|
||||
: isGroupChat
|
||||
? msg.name || msg.from
|
||||
: contact.m_nsNickName
|
||||
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
|
||||
const isVoice = msg.type === '语音'
|
||||
const isImage = msg.type === '图片'
|
||||
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包'].includes(msg.type)
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
|
||||
{!isMine && showAvatar && (
|
||||
<div className="message-avatar">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
(displayName || '?').charAt(0)
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: !showAvatar ? 'flex' : 'block'
|
||||
}}
|
||||
>
|
||||
{(msg.name || contact.m_nsNickName) && (
|
||||
<div style={{ display: 'flex', fontSize: 18 }}>
|
||||
{isGroupChat ? msg.name : msg.from === 'user' ? contact.m_nsNickName : '我'}
|
||||
<span>{isGroupChat ? (msg.name ? ':' : '') : ':'} </span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 18,
|
||||
background: '#fff',
|
||||
margin: 4,
|
||||
padding: 4,
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
|
||||
>
|
||||
{isVoice && msg.sessionId ? (
|
||||
<VoicePlayer
|
||||
sessionId={msg.sessionId}
|
||||
localId={msg.localId || 0}
|
||||
createTime={msg.createTime || 0}
|
||||
/>
|
||||
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
|
||||
<ImageBubble
|
||||
imageMd5={msg.contentData.md5}
|
||||
imageDatName={msg.contentData.datName}
|
||||
sessionId={msg.sessionId}
|
||||
onImageClick={openImagePreview}
|
||||
/>
|
||||
) : isRichMedia && msg.contentData ? (
|
||||
<RichMessageBubble contentData={msg.contentData} />
|
||||
) : (
|
||||
<div className="message-text">{msg.content}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="message-meta">
|
||||
<span>{msg.datetime}</span>
|
||||
<span>{msg.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isMine && showAvatar && (
|
||||
<div className="message-avatar mine-avatar">
|
||||
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredMessages.length > displayLimit && (
|
||||
<div style={{ textAlign: 'center', padding: '10px' }}>
|
||||
<button
|
||||
@@ -481,6 +507,55 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewImage && (
|
||||
<div className="image-viewer-overlay" onClick={closeImagePreview}>
|
||||
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="image-viewer-titlebar">
|
||||
<div className="image-viewer-tools">
|
||||
<span className="image-viewer-title">图片查看</span>
|
||||
<button onClick={() => zoomImage(-0.1)} title="缩小">
|
||||
−
|
||||
</button>
|
||||
<span className="image-viewer-zoom">{Math.round(imageScale * 100)}%</span>
|
||||
<button onClick={() => zoomImage(0.1)} title="放大">
|
||||
+
|
||||
</button>
|
||||
<span className="image-viewer-divider" />
|
||||
<button onClick={() => setImageRotation((prev) => prev - 90)} title="左旋转">
|
||||
↶
|
||||
</button>
|
||||
<button onClick={() => setImageRotation((prev) => prev + 90)} title="右旋转">
|
||||
↷
|
||||
</button>
|
||||
<button onClick={resetImageTransform} title="重置">
|
||||
⟲
|
||||
</button>
|
||||
</div>
|
||||
<button className="image-viewer-close" onClick={closeImagePreview} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="image-viewer-stage"
|
||||
onWheel={handleViewerWheel}
|
||||
onMouseDown={handleViewerMouseDown}
|
||||
onMouseMove={handleViewerMouseMove}
|
||||
onMouseUp={handleViewerMouseUp}
|
||||
onMouseLeave={handleViewerMouseUp}
|
||||
>
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="图片预览"
|
||||
draggable={false}
|
||||
style={{
|
||||
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Settings Modal */}
|
||||
{showSettingsModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowSettingsModal(false)}>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
sessionId?: string
|
||||
isThumb?: boolean
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
export function ImageBubble({
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
isThumb = false,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data) {
|
||||
// 验证返回的是否是有效的图片 data URL
|
||||
if (result.data.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setError(null)
|
||||
} else {
|
||||
// 解密后不是有效图片格式,显示未解密
|
||||
setError('未解密')
|
||||
}
|
||||
} else {
|
||||
setError(result.error || '加载图片失败')
|
||||
}
|
||||
} catch {
|
||||
setError('加载图片失败')
|
||||
}
|
||||
setLoading(false)
|
||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadImage()
|
||||
}, 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [error, imageUrl, loadImage, loading])
|
||||
|
||||
const handleCopy = async (event: MouseEvent): Promise<void> => {
|
||||
event.stopPropagation()
|
||||
if (imageUrl) {
|
||||
await window.api.copyImage(imageUrl)
|
||||
alert('图片已复制')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (imageUrl) {
|
||||
onImageClick?.(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="image-bubble image-loading" onClick={handleClick}>
|
||||
<div className="image-loading-spinner">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="image-bubble image-error" onClick={loadImage}>
|
||||
<div className="image-error-text">图片未加载</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<div className="image-bubble image-placeholder">
|
||||
<div className="image-placeholder-icon">🖼</div>
|
||||
<div className="image-placeholder-text">加载图片中</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||
<img src={imageUrl} alt="图片" className="image-content" />
|
||||
<div className="image-actions">
|
||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { ParsedContent } from '../../../shared/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
const stickerDataUrlCache = new Map<string, string>()
|
||||
|
||||
interface RichMessageBubbleProps {
|
||||
contentData: ParsedContent
|
||||
}
|
||||
|
||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
||||
switch (contentData.type) {
|
||||
case 'location':
|
||||
return <LocationBubble data={contentData} />
|
||||
case 'card':
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'voip':
|
||||
return <VoipBubble data={contentData} />
|
||||
case 'sticker':
|
||||
return <StickerBubble data={contentData} />
|
||||
case 'quote':
|
||||
return <QuoteBubble data={contentData} />
|
||||
case 'unknown':
|
||||
return (
|
||||
<div className="message-text">{(contentData as { raw?: string }).raw || '[未知消息]'}</div>
|
||||
)
|
||||
default:
|
||||
return <div className="message-text">[不支持的消息类型]</div>
|
||||
}
|
||||
}
|
||||
|
||||
function LocationBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'location' }>
|
||||
}): JSX.Element {
|
||||
const { poiname, label, lat, lng } = data
|
||||
const locationText = poiname || label || '位置'
|
||||
const hasCoords = lat !== 0 || lng !== 0
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (hasCoords) {
|
||||
const url = `https://maps.apple.com/?q=${encodeURIComponent(locationText)}&ll=${lat},${lng}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
|
||||
<div className="location-icon">📍</div>
|
||||
<div className="location-info">
|
||||
<div className="location-name">{locationText}</div>
|
||||
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
|
||||
{hasCoords && (
|
||||
<div className="location-coords">
|
||||
{lat.toFixed(6)}, {lng.toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }): JSX.Element {
|
||||
const { username, nickname } = data
|
||||
|
||||
const handleCopy = (e: MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(username)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-message">
|
||||
<div className="card-avatar">{(nickname || username).charAt(0).toUpperCase()}</div>
|
||||
<div className="card-info">
|
||||
<div className="card-nickname">{nickname || '未知'}</div>
|
||||
<div className="card-username" onClick={handleCopy} title="点击复制">
|
||||
{username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
||||
const { title, des, url, appname } = data
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const urlHost = getUrlHost(url)
|
||||
|
||||
return (
|
||||
<div className="share-message" onClick={handleClick}>
|
||||
{appname && <div className="share-appname">{appname}</div>}
|
||||
<div className="share-title">{title || '链接'}</div>
|
||||
{des && <div className="share-desc">{des}</div>}
|
||||
{urlHost && <div className="share-url">{urlHost}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||
const { status, roomType, duration } = data
|
||||
const isVideo = roomType === 1
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds) return ''
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
if (mins > 0) {
|
||||
return `${mins}分${secs}秒`
|
||||
}
|
||||
return `${secs}秒`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voip-message">
|
||||
<span className="voip-icon">{isVideo ? '📹' : '📞'}</span>
|
||||
<span className="voip-status">
|
||||
{status}
|
||||
{duration ? ` ${formatDuration(duration)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StickerBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'sticker' }>
|
||||
}): JSX.Element {
|
||||
const { md5, url, thumbUrl } = data
|
||||
const sourceUrl = url || thumbUrl || ''
|
||||
const cacheKey = md5 || sourceUrl
|
||||
const [displayUrl, setDisplayUrl] = useState(() =>
|
||||
cacheKey ? stickerDataUrlCache.get(cacheKey) || '' : ''
|
||||
)
|
||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || displayUrl || error) return
|
||||
|
||||
let cancelled = false
|
||||
window.api
|
||||
.getSticker(sourceUrl, md5)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.data) {
|
||||
stickerDataUrlCache.set(cacheKey, result.data)
|
||||
setDisplayUrl(result.data)
|
||||
setError(false)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [cacheKey, displayUrl, error, md5, sourceUrl])
|
||||
|
||||
if (displayUrl) {
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<img src={displayUrl} alt="表情包" className="sticker-image" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">表情包加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||
const replyText = data.content || data.title || ''
|
||||
const quotedSender = data.quotedSender || data.sender || ''
|
||||
|
||||
return (
|
||||
<div className="quote-message">
|
||||
<div className="quoted-message">
|
||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||
<span className="quoted-text">{quotedText}</span>
|
||||
</div>
|
||||
{replyText && <div className="quote-reply">{replyText}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getUrlHost(url?: string): string {
|
||||
if (!url) return ''
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
>
|
||||
<div className="contact-avatar">{contact.m_nsNickName.charAt(0)}</div>
|
||||
<div className="contact-avatar">
|
||||
{contact.avatar ? (
|
||||
<img src={contact.avatar} alt={contact.m_nsNickName} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
<div className="contact-name">{contact.m_nsNickName}</div>
|
||||
</div>
|
||||
@@ -98,12 +104,16 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> 群聊 ({groups.length})
|
||||
</div>
|
||||
{isGroupsExpanded && groups.map(renderContactItem)}
|
||||
{isGroupsExpanded && groups.length === 0 && <div className="section-empty">暂无群聊</div>}
|
||||
|
||||
{/* 联系人部分 */}
|
||||
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
|
||||
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> 联系人 ({users.length})
|
||||
</div>
|
||||
{isContactsExpanded && users.map(renderContactItem)}
|
||||
{isContactsExpanded && users.length === 0 && (
|
||||
<div className="section-empty">暂无联系人</div>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="sidebar-footer">
|
||||
<div className="sidebar-btn" onClick={() => window.location.reload()}>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
|
||||
interface VoicePlayerProps {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
let globalCurrentAudio: HTMLAudioElement | null = null
|
||||
let globalStopCallback: (() => void) | null = null
|
||||
|
||||
export function VoicePlayer({
|
||||
sessionId,
|
||||
localId,
|
||||
createTime,
|
||||
svrId
|
||||
}: VoicePlayerProps): JSX.Element {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [audioDuration, setAudioDuration] = useState<number | undefined>(undefined)
|
||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => {
|
||||
if (globalCurrentAudio && globalCurrentAudio !== audio) {
|
||||
globalCurrentAudio.pause()
|
||||
globalCurrentAudio.currentTime = 0
|
||||
globalStopCallback?.()
|
||||
}
|
||||
globalCurrentAudio = audio
|
||||
}, [])
|
||||
|
||||
const handlePlayPause = useCallback(async () => {
|
||||
// 如果还没有音频数据,先获取
|
||||
if (!audioUrl && !loading) {
|
||||
setLoading(true)
|
||||
setShouldAutoPlay(true)
|
||||
console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime })
|
||||
try {
|
||||
const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId)
|
||||
console.log('[VoicePlayer] got result:', result)
|
||||
if (result.success && result.data) {
|
||||
console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length)
|
||||
// 使用 Blob URL 替代 data URL,绕过 CSP 限制
|
||||
const byteCharacters = atob(result.data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: 'audio/wav' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
console.log('[VoicePlayer] created blob URL:', blobUrl)
|
||||
setAudioUrl(blobUrl)
|
||||
} else {
|
||||
console.log('[VoicePlayer] getVoiceData failed:', result.error)
|
||||
setError(result.error || '获取语音数据失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[VoicePlayer] exception:', e)
|
||||
setError('加载语音失败')
|
||||
setShouldAutoPlay(false)
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!audioRef.current) {
|
||||
console.log('[VoicePlayer] no audioRef')
|
||||
return
|
||||
}
|
||||
|
||||
const audio = audioRef.current
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause()
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
} else {
|
||||
stopCurrentAndPlay(audio)
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
console.log('[VoicePlayer] play() succeeded')
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log('[VoicePlayer] play() failed:', e)
|
||||
})
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
}, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) return
|
||||
|
||||
let audio = audioRef.current
|
||||
if (!audio) {
|
||||
audio = new Audio(audioUrl)
|
||||
audioRef.current = audio
|
||||
}
|
||||
|
||||
const audioEl = audio!
|
||||
|
||||
audioEl.addEventListener('loadedmetadata', () => {
|
||||
setAudioDuration(audioEl.duration)
|
||||
console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration)
|
||||
})
|
||||
|
||||
audioEl.addEventListener('ended', () => {
|
||||
setIsPlaying(false)
|
||||
globalStopCallback = null
|
||||
})
|
||||
|
||||
audioEl.addEventListener('timeupdate', () => {
|
||||
if (audioEl.duration && isFinite(audioEl.duration)) {
|
||||
setAudioDuration(audioEl.duration)
|
||||
}
|
||||
})
|
||||
|
||||
audioEl.addEventListener('canplay', () => {
|
||||
console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay)
|
||||
if (shouldAutoPlay && audioRef.current) {
|
||||
setShouldAutoPlay(false)
|
||||
stopCurrentAndPlay(audioRef.current)
|
||||
audioRef.current.play()
|
||||
setIsPlaying(true)
|
||||
globalStopCallback = () => {
|
||||
setIsPlaying(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ''
|
||||
audioRef.current = null
|
||||
}
|
||||
if (globalCurrentAudio === audioRef.current) {
|
||||
globalCurrentAudio = null
|
||||
globalStopCallback = null
|
||||
}
|
||||
}
|
||||
}, [audioUrl, shouldAutoPlay, stopCurrentAndPlay])
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds || !isFinite(seconds)) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="voice-message voice-loading">
|
||||
<span className="voice-icon">▶</span>
|
||||
<span className="voice-loading-text">加载中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !audioUrl) {
|
||||
return (
|
||||
<div className="voice-message voice-error" onClick={handlePlayPause}>
|
||||
<span className="voice-icon">▶</span>
|
||||
<span className="voice-error-text">[语音]</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-message" onClick={handlePlayPause}>
|
||||
<span className={`voice-icon ${isPlaying ? 'playing' : ''}`}>{isPlaying ? '⏸' : '▶'}</span>
|
||||
<div className="voice-bars" aria-hidden="true">
|
||||
<i></i>
|
||||
<i></i>
|
||||
<i></i>
|
||||
</div>
|
||||
<span className="voice-duration">{formatDuration(audioDuration)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user