VisitorDetailPanel.tsx 13 KB

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