新增通用聊天图片预览组件

- ImagePreview / ChatImage:全屏预览、ESC 关闭、下载
- 接入工作台、访客 Widget、对话记录
This commit is contained in:
yml2213
2026-07-15 12:44:37 +08:00
parent 32d88f42e7
commit cd28796440
4 changed files with 128 additions and 4 deletions
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useCallback, useState, type MouseEvent } from 'react'
import { createPortal } from 'react-dom'
import { CloseOutlined, DownloadOutlined, ZoomInOutlined } from '@ant-design/icons'
export interface ImagePreviewProps {
open: boolean
src: string
alt?: string
onClose: () => void
}
/**
* 通用大图预览:遮罩 + 居中大图,支持 ESC / 点遮罩关闭,可下载。
*/
export function ImagePreview({ open, src, alt = '图片预览', onClose }: ImagePreviewProps) {
const onKey = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (!open) return
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
}
}, [open, onKey])
if (!open || !src) return null
const stop = (e: MouseEvent) => e.stopPropagation()
const download = () => {
const a = document.createElement('a')
a.href = src
a.download = alt || 'image'
a.target = '_blank'
a.rel = 'noopener noreferrer'
a.click()
}
const node = (
<div
className="fixed inset-0 z-[2000] flex items-center justify-center bg-black/75 p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={alt}
>
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
<button
type="button"
onClick={e => { stop(e); download() }}
className="w-9 h-9 rounded-full bg-white/15 hover:bg-white/25 text-white flex items-center justify-center border-0 cursor-pointer"
title="下载 / 新窗口打开"
>
<DownloadOutlined />
</button>
<button
type="button"
onClick={e => { stop(e); onClose() }}
className="w-9 h-9 rounded-full bg-white/15 hover:bg-white/25 text-white flex items-center justify-center border-0 cursor-pointer"
title="关闭"
>
<CloseOutlined />
</button>
</div>
<img
src={src}
alt={alt}
onClick={stop}
className="max-w-full max-h-[min(90vh,900px)] object-contain rounded-lg shadow-2xl select-none"
draggable={false}
/>
</div>
)
return createPortal(node, document.body)
}
export interface ChatImageProps {
src: string
alt?: string
/** 缩略图样式 */
className?: string
}
/**
* 聊天内可点击图片:缩略图 + 通用 ImagePreview
*/
export function ChatImage({ src, alt = '聊天图片', className = '' }: ChatImageProps) {
const [open, setOpen] = useState(false)
if (!src) return null
return (
<>
<button
type="button"
className="p-0 m-0 border-0 bg-transparent cursor-zoom-in block text-left relative group max-w-full"
onClick={() => setOpen(true)}
title="点击预览"
>
<img
src={src}
alt={alt}
className={`rounded-lg block object-cover ${className}`}
loading="lazy"
/>
<span className="pointer-events-none absolute inset-0 rounded-lg bg-black/0 group-hover:bg-black/15 transition-colors flex items-center justify-center">
<ZoomInOutlined className="text-white text-lg opacity-0 group-hover:opacity-90 drop-shadow" />
</span>
</button>
<ImagePreview open={open} src={src} alt={alt} onClose={() => setOpen(false)} />
</>
)
}
export default ImagePreview
+2 -1
View File
@@ -5,6 +5,7 @@ import {
getCustomers, getSession, getSessions, getCustomers, getSession, getSessions,
type Customer, type Message, type Session, type SessionEvent, type Customer, type Message, type Session, type SessionEvent,
} from '@/services/api' } from '@/services/api'
import { ChatImage } from '@/components/common/ImagePreview'
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
active: 'blue', waiting: 'orange', ended: 'green', archived: 'default', active: 'blue', waiting: 'orange', ended: 'green', archived: 'default',
@@ -212,7 +213,7 @@ const ChatHistory = () => {
{isAgent ? '客服' : '访客'} {isAgent ? '客服' : '访客'}
</div> </div>
{message.type === 'image' ? ( {message.type === 'image' ? (
<img src={message.content} alt="图片" className="max-w-56 max-h-56 rounded-lg" /> <ChatImage src={message.content} alt="图片" className="max-w-56 max-h-56" />
) : ( ) : (
<div className="whitespace-pre-wrap break-words">{message.content}</div> <div className="whitespace-pre-wrap break-words">{message.content}</div>
)} )}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
ExportOutlined, BookOutlined, PictureOutlined, ExportOutlined, BookOutlined, PictureOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview'
import { useAuth } from '@/stores/auth' import { useAuth } from '@/stores/auth'
import { import {
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
@@ -725,7 +726,7 @@ const Dashboard = () => {
}`} }`}
> >
{message.type === 'image' ? ( {message.type === 'image' ? (
<img src={message.content} alt="聊天图片" className="max-w-64 max-h-64 rounded-lg" /> <ChatImage src={message.content} alt="聊天图片" className="max-w-64 max-h-64" />
) : ( ) : (
<p className="text-sm leading-normal whitespace-pre-wrap break-words m-0">{message.content}</p> <p className="text-sm leading-normal whitespace-pre-wrap break-words m-0">{message.content}</p>
)} )}
+3 -2
View File
@@ -4,6 +4,7 @@ import {
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined, StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview'
interface Message { interface Message {
id: number id: number
@@ -468,7 +469,7 @@ const VisitorChat = ({
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto"> <div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
<div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}> <div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
{msg.type === 'image' ? ( {msg.type === 'image' ? (
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" /> <ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
) : ( ) : (
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p> <p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
)} )}
@@ -481,7 +482,7 @@ const VisitorChat = ({
</div> </div>
<div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}> <div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
{msg.type === 'image' ? ( {msg.type === 'image' ? (
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" /> <ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
) : ( ) : (
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p> <p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
)} )}