新增通用聊天图片预览组件
- ImagePreview / ChatImage:全屏预览、ESC 关闭、下载 - 接入工作台、访客 Widget、对话记录
This commit is contained in:
@@ -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
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user