实现 MinIO 对象存储图片流水线(可迁移 OSS)

- 新增 S3 兼容 Storage 抽象与 MinIO 实现,compose 启动 MinIO
- 上传接口:校验 → 缩放 → WebP → 主图/缩略图入库
- 消息图片 content 改为对象 URL,拒绝 base64
- 工作台/访客端改为先上传再发消息
This commit is contained in:
yml2213
2026-07-15 12:21:31 +08:00
parent 85b407fddf
commit ec2f12c6ed
20 changed files with 907 additions and 108 deletions
+52 -26
View File
@@ -53,7 +53,7 @@ const VisitorChat = ({
const [agentTyping, setAgentTyping] = useState(false)
const [ratingText, setRatingText] = useState('')
const [hoverStar, setHoverStar] = useState(0)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
const [sendError, setSendError] = useState('')
const [agentsOnline, setAgentsOnline] = useState(true)
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
@@ -201,7 +201,7 @@ const VisitorChat = ({
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, agentTyping, open, imagePreview])
}, [messages, agentTyping, open, pendingImage])
const notifyHost = (type: 'kefu-widget-close' | 'kefu-widget-minimize') => {
if (embedded && window.parent && window.parent !== window) {
@@ -231,6 +231,22 @@ const VisitorChat = ({
return json.data
}
const uploadVisitorImage = async (file: File) => {
if (!sessionId || !visitorToken) throw new Error('会话未就绪')
const form = new FormData()
form.append('file', file)
form.append('session_id', String(sessionId))
form.append('visitor_token', visitorToken)
const res = await fetch('/api/widget/upload', {
method: 'POST',
headers: { 'X-Visitor-Token': visitorToken },
body: form,
})
const json = await res.json()
if (json.code !== 0) throw new Error(json.message || '上传失败')
return json.data as { url: string; thumb_url: string }
}
const sendMessage = async (text: string) => {
if (!text.trim() || sending || sessionEnded) return
if (!agentsOnline) {
@@ -298,39 +314,40 @@ const VisitorChat = ({
const handleImageFile = (file?: File | null) => {
if (!file || sessionEnded || !sessionId || !agentsOnline) return
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
setSendError('仅支持 jpg、png、gif 图片')
const okTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!okTypes.includes(file.type)) {
setSendError('仅支持 jpg、png、gif、webp 图片')
return
}
if (file.size > 5 * 1024 * 1024) {
setSendError('图片不能超过 5 MB')
if (file.size > 10 * 1024 * 1024) {
setSendError('图片不能超过 10 MB')
return
}
setSendError('')
const reader = new FileReader()
reader.onload = () => setImagePreview(String(reader.result))
reader.readAsDataURL(file)
if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview)
setPendingImage({ file, preview: URL.createObjectURL(file) })
}
const sendImage = async () => {
if (!imagePreview || sending || sessionEnded || !agentsOnline) return
if (!pendingImage || sending || sessionEnded || !agentsOnline) return
setSending(true)
setSendError('')
const localMsg: Message = {
id: -Date.now(),
sender: 'visitor',
content: imagePreview,
type: 'image',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
}
setMessages(prev => [...prev, localMsg])
setImagePreview(null)
try {
await postMessage(localMsg.content, 'image')
const uploaded = await uploadVisitorImage(pendingImage.file)
const localMsg: Message = {
id: -Date.now(),
sender: 'visitor',
content: uploaded.url,
type: 'image',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
}
setMessages(prev => [...prev, localMsg])
URL.revokeObjectURL(pendingImage.preview)
setPendingImage(null)
await postMessage(uploaded.url, 'image')
await loadMessages(sessionId || undefined)
} catch (e) {
setSendError(e instanceof Error ? e.message : '图片发送失败')
setMessages(prev => prev.filter(m => m.id !== localMsg.id))
} finally {
setSending(false)
}
@@ -530,11 +547,20 @@ const VisitorChat = ({
</div>
)}
{imagePreview && agentsOnline && (
{pendingImage && agentsOnline && (
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
<img src={imagePreview} alt="预览" className="w-14 h-14 object-cover rounded" />
<div className="flex-1 min-w-0 text-xs text-neutral-500"></div>
<button type="button" className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer" onClick={() => setImagePreview(null)}></button>
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
<div className="flex-1 min-w-0 text-xs text-neutral-500"> WebP</div>
<button
type="button"
className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer"
onClick={() => {
URL.revokeObjectURL(pendingImage.preview)
setPendingImage(null)
}}
>
</button>
<button type="button" disabled={sending} className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50" onClick={sendImage}></button>
</div>
)}
@@ -559,7 +585,7 @@ const VisitorChat = ({
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={e => { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }}
/>