实现 MinIO 对象存储图片流水线(可迁移 OSS)
- 新增 S3 兼容 Storage 抽象与 MinIO 实现,compose 启动 MinIO - 上传接口:校验 → 缩放 → WebP → 主图/缩略图入库 - 消息图片 content 改为对象 URL,拒绝 base64 - 工作台/访客端改为先上传再发消息
This commit is contained in:
@@ -10,6 +10,7 @@ import { useAuth } from '@/stores/auth'
|
||||
import {
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
||||
getSession, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority,
|
||||
uploadImage,
|
||||
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent,
|
||||
} from '@/services/api'
|
||||
|
||||
@@ -111,7 +112,7 @@ const Dashboard = () => {
|
||||
const [knowledgeKeyword, setKnowledgeKeyword] = useState('')
|
||||
const [knowledgeEntries, setKnowledgeEntries] = useState<KnowledgeEntry[]>([])
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(false)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [noteInput, setNoteInput] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
|
||||
@@ -312,17 +313,34 @@ const Dashboard = () => {
|
||||
|
||||
const handleImage = (file?: File) => {
|
||||
if (!file) return
|
||||
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
|
||||
antMsg.error('仅支持 jpg、png、gif 图片')
|
||||
const okTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (!okTypes.includes(file.type)) {
|
||||
antMsg.error('仅支持 jpg、png、gif、webp 图片')
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
antMsg.error('图片不能超过 5 MB')
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
antMsg.error('图片不能超过 10 MB')
|
||||
return
|
||||
}
|
||||
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 confirmSendImage = async () => {
|
||||
if (!pendingImage || !selectedId || !canOperate) return
|
||||
setSending(true)
|
||||
try {
|
||||
const uploaded = await uploadImage(pendingImage.file)
|
||||
await sendSessionMessage(selectedId, uploaded.data.url, 'image')
|
||||
URL.revokeObjectURL(pendingImage.preview)
|
||||
setPendingImage(null)
|
||||
await loadDetail(selectedId)
|
||||
await loadAll()
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '图片发送失败')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClaim = async (sessionID: number) => {
|
||||
@@ -412,7 +430,7 @@ const Dashboard = () => {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }}
|
||||
/>
|
||||
@@ -1006,15 +1024,19 @@ const Dashboard = () => {
|
||||
</Modal>
|
||||
<Modal
|
||||
title="图片预览"
|
||||
open={Boolean(imagePreview)}
|
||||
onCancel={() => setImagePreview(null)}
|
||||
onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }}
|
||||
okText="发送"
|
||||
open={Boolean(pendingImage)}
|
||||
onCancel={() => {
|
||||
if (pendingImage?.preview) URL.revokeObjectURL(pendingImage.preview)
|
||||
setPendingImage(null)
|
||||
}}
|
||||
onOk={confirmSendImage}
|
||||
okText="上传并发送"
|
||||
okButtonProps={{ loading: sending }}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<img src={imagePreview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" />
|
||||
<img src={pendingImage?.preview || ''} alt="待发送图片预览" className="max-h-[420px] max-w-full rounded-lg" />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 text-center mt-3 mb-0">将自动压缩并转为 WebP 后上传</p>
|
||||
</Modal>
|
||||
<Modal title="知识库与快捷回复" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
|
||||
<Input
|
||||
|
||||
+33
-1
@@ -1,4 +1,4 @@
|
||||
import { get, post, put, del, getList } from './request'
|
||||
import { get, post, put, del, getList, postForm } from './request'
|
||||
|
||||
export interface LoginParams { username: string; password: string }
|
||||
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
|
||||
@@ -107,6 +107,38 @@ export const addSessionNote = (id: number, content: string) => post<SessionEvent
|
||||
export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post<Message>(`/sessions/${id}/messages`, { content, type })
|
||||
export const getAvailableAgents = () => get<AvailableAgent[]>('/agents/available')
|
||||
|
||||
export interface UploadImageResult {
|
||||
url: string
|
||||
thumb_url: string
|
||||
content_type: string
|
||||
width: number
|
||||
height: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/** 客服端上传图片 → 返回对象存储 URL */
|
||||
export const uploadImage = (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return postForm<UploadImageResult>('/uploads', form)
|
||||
}
|
||||
|
||||
/** 访客端上传图片 */
|
||||
export const uploadWidgetImage = async (file: File, sessionId: number, visitorToken: string) => {
|
||||
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 as { code: number; data: UploadImageResult }
|
||||
}
|
||||
|
||||
// Customers
|
||||
export const getCustomers = (params?: { search?: string; status?: string; source?: string; page?: number; pageSize?: number }) => {
|
||||
const search = new URLSearchParams()
|
||||
|
||||
@@ -22,9 +22,13 @@ interface ListResponse<T = unknown> {
|
||||
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
}
|
||||
// FormData 时不要手动设置 Content-Type,由浏览器带 boundary
|
||||
const isForm = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||
if (!isForm && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE}${url}`, { ...options, headers })
|
||||
@@ -44,3 +48,8 @@ export const post = <T>(url: string, data: unknown) => request<Response<T>>(url,
|
||||
export const put = <T>(url: string, data: unknown) => request<Response<T>>(url, { method: 'PUT', body: JSON.stringify(data) })
|
||||
export const del = <T>(url: string) => request<Response<T>>(url, { method: 'DELETE' })
|
||||
export const getList = <T>(url: string) => request<ListResponse<T>>(url)
|
||||
|
||||
/** multipart 上传(图片等) */
|
||||
export const postForm = <T>(url: string, form: FormData) =>
|
||||
request<Response<T>>(url, { method: 'POST', body: form })
|
||||
|
||||
|
||||
@@ -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 = '' }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user