|
|
@@ -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="请先在左侧选择一个会话" />
|