Selaa lähdekoodia

Refactor ImRecordPage to enhance message rendering and simplify history management

- Replaced the RangePickerPopover component with direct date range handling, streamlining the UI.
- Introduced new functions for parsing and rendering message bodies, including support for text, audio, and image messages.
- Updated state management for chat history to focus on week-based pagination, improving clarity and usability.
- Removed unused imports and variables to clean up the codebase.
0es 1 kuukausi sitten
vanhempi
sitoutus
6ee090ad90
1 muutettua tiedostoa jossa 250 lisäystä ja 124 poistoa
  1. 250 124
      src/app/(dashboard)/user/im-record/page.tsx

+ 250 - 124
src/app/(dashboard)/user/im-record/page.tsx

@@ -3,7 +3,7 @@
 import {
   CheckCircleOutlined,
   CloseCircleOutlined,
-  EllipsisOutlined,
+  CopyOutlined,
   SearchOutlined,
 } from "@ant-design/icons";
 import {
@@ -12,11 +12,10 @@ import {
   Button,
   Card,
   Col,
-  DatePicker,
   Empty,
   Form,
+  Image,
   Input,
-  Popover,
   Row,
   Space,
   Spin,
@@ -25,7 +24,6 @@ import {
   Typography,
 } from "antd";
 import type { ColumnsType } from "antd/es/table";
-import type { Dayjs } from "dayjs";
 import type React from "react";
 import { useMemo, useRef, useState } from "react";
 import UserBrief from "@/components/UserBrief";
@@ -34,7 +32,6 @@ import { getUserPage } from "@/services/user";
 import type { ImChatInfoVo, UserChatSessionItemVo } from "@/types/api";
 import dayjs from "@/utils/dayjs";
 
-const { RangePicker } = DatePicker;
 const { Text, Paragraph } = Typography;
 
 function formatUnixSeconds(sec?: number): string {
@@ -50,6 +47,142 @@ function safeJsonStringify(value: unknown): string {
   }
 }
 
+type TimMsgBodyElem = {
+  MsgType?: string;
+  MsgContent?: unknown;
+};
+
+function isTimMsgBodyElem(value: unknown): value is TimMsgBodyElem {
+  return !!value && typeof value === "object";
+}
+
+function isPlainRecord(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value);
+}
+
+function pickImageUrls(
+  infoArray: Array<{ Type?: number; URL?: string }> | undefined,
+): { previewUrl?: string; originUrl?: string } {
+  if (!infoArray?.length) return {};
+  const byType = new Map<number, string>();
+  for (const item of infoArray) {
+    const t = Number(item?.Type);
+    const url = String(item?.URL || "").trim();
+    if (!Number.isFinite(t) || !url) continue;
+    if (!byType.has(t)) byType.set(t, url);
+  }
+  const previewUrl = byType.get(3) || byType.get(2) || byType.get(1);
+  const originUrl = byType.get(1) || byType.get(2) || byType.get(3);
+  return { previewUrl, originUrl };
+}
+
+function renderParsedMsgBody(v: unknown): React.ReactNode {
+  if (!Array.isArray(v) || v.length === 0)
+    return <Text type="secondary">-</Text>;
+
+  const nodes: React.ReactNode[] = [];
+  for (const item of v) {
+    if (!isTimMsgBodyElem(item)) {
+      nodes.push(
+        <Text key={`unknown-${nodes.length}`} type="secondary">
+          不支持展示的消息体
+        </Text>,
+      );
+      continue;
+    }
+
+    const msgType = String(item.MsgType || "").trim();
+    const msgContentRaw = (item as { MsgContent?: unknown }).MsgContent;
+    const msgContent = isPlainRecord(msgContentRaw) ? msgContentRaw : undefined;
+
+    if (msgType === "TIMTextElem") {
+      const text = String(msgContent?.Text ?? "").trim();
+      nodes.push(
+        <Paragraph
+          key={`text-${nodes.length}`}
+          style={{ marginBottom: 0, whiteSpace: "pre-wrap" }}
+          ellipsis={{ rows: 3, expandable: true }}
+        >
+          {text || <Text type="secondary">(空文本)</Text>}
+        </Paragraph>,
+      );
+      continue;
+    }
+
+    if (msgType === "TIMSoundElem") {
+      const url = String(msgContent?.Url ?? "").trim();
+      nodes.push(
+        <Space
+          key={`sound-${nodes.length}`}
+          direction="vertical"
+          size={4}
+          style={{ width: "100%" }}
+        >
+          {url ? (
+            <audio
+              controls
+              preload="none"
+              src={url}
+              style={{ width: 260 }}
+              aria-label="Voice message"
+            >
+              <track kind="captions" />
+            </audio>
+          ) : (
+            <Text type="secondary">(无可播放地址)</Text>
+          )}
+        </Space>,
+      );
+      continue;
+    }
+
+    if (msgType === "TIMImageElem") {
+      const raw = msgContent?.ImageInfoArray;
+      const infoArray = Array.isArray(raw)
+        ? (raw as Array<{ Type?: number; URL?: string }>)
+        : [];
+      const { previewUrl, originUrl } = pickImageUrls(infoArray);
+      const showUrl = previewUrl || originUrl;
+
+      nodes.push(
+        <Space
+          key={`image-${nodes.length}`}
+          direction="vertical"
+          size={4}
+          style={{ width: "100%" }}
+        >
+          {showUrl ? (
+            <Image
+              src={showUrl}
+              width={60}
+              height={60}
+              wrapperStyle={{ borderRadius: 6, overflow: "hidden" }}
+              style={{ objectFit: "cover" }}
+              preview={{ src: originUrl || showUrl }}
+            />
+          ) : (
+            <Text type="secondary">(无图片地址)</Text>
+          )}
+        </Space>,
+      );
+      continue;
+    }
+
+    nodes.push(
+      <Text key={`unsupported-${nodes.length}`} type="secondary">
+        不支持展示的消息体
+      </Text>,
+    );
+  }
+
+  if (nodes.length === 0) return <Text type="secondary">-</Text>;
+  return (
+    <Space direction="vertical" size={8} style={{ width: "100%" }}>
+      {nodes}
+    </Space>
+  );
+}
+
 function getSessionRowKey(s: UserChatSessionItemVo): string {
   return `${s.type}-${s.userNo || ""}-${s.groupId || ""}`;
 }
@@ -60,42 +193,18 @@ function getChatRowKey(c: ImChatInfoVo): string {
   );
 }
 
-type HistoryRangeValue = [Dayjs, Dayjs] | null;
-
-interface RangePickerPopoverProps {
-  value?: HistoryRangeValue;
-  onChange?: (value: HistoryRangeValue) => void;
-  disabled?: boolean;
+function getWeekRangeByPage(
+  page: number,
+): [ReturnType<typeof dayjs>, ReturnType<typeof dayjs>] {
+  const safe = Math.max(1, Math.floor(page || 1));
+  const end = dayjs().subtract((safe - 1) * 7, "day");
+  const start = end.subtract(7, "day");
+  return [start, end];
 }
 
-const RangePickerPopover: React.FC<RangePickerPopoverProps> = ({
-  value,
-  onChange,
-  disabled,
-}) => {
-  return (
-    <Popover
-      trigger="click"
-      placement="bottomRight"
-      content={
-        <RangePicker
-          showTime
-          style={{ width: 360 }}
-          disabled={disabled}
-          value={value ?? null}
-          onChange={(dates) => onChange?.((dates as HistoryRangeValue) ?? null)}
-        />
-      }
-    >
-      <Button icon={<EllipsisOutlined />} disabled={disabled} />
-    </Popover>
-  );
-};
-
 const ImRecordPage: React.FC = () => {
   const { message } = App.useApp();
   const [searchForm] = Form.useForm();
-  const [historyForm] = Form.useForm();
 
   const [currentUserNo, setCurrentUserNo] = useState<string>("");
 
@@ -120,20 +229,16 @@ const ImRecordPage: React.FC = () => {
 
   // Step 2: chat history
   const [historyLoading, setHistoryLoading] = useState(false);
-  const [historyPage, setHistoryPage] = useState(1);
-  const [historyTokenByPage, setHistoryTokenByPage] = useState<
-    Record<number, string | undefined>
-  >({ 1: undefined });
-  const [historyHasNext, setHistoryHasNext] = useState(false);
+  // "Page" means week index: 1 => last 7 days, 2 => 7-14 days ago, ...
+  const [historyWeekPage, setHistoryWeekPage] = useState(1);
   const [chatInfos, setChatInfos] = useState<ImChatInfoVo[]>([]);
-  const [currentRange, setCurrentRange] = useState<[Dayjs, Dayjs] | null>(null);
 
   const sessionsReqId = useRef(0);
   const historyReqId = useRef(0);
 
   const canQueryHistory = useMemo(() => {
-    return !!currentUserNo && !!selectedSession && !!currentRange;
-  }, [currentUserNo, selectedSession, currentRange]);
+    return !!currentUserNo && !!selectedSession;
+  }, [currentUserNo, selectedSession]);
 
   const loadSessions = async (page: number, userNoInput?: string) => {
     const userNo = (userNoInput ?? currentUserNo)?.trim();
@@ -165,12 +270,8 @@ const ImRecordPage: React.FC = () => {
   };
 
   const resetHistory = () => {
-    setHistoryPage(1);
-    setHistoryTokenByPage({ 1: undefined });
-    setHistoryHasNext(false);
+    setHistoryWeekPage(1);
     setChatInfos([]);
-    setCurrentRange(null);
-    historyForm.resetFields();
   };
 
   const loadSessionsForUser = async (userNoInput: string) => {
@@ -283,9 +384,8 @@ const ImRecordPage: React.FC = () => {
     await loadSessions(nextPage);
   };
 
-  const loadHistory = async (
-    page: number,
-    range: [Dayjs, Dayjs],
+  const loadHistoryWeek = async (
+    weekPage: number,
     fromAccountInput?: string,
     toAccountInput?: string,
     sessionTypeInput?: number,
@@ -303,26 +403,48 @@ const ImRecordPage: React.FC = () => {
     const reqId = ++historyReqId.current;
     setHistoryLoading(true);
     try {
-      const minTime = range[0].unix();
-      const maxTime = range[1].unix();
-      const next = historyTokenByPage[page];
-
-      const res = await getChatHistory({
-        fromAccount,
-        toAccount,
-        minTime,
-        maxTime,
-        next: next || undefined,
-      });
+      const [start, end] = getWeekRangeByPage(weekPage);
+      const minTime = start.unix();
+      const maxTime = end.unix();
+
+      const all: ImChatInfoVo[] = [];
+      let next: string | undefined;
+      let guard = 0;
+
+      // Pull all cursor pages within the week window.
+      while (guard < 200) {
+        guard += 1;
+        const res = await getChatHistory({
+          fromAccount,
+          toAccount,
+          minTime,
+          maxTime,
+          next,
+        });
+
+        if (reqId !== historyReqId.current) return;
+
+        all.push(...(res.chatInfos || []));
+        if (!res.next) break;
+        next = res.next;
+        if (all.length >= 20000) break;
+      }
 
       if (reqId !== historyReqId.current) return;
 
-      setChatInfos(res.chatInfos || []);
-      setHistoryHasNext(!!res.next);
-      setHistoryTokenByPage((prev) => ({
-        ...prev,
-        [page + 1]: res.next || undefined,
-      }));
+      all.sort((a, b) => {
+        const ta = Number(a.msgTimeStamp || 0);
+        const tb = Number(b.msgTimeStamp || 0);
+        if (ta !== tb) return tb - ta;
+        const sa = Number(a.msgSeq || 0);
+        const sb = Number(b.msgSeq || 0);
+        if (sa !== sb) return sb - sa;
+        const ra = Number(a.msgRandom || 0);
+        const rb = Number(b.msgRandom || 0);
+        return rb - ra;
+      });
+
+      setChatInfos(all);
     } catch (e) {
       console.error("Failed to load IM chat history:", e);
       message.error("加载聊天记录失败");
@@ -334,27 +456,18 @@ const ImRecordPage: React.FC = () => {
   const selectSession = (session: UserChatSessionItemVo | null) => {
     setSelectedSession(session);
 
-    setHistoryPage(1);
-    setHistoryTokenByPage({ 1: undefined });
-    setHistoryHasNext(false);
+    setHistoryWeekPage(1);
     setChatInfos([]);
 
     if (!session) {
-      setCurrentRange(null);
-      historyForm.resetFields();
       return;
     }
 
-    // Default time range: 1 year ago -> now
-    const range: [Dayjs, Dayjs] = [dayjs().subtract(1, "year"), dayjs()];
-    setCurrentRange(range);
-    historyForm.setFieldsValue({ range });
-
     const fromAccount =
       currentUserNo?.trim() ||
       String(searchForm.getFieldValue("userNo") || "").trim();
 
-    void loadHistory(1, range, fromAccount, session.userNo, session.type);
+    void loadHistoryWeek(1, fromAccount, session.userNo, session.type);
   };
 
   const handleQueryHistory = async () => {
@@ -367,33 +480,26 @@ const ImRecordPage: React.FC = () => {
       return;
     }
 
-    const values = await historyForm.validateFields();
-    const range = values.range as [Dayjs, Dayjs] | undefined | null;
-    if (!range?.[0] || !range?.[1]) return;
-
-    setCurrentRange(range);
-    setHistoryPage(1);
-    setHistoryTokenByPage({ 1: undefined });
-    setHistoryHasNext(false);
     setChatInfos([]);
 
-    await loadHistory(1, range);
+    await loadHistoryWeek(historyWeekPage);
   };
 
-  const handlePrevHistory = async () => {
-    if (!currentRange) return;
-    if (historyPage <= 1) return;
-    const nextPage = historyPage - 1;
-    setHistoryPage(nextPage);
-    await loadHistory(nextPage, currentRange);
+  const handlePrevWeek = async () => {
+    if (!selectedSession) return;
+    if (historyWeekPage <= 1) return;
+    const nextPage = historyWeekPage - 1;
+    setHistoryWeekPage(nextPage);
+    setChatInfos([]);
+    await loadHistoryWeek(nextPage);
   };
 
-  const handleNextHistory = async () => {
-    if (!currentRange) return;
-    if (!historyHasNext) return;
-    const nextPage = historyPage + 1;
-    setHistoryPage(nextPage);
-    await loadHistory(nextPage, currentRange);
+  const handleNextWeek = async () => {
+    if (!selectedSession) return;
+    const nextPage = historyWeekPage + 1;
+    setHistoryWeekPage(nextPage);
+    setChatInfos([]);
+    await loadHistoryWeek(nextPage);
   };
 
   const sessionColumns: ColumnsType<UserChatSessionItemVo> = [
@@ -432,18 +538,31 @@ const ImRecordPage: React.FC = () => {
       render: (v: number) => formatUnixSeconds(v),
     },
     {
-      title: "消息",
+      title: "消息",
       dataIndex: "msgBody",
       render: (v: unknown) => {
-        const text = safeJsonStringify(v);
+        const rawText = safeJsonStringify(v);
         return (
-          <Paragraph
-            style={{ marginBottom: 0, whiteSpace: "pre-wrap" }}
-            ellipsis={{ rows: 2, expandable: true }}
-            copyable={{ text }}
-          >
-            {text}
-          </Paragraph>
+          <Space size={8} style={{ width: "100%" }}>
+            {renderParsedMsgBody(v)}
+            <Button
+              type="link"
+              size="small"
+              icon={<CopyOutlined />}
+              style={{ padding: 0, height: "auto", alignSelf: "flex-start" }}
+              onClick={async (e) => {
+                e.preventDefault();
+                e.stopPropagation();
+                try {
+                  await navigator.clipboard.writeText(rawText);
+                  message.success("已复制");
+                } catch (err) {
+                  console.error("Failed to copy:", err);
+                  message.error("复制失败");
+                }
+              }}
+            />
+          </Space>
         );
       },
     },
@@ -565,25 +684,39 @@ const ImRecordPage: React.FC = () => {
             title="聊天记录"
             extra={
               <Space>
-                <Button onClick={handlePrevHistory} disabled={historyPage <= 1}>
-                  上一页
+                <Button
+                  onClick={handlePrevWeek}
+                  disabled={!selectedSession || historyWeekPage <= 1}
+                >
+                  较新一周
                 </Button>
-                <Text type="secondary">第 {historyPage} 页</Text>
-                <Button onClick={handleNextHistory} disabled={!historyHasNext}>
-                  下一页
+                <Text type="secondary">第 {historyWeekPage} 周</Text>
+                <Button onClick={handleNextWeek} disabled={!selectedSession}>
+                  更早一周
                 </Button>
               </Space>
             }
           >
             <Space direction="vertical" size={12} style={{ width: "100%" }}>
               <Card size="small">
-                <Form form={historyForm} layout="inline">
+                <Form layout="inline">
                   <Form.Item label="From">
                     <Text>{currentUserNo || "-"}</Text>
                   </Form.Item>
                   <Form.Item label="To">
                     <Text>{selectedSession?.userNo || "-"}</Text>
                   </Form.Item>
+                  <Form.Item label="时间范围">
+                    {(() => {
+                      const [start, end] = getWeekRangeByPage(historyWeekPage);
+                      return (
+                        <Text type="secondary">
+                          {start.format("YYYY-MM-DD")} ~{" "}
+                          {end.format("YYYY-MM-DD")}
+                        </Text>
+                      );
+                    })()}
+                  </Form.Item>
                   <Space.Compact style={{ marginLeft: "auto" }}>
                     <Form.Item noStyle>
                       <Button
@@ -594,13 +727,6 @@ const ImRecordPage: React.FC = () => {
                         刷新记录
                       </Button>
                     </Form.Item>
-                    <Form.Item
-                      name="range"
-                      rules={[{ required: true, message: "请选择时间段" }]}
-                      noStyle
-                    >
-                      <RangePickerPopover disabled={!selectedSession} />
-                    </Form.Item>
                   </Space.Compact>
                 </Form>
               </Card>
@@ -617,7 +743,7 @@ const ImRecordPage: React.FC = () => {
                     canQueryHistory ? (
                       <Empty description="暂无聊天记录" />
                     ) : (
-                      <Empty description="请选择时间段后拉取聊天记录" />
+                      <Empty description="请先拉取会话列表并选择一个会话" />
                     )
                   ) : (
                     <Empty description="请先在左侧选择一个会话" />