UserTicketDetailModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. 'use client';
  2. import { useState, useEffect, useRef } from 'react';
  3. import api from '@/lib/api';
  4. import { X, Send, User, Headset, Paperclip, Loader2, Clock, CheckCircle, AlertCircle, XCircle } from 'lucide-react';
  5. // 定义工单类型(需导出给列表页使用)
  6. export interface UserTicket {
  7. id: number;
  8. order_id: string;
  9. type: string;
  10. status: string;
  11. reason: string;
  12. admin_comment?: string;
  13. created_at: string;
  14. }
  15. interface Message {
  16. id: number;
  17. ticket_id: number;
  18. sender_type: 'user' | 'admin' | 'system';
  19. content: string;
  20. created_at: string;
  21. }
  22. interface UserTicketDetailModalProps {
  23. isOpen: boolean;
  24. onClose: () => void;
  25. ticket: UserTicket | null;
  26. }
  27. export default function UserTicketDetailModal({ isOpen, onClose, ticket }: UserTicketDetailModalProps) {
  28. const [messages, setMessages] = useState<Message[]>([]);
  29. const [loadingMsg, setLoadingMsg] = useState(false);
  30. const [replyContent, setReplyContent] = useState('');
  31. const [sending, setSending] = useState(false);
  32. // 滚动到底部的引用
  33. const messagesEndRef = useRef<HTMLDivElement>(null);
  34. // 1. 初始化加载消息
  35. useEffect(() => {
  36. if (isOpen && ticket) {
  37. fetchMessages();
  38. // 可选:设置轮询,每10秒获取新消息
  39. // const interval = setInterval(fetchMessages, 10000);
  40. // return () => clearInterval(interval);
  41. }
  42. }, [isOpen, ticket]);
  43. // 2. 滚动到底部
  44. useEffect(() => {
  45. scrollToBottom();
  46. }, [messages]);
  47. const fetchMessages = async () => {
  48. if (!ticket) return;
  49. try {
  50. setLoadingMsg(true);
  51. // API: GET /api/vas/tickets/fetch_message?ticket_id=123&page=1&size=50
  52. const res = await api.get('/api/vas/tickets/fetch_message', {
  53. params: {
  54. ticket_id: ticket.id,
  55. page: 1,
  56. size: 100 // 获取最近的100条
  57. }
  58. });
  59. const items = res.data.data?.items || [];
  60. // 确保按时间正序排列 (旧 -> 新)
  61. const sorted = items.sort((a: Message, b: Message) =>
  62. new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
  63. );
  64. setMessages(sorted);
  65. } catch (error) {
  66. console.error("Fetch messages failed", error);
  67. } finally {
  68. setLoadingMsg(false);
  69. }
  70. };
  71. const handleSend = async (e: React.FormEvent) => {
  72. e.preventDefault();
  73. if (!replyContent.trim() || !ticket) return;
  74. setSending(true);
  75. try {
  76. // API: POST /api/vas/tickets/send_message?ticket_id=123
  77. await api.post(`/api/vas/tickets/send_message`, {
  78. content: replyContent,
  79. attachments: null // 暂不支持附件,留空
  80. }, {
  81. params: { ticket_id: ticket.id }
  82. });
  83. setReplyContent('');
  84. fetchMessages(); // 发送成功后刷新列表
  85. } catch (error) {
  86. console.error("Send message failed", error);
  87. alert("发送失败,请稍后重试");
  88. } finally {
  89. setSending(false);
  90. }
  91. };
  92. const scrollToBottom = () => {
  93. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  94. };
  95. // 辅助函数:渲染状态
  96. const renderStatus = (status: string) => {
  97. const map: any = {
  98. pending: { color: 'text-yellow-600 bg-yellow-50', icon: Clock, text: '待处理' },
  99. info_required: { color: 'text-orange-600 bg-orange-50', icon: AlertCircle, text: '需补充资料' },
  100. resolved: { color: 'text-green-600 bg-green-50', icon: CheckCircle, text: '已解决' },
  101. rejected: { color: 'text-red-600 bg-red-50', icon: XCircle, text: '已拒绝' },
  102. };
  103. const conf = map[status] || { color: 'text-gray-600 bg-gray-50', icon: Clock, text: status };
  104. const Icon = conf.icon;
  105. return (
  106. <span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${conf.color}`}>
  107. <Icon size={12} /> {conf.text}
  108. </span>
  109. );
  110. };
  111. if (!isOpen || !ticket) return null;
  112. return (
  113. <div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
  114. {/* 遮罩层 */}
  115. <div className="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity" onClick={onClose} />
  116. {/* 弹窗主体 */}
  117. <div className="relative w-full max-w-2xl h-[80vh] bg-white rounded-xl shadow-2xl overflow-hidden flex flex-col animate-in zoom-in duration-200">
  118. {/* 1. 顶部 Header */}
  119. <div className="px-6 py-4 border-b bg-gray-50 flex justify-between items-center flex-shrink-0">
  120. <div>
  121. <div className="flex items-center gap-3">
  122. <h3 className="font-bold text-gray-900 text-lg">
  123. 工单 #{ticket.id}
  124. </h3>
  125. {renderStatus(ticket.status)}
  126. </div>
  127. <p className="text-xs text-gray-500 mt-1">
  128. 关联订单: <span className="font-mono">{ticket.order_id}</span>
  129. </p>
  130. </div>
  131. <button onClick={onClose} className="p-2 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition">
  132. <X size={20} />
  133. </button>
  134. </div>
  135. {/* 2. 中间:消息列表 (Scrollable) */}
  136. <div className="flex-1 overflow-y-auto p-4 sm:p-6 bg-slate-50 space-y-6">
  137. {/* 原始工单描述 (作为第一条展示) */}
  138. <div className="flex justify-center">
  139. <div className="bg-white border border-gray-200 text-gray-600 text-xs px-4 py-2 rounded-full shadow-sm">
  140. 工单创建于 {new Date(ticket.created_at).toLocaleString()}
  141. </div>
  142. </div>
  143. <div className="flex justify-end">
  144. <div className="flex flex-row-reverse items-end gap-2 max-w-[85%]">
  145. <div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
  146. <User size={14} className="text-blue-600" />
  147. </div>
  148. <div>
  149. <div className="bg-blue-600 text-white px-4 py-3 rounded-2xl rounded-tr-none shadow-sm text-sm">
  150. <p className="font-bold text-xs text-blue-100 mb-1 border-b border-blue-500 pb-1">工单描述</p>
  151. {ticket.reason}
  152. </div>
  153. </div>
  154. </div>
  155. </div>
  156. {/* 会话记录 */}
  157. {loadingMsg ? (
  158. <div className="flex justify-center py-8"><Loader2 className="animate-spin text-gray-400" /></div>
  159. ) : (
  160. messages.map((msg) => {
  161. const isMe = msg.sender_type === 'user';
  162. const isSystem = msg.sender_type === 'system';
  163. if (isSystem) {
  164. return (
  165. <div key={msg.id} className="flex justify-center my-4">
  166. <span className="text-[10px] text-gray-400 bg-gray-100 px-2 py-1 rounded">
  167. 系统消息: {msg.content} - {new Date(msg.created_at).toLocaleTimeString()}
  168. </span>
  169. </div>
  170. );
  171. }
  172. return (
  173. <div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
  174. <div className={`flex items-end gap-2 max-w-[80%] ${isMe ? 'flex-row-reverse' : 'flex-row'}`}>
  175. {/* 头像 */}
  176. <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
  177. isMe ? 'bg-blue-100' : 'bg-purple-100'
  178. }`}>
  179. {isMe ? <User size={14} className="text-blue-600" /> : <Headset size={14} className="text-purple-600" />}
  180. </div>
  181. {/* 气泡 */}
  182. <div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
  183. <div className={`px-4 py-2 text-sm shadow-sm ${
  184. isMe
  185. ? 'bg-blue-600 text-white rounded-2xl rounded-tr-none'
  186. : 'bg-white text-gray-800 border border-gray-100 rounded-2xl rounded-tl-none'
  187. }`}>
  188. {msg.content}
  189. </div>
  190. <span className="text-[10px] text-gray-400 mt-1 px-1">
  191. {isMe ? '我' : '客服'} • {new Date(msg.created_at).toLocaleString([], {month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit'})}
  192. </span>
  193. </div>
  194. </div>
  195. </div>
  196. );
  197. })
  198. )}
  199. <div ref={messagesEndRef} />
  200. </div>
  201. {/* 3. 底部:输入框 */}
  202. <div className="p-4 bg-white border-t border-gray-100 flex-shrink-0">
  203. <form onSubmit={handleSend} className="flex items-end gap-2">
  204. <button type="button" className="p-3 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-50 transition" title="上传附件(暂不可用)">
  205. <Paperclip size={20} />
  206. </button>
  207. <div className="flex-1 bg-gray-50 rounded-xl border border-gray-200 focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500 focus-within:bg-white transition-all">
  208. <textarea
  209. value={replyContent}
  210. onChange={(e) => setReplyContent(e.target.value)}
  211. onKeyDown={(e) => {
  212. if (e.key === 'Enter' && !e.shiftKey) {
  213. e.preventDefault();
  214. handleSend(e);
  215. }
  216. }}
  217. placeholder="请输入回复内容..."
  218. rows={1}
  219. className="w-full bg-transparent border-0 focus:ring-0 p-3 text-sm resize-none max-h-32 min-h-[44px]"
  220. style={{ height: 'auto', overflowY: 'hidden' }}
  221. // 简单的自动高度调整,实际项目中可使用 text-area-autosize 库
  222. />
  223. </div>
  224. <button
  225. type="submit"
  226. disabled={sending || !replyContent.trim()}
  227. className="p-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-md transition-all flex items-center justify-center"
  228. >
  229. {sending ? <Loader2 className="animate-spin w-5 h-5" /> : <Send size={20} />}
  230. </button>
  231. </form>
  232. <div className="text-center mt-2">
  233. <p className="text-[10px] text-gray-400">如需紧急处理,请发送邮件至 support@visafly.com</p>
  234. </div>
  235. </div>
  236. </div>
  237. </div>
  238. );
  239. }