VisitorDetailPanel.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. "use client";
  2. import { useMemo, useState } from "react";
  3. import { ConversationDetail, ConversationSummary } from "@/features/agent/types";
  4. import {
  5. formatConversationTime,
  6. isVisitorOnline,
  7. } from "@/utils/format";
  8. type ContactField = "email" | "phone" | "notes";
  9. type ContactUpdatePayload = Partial<Record<ContactField, string>>;
  10. interface VisitorDetailPanelProps {
  11. conversation: ConversationSummary | null;
  12. detail: ConversationDetail | null;
  13. onRefresh: () => void;
  14. onUpdateContact: (payload: ContactUpdatePayload) => Promise<unknown>;
  15. }
  16. const displayValue = (value?: string | null, placeholder = "暂未填写") => {
  17. if (!value) {
  18. return placeholder;
  19. }
  20. const trimmed = value.trim();
  21. return trimmed || placeholder;
  22. };
  23. export function VisitorDetailPanel({
  24. conversation,
  25. detail,
  26. onRefresh,
  27. onUpdateContact,
  28. }: VisitorDetailPanelProps) {
  29. const [editingField, setEditingField] = useState<ContactField | null>(null);
  30. const [editingValue, setEditingValue] = useState("");
  31. const [saving, setSaving] = useState(false);
  32. const [errorMessage, setErrorMessage] = useState("");
  33. const fieldLabels = useMemo<Record<ContactField, string>>(
  34. () => ({
  35. email: "邮箱",
  36. phone: "电话",
  37. notes: "备注",
  38. }),
  39. []
  40. );
  41. if (!conversation) {
  42. return (
  43. <div className="w-80 bg-white border-l border-gray-200 flex flex-col min-h-0">
  44. <div className="flex-1 flex items-center justify-center">
  45. <div className="text-center text-gray-400 text-sm">
  46. 选择一个对话查看详情
  47. </div>
  48. </div>
  49. </div>
  50. );
  51. }
  52. const avatarColor = `hsl(${(conversation.visitor_id * 137.5) % 360}, 70%, 50%)`;
  53. // 根据 last_seen_at 判断是否在线(优先使用 detail,因为它是最新的)
  54. // 如果 detail 不存在,使用 conversation.last_seen_at
  55. const isOnline = isVisitorOnline(
  56. detail?.last_seen_at ?? conversation.last_seen_at ?? null
  57. );
  58. const getFieldValue = (field: ContactField) => {
  59. if (!detail) {
  60. return "";
  61. }
  62. switch (field) {
  63. case "email":
  64. return detail.email ?? "";
  65. case "phone":
  66. return detail.phone ?? "";
  67. case "notes":
  68. return detail.notes ?? "";
  69. default:
  70. return "";
  71. }
  72. };
  73. const handleOpenEditor = (field: ContactField) => {
  74. setEditingField(field);
  75. setEditingValue(getFieldValue(field));
  76. setErrorMessage("");
  77. };
  78. const handleCloseEditor = () => {
  79. if (saving) {
  80. return;
  81. }
  82. setEditingField(null);
  83. setEditingValue("");
  84. setErrorMessage("");
  85. };
  86. const handleSubmit = async () => {
  87. if (!editingField) {
  88. return;
  89. }
  90. setSaving(true);
  91. try {
  92. const payload: ContactUpdatePayload = {
  93. [editingField]: editingValue,
  94. };
  95. await onUpdateContact(payload);
  96. setEditingField(null);
  97. setEditingValue("");
  98. setErrorMessage("");
  99. } catch (error) {
  100. setErrorMessage((error as Error).message || "保存失败,请稍后重试");
  101. } finally {
  102. setSaving(false);
  103. }
  104. };
  105. const actionLabel = (field: ContactField) => {
  106. const current = getFieldValue(field).trim();
  107. return current ? "编辑" : "+ Add";
  108. };
  109. return (
  110. <div className="w-80 bg-white border-l border-gray-200 flex flex-col min-h-0">
  111. <div className="h-16 border-b border-gray-200 flex items-center justify-between px-4 flex-shrink-0">
  112. <div className="flex items-center gap-3">
  113. <div
  114. className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0"
  115. style={{ backgroundColor: avatarColor }}
  116. >
  117. {conversation.visitor_id.toString().slice(-2)}
  118. </div>
  119. <div>
  120. <div className="font-semibold text-gray-800 text-sm">
  121. 访客 #{conversation.visitor_id}
  122. </div>
  123. <div className="text-xs text-gray-500">
  124. {isOnline ? (
  125. <span className="text-green-600">● 在线</span>
  126. ) : (
  127. <span className="text-gray-400">● 离线</span>
  128. )}
  129. </div>
  130. </div>
  131. </div>
  132. <div className="flex items-center gap-2">
  133. <button
  134. className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
  135. title="刷新"
  136. onClick={onRefresh}
  137. >
  138. <svg
  139. className="w-5 h-5 text-gray-600"
  140. fill="none"
  141. stroke="currentColor"
  142. viewBox="0 0 24 24"
  143. >
  144. <path
  145. strokeLinecap="round"
  146. strokeLinejoin="round"
  147. strokeWidth={2}
  148. d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
  149. />
  150. </svg>
  151. </button>
  152. <button
  153. className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
  154. title="更多选项"
  155. >
  156. <svg
  157. className="w-5 h-5 text-gray-600"
  158. fill="none"
  159. stroke="currentColor"
  160. viewBox="0 0 24 24"
  161. >
  162. <path
  163. strokeLinecap="round"
  164. strokeLinejoin="round"
  165. strokeWidth={2}
  166. d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
  167. />
  168. </svg>
  169. </button>
  170. </div>
  171. </div>
  172. <div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
  173. {/* 联系信息区域 */}
  174. <div className="p-4 border-b border-gray-200">
  175. <h3 className="text-sm font-semibold text-gray-700 mb-3">联系信息</h3>
  176. <div className="space-y-3 text-sm">
  177. <div>
  178. <div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
  179. <span>邮箱</span>
  180. <button
  181. className="text-blue-500 text-xs hover:text-blue-600"
  182. onClick={() => handleOpenEditor("email")}
  183. >
  184. {actionLabel("email")}
  185. </button>
  186. </div>
  187. <div className="text-xs text-gray-700 break-all">
  188. {displayValue(detail?.email, "暂未填写")}
  189. </div>
  190. </div>
  191. <div>
  192. <div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
  193. <span>电话</span>
  194. <button
  195. className="text-blue-500 text-xs hover:text-blue-600"
  196. onClick={() => handleOpenEditor("phone")}
  197. >
  198. {actionLabel("phone")}
  199. </button>
  200. </div>
  201. <div className="text-xs text-gray-700 break-all">
  202. {displayValue(detail?.phone, "暂未填写")}
  203. </div>
  204. </div>
  205. <div>
  206. <div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
  207. <span>备注</span>
  208. <button
  209. className="text-blue-500 text-xs hover:text-blue-600"
  210. onClick={() => handleOpenEditor("notes")}
  211. >
  212. {actionLabel("notes")}
  213. </button>
  214. </div>
  215. <div className="text-xs text-gray-700 whitespace-pre-wrap break-words min-h-[1rem]">
  216. {displayValue(detail?.notes, "暂无备注")}
  217. </div>
  218. </div>
  219. </div>
  220. </div>
  221. {/* 技术信息区域 */}
  222. <div className="p-4">
  223. <h3 className="text-sm font-semibold text-gray-700 mb-3">技术信息</h3>
  224. <div className="space-y-3 text-sm">
  225. <div>
  226. <div className="text-gray-500 mb-1 text-xs">网站</div>
  227. {detail?.website ? (
  228. <a
  229. href={detail.website}
  230. target="_blank"
  231. rel="noreferrer"
  232. className="text-xs text-blue-600 break-all hover:underline"
  233. >
  234. {detail.website}
  235. </a>
  236. ) : (
  237. <div className="text-gray-400 text-xs">暂未收集</div>
  238. )}
  239. </div>
  240. <div>
  241. <div className="text-gray-500 mb-1 text-xs">来源</div>
  242. {detail?.referrer ? (
  243. <a
  244. href={detail.referrer}
  245. target="_blank"
  246. rel="noreferrer"
  247. className="text-xs text-blue-600 break-all hover:underline"
  248. >
  249. {detail.referrer}
  250. </a>
  251. ) : (
  252. <div className="text-gray-400 text-xs">暂无来源信息</div>
  253. )}
  254. </div>
  255. <div>
  256. <div className="text-gray-500 mb-1 text-xs">语言</div>
  257. <div className="text-gray-700 text-xs">
  258. {displayValue(detail?.language, "暂未收集")}
  259. </div>
  260. </div>
  261. <div>
  262. <div className="text-gray-500 mb-1 text-xs">浏览器</div>
  263. <div className="text-gray-700 text-xs">
  264. {displayValue(detail?.browser, "暂未收集")}
  265. </div>
  266. </div>
  267. <div>
  268. <div className="text-gray-500 mb-1 text-xs">操作系统</div>
  269. <div className="text-gray-700 text-xs">
  270. {displayValue(detail?.os, "暂未收集")}
  271. </div>
  272. </div>
  273. <div>
  274. <div className="text-gray-500 mb-1 text-xs">IP 地址</div>
  275. <div className="text-gray-700 text-xs">
  276. {displayValue(detail?.ip_address, "暂未收集")}
  277. </div>
  278. </div>
  279. <div>
  280. <div className="text-gray-500 mb-1 text-xs">位置</div>
  281. <div className="text-gray-700 text-xs">
  282. {displayValue(detail?.location, "暂未收集")}
  283. </div>
  284. </div>
  285. <div>
  286. <div className="text-gray-500 mb-1 text-xs">最后活跃</div>
  287. <div className="text-gray-700 text-xs">
  288. {detail?.last_seen_at
  289. ? formatConversationTime(detail.last_seen_at)
  290. : "未知"}
  291. </div>
  292. </div>
  293. </div>
  294. </div>
  295. </div>
  296. {editingField && (
  297. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4">
  298. <div className="bg-white rounded-xl shadow-xl w-full max-w-sm p-6">
  299. <h3 className="text-base font-semibold text-gray-800 mb-3">
  300. 编辑{fieldLabels[editingField]}
  301. </h3>
  302. {editingField === "notes" ? (
  303. <textarea
  304. className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none h-32"
  305. value={editingValue}
  306. onChange={(event) => setEditingValue(event.target.value)}
  307. placeholder={`请输入${fieldLabels[editingField]}`}
  308. />
  309. ) : (
  310. <input
  311. type="text"
  312. className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
  313. value={editingValue}
  314. onChange={(event) => setEditingValue(event.target.value)}
  315. placeholder={`请输入${fieldLabels[editingField]}`}
  316. />
  317. )}
  318. {errorMessage && (
  319. <div className="text-xs text-red-500 mt-2">{errorMessage}</div>
  320. )}
  321. <div className="mt-4 flex justify-end gap-2">
  322. <button
  323. type="button"
  324. className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200"
  325. onClick={handleCloseEditor}
  326. disabled={saving}
  327. >
  328. 取消
  329. </button>
  330. <button
  331. type="button"
  332. className="px-3 py-1.5 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-300 disabled:cursor-not-allowed"
  333. onClick={handleSubmit}
  334. disabled={saving}
  335. >
  336. {saving ? "保存中..." : "保存"}
  337. </button>
  338. </div>
  339. </div>
  340. </div>
  341. )}
  342. </div>
  343. );
  344. }