Procházet zdrojové kódy

feat: 群消息展示真实昵称与聊天内容

修复联系人/群成员解析、过滤撤回等系统消息,并优化群详情消息列表展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 před 3 měsíci
rodič
revize
f7457dcc17

+ 2 - 1
backend/backend/src/apps/pc/qiwe/controllers/messages.controller.ts

@@ -33,7 +33,8 @@ export async function handleListMessages(req: Request, res: Response): Promise<v
 
   const limit = Math.min(Number(req.query.limit) || 50, 200);
   const skip = Math.max(Number(req.query.skip) || 0, 0);
+  const chatOnly = req.query.chatOnly !== 'false';
 
-  const result = await listMessagesByRoom(roomId, limit, skip);
+  const result = await listMessagesByRoom(roomId, limit, skip, chatOnly);
   sendSuccess(res, result);
 }

+ 2 - 0
backend/backend/src/apps/pc/qiwe/services/groups.service.ts

@@ -53,6 +53,7 @@ function startOfToday(): Date {
 async function countMessagesToday(roomId: string): Promise<number> {
   const query = new Parse.Query('Message');
   query.equalTo('roomId', roomId);
+  query.containedIn('msgType', [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213]);
   query.greaterThanOrEqualTo('timestamp', startOfToday());
   return query.count({ useMasterKey: true });
 }
@@ -60,6 +61,7 @@ async function countMessagesToday(roomId: string): Promise<number> {
 async function countMessagesTotal(roomId: string): Promise<number> {
   const query = new Parse.Query('Message');
   query.equalTo('roomId', roomId);
+  query.containedIn('msgType', [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213]);
   return query.count({ useMasterKey: true });
 }
 

+ 148 - 13
backend/backend/src/apps/pc/qiwe/services/message-persist.service.ts

@@ -1,5 +1,28 @@
 import Parse from '../../../../shared/db/parse-client.js';
 import { ensureGroupChatForRoom } from './groups.service.js';
+import { getContactDetailsBatch, batchGetRoomDetails, getQiWeConfig } from './qiwe-api.service.js';
+import { resolveOwnerName } from './organization.service.js';
+
+const MSG_TYPE_LABELS: Record<number, string> = {
+  0: '文本消息',
+  2: '文本消息',
+  13: '链接消息',
+  14: '图片消息',
+  15: '文件消息',
+  16: '语音消息',
+  31: '应用消息',
+  2001: '已读通知',
+  2063: '撤回消息',
+};
+
+/** 用户可见的聊天类消息(不含撤回/已读/群事件等系统消息) */
+const CHAT_MSG_TYPES = new Set([
+  0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213,
+]);
+
+export function isChatMessageType(msgType: number): boolean {
+  return CHAT_MSG_TYPES.has(msgType);
+}
 
 export interface MessagePersistInput {
   msgUniqueIdentifier: string;
@@ -29,11 +52,23 @@ export function isLikelyWecomGroupRoomId(
   return room.length >= 14 && /^\d+$/.test(room);
 }
 
+function msgTypeLabel(msgType: number): string {
+  return MSG_TYPE_LABELS[msgType] ?? `消息类型 ${msgType}`;
+}
+
 function extractContent(msgType: number, msgData: Record<string, unknown> = {}): string {
-  const text = typeof msgData.content === 'string' ? msgData.content : '';
+  if (msgType === 2063 || msgData.revokeMsgUniqueIdentifier) {
+    return '撤回了一条消息';
+  }
+  const text = typeof msgData.content === 'string' ? msgData.content.trim() : '';
   if (text) return text;
-  if (typeof msgData.linkUrl === 'string') return msgData.linkUrl;
-  if (typeof msgData.title === 'string') return msgData.title;
+  if (typeof msgData.linkUrl === 'string' && msgData.linkUrl) return msgData.linkUrl;
+  if (typeof msgData.title === 'string' && msgData.title) return msgData.title;
+  if (typeof msgData.desc === 'string' && msgData.desc) return msgData.desc;
+  if (msgType === 14) return '[图片]';
+  if (msgType === 16) return '[语音]';
+  if (msgType === 15 || msgType === 20) return '[文件]';
+  if (msgType === 22 || msgType === 23) return '[视频]';
   const moreDetail = msgData.moreDetail;
   if (Array.isArray(moreDetail)) {
     const parts = moreDetail
@@ -41,13 +76,103 @@ function extractContent(msgType: number, msgData: Record<string, unknown> = {}):
       .filter(Boolean);
     if (parts.length > 0) return parts.join(' ');
   }
-  const keys = Object.keys(msgData);
-  if (keys.length === 0) return '';
+  return '';
+}
+
+/** 将已入库的内容转为可读展示文本(兼容历史 JSON 脏数据) */
+export function formatDisplayContent(content: string, msgType: number): string {
+  if (msgType === 2063) return '撤回了一条消息';
+  const trimmed = (content || '').trim();
+  if (!trimmed) return `(${msgTypeLabel(msgType)})`;
+  if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return trimmed;
+
+  try {
+    const parsed = JSON.parse(trimmed) as Record<string, unknown>;
+    if (parsed.revokeMsgUniqueIdentifier) return '撤回了一条消息';
+    const extracted = extractContent(msgType, parsed);
+    if (extracted) return extracted;
+    return `(${msgTypeLabel(msgType)})`;
+  } catch {
+    return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed;
+  }
+}
+
+function formatSenderDisplay(senderId: string, senderName: string): string {
+  if (senderName && senderName !== senderId) return senderName;
+  if (!senderId) return '未知';
+  return senderId;
+}
+
+async function loadRoomMemberNameMap(roomId: string, guid: string): Promise<Map<string, string>> {
+  const map = new Map<string, string>();
+
+  const dbQuery = new Parse.Query('GroupMember');
+  dbQuery.equalTo('roomId', roomId);
+  dbQuery.limit(500);
+  const dbMembers = await dbQuery.find({ useMasterKey: true });
+  for (const m of dbMembers) {
+    const uid = m.get('userId');
+    const nick = m.get('nickname');
+    if (uid && nick) map.set(String(uid), String(nick));
+  }
+
   try {
-    return JSON.stringify(msgData);
+    const rooms = await batchGetRoomDetails(guid, [roomId]);
+    for (const room of rooms) {
+      for (const mem of room.members) {
+        if (mem.nickname) map.set(mem.userId, mem.nickname);
+      }
+    }
   } catch {
-    return '';
+    /* 群详情不可用时跳过 */
   }
+
+  return map;
+}
+
+async function enrichMessagesForDisplay(
+  messages: MessageDto[],
+  roomId: string,
+): Promise<MessageDto[]> {
+  const guid = getQiWeConfig().guid;
+  const memberMap = guid ? await loadRoomMemberNameMap(roomId, guid) : new Map<string, string>();
+
+  const needIds = [...new Set(
+    messages
+      .filter((m) => !memberMap.has(m.senderId) && (!m.senderName || m.senderName === m.senderId))
+      .map((m) => m.senderId)
+      .filter(Boolean),
+  )];
+
+  const contactMap = new Map<string, string>();
+  if (needIds.length > 0) {
+    try {
+      const contacts = await getContactDetailsBatch(needIds.slice(0, 50));
+      for (const c of contacts) {
+        if (c.name && c.name !== c.userId) contactMap.set(c.userId, c.name);
+      }
+    } catch {
+      /* 联系人接口不可用时跳过 */
+    }
+    for (const id of needIds.slice(0, 20)) {
+      if (contactMap.has(id) || memberMap.has(id)) continue;
+      const orgName = await resolveOwnerName(id);
+      if (orgName && orgName !== id) contactMap.set(id, orgName);
+    }
+  }
+
+  return messages.map((m) => {
+    const resolvedName =
+      (m.senderName && m.senderName !== m.senderId ? m.senderName : '')
+      || memberMap.get(m.senderId)
+      || contactMap.get(m.senderId)
+      || '';
+    return {
+      ...m,
+      senderName: formatSenderDisplay(m.senderId, resolvedName),
+      content: formatDisplayContent(m.content, m.msgType),
+    };
+  });
 }
 
 export function buildSyncMsgUniqueId(
@@ -189,7 +314,8 @@ export async function listMessagesByRoom(
   roomId: string,
   limit = 50,
   skip = 0,
-): Promise<{ messages: MessageDto[]; total: number }> {
+  chatOnly = true,
+): Promise<{ messages: MessageDto[]; total: number; chatTotal: number }> {
   const query = new Parse.Query('Message');
   query.equalTo('roomId', roomId);
   query.descending('timestamp');
@@ -198,12 +324,21 @@ export async function listMessagesByRoom(
   countQuery.equalTo('roomId', roomId);
 
   const [rows, total] = await Promise.all([
-    query.skip(skip).limit(limit).find({ useMasterKey: true }),
+    query.skip(skip).limit(Math.min(limit * 3, 300)).find({ useMasterKey: true }),
     countQuery.count({ useMasterKey: true }),
   ]);
 
-  return {
-    messages: rows.map(toMessageDto),
-    total,
-  };
+  let dtos = rows.map(toMessageDto);
+  if (chatOnly) {
+    dtos = dtos.filter((m) => isChatMessageType(m.msgType));
+  }
+  dtos = dtos.slice(0, limit);
+
+  const chatTotalQuery = new Parse.Query('Message');
+  chatTotalQuery.equalTo('roomId', roomId);
+  chatTotalQuery.containedIn('msgType', [...CHAT_MSG_TYPES]);
+  const chatTotal = await chatTotalQuery.count({ useMasterKey: true });
+
+  const messages = await enrichMessagesForDisplay(dtos, roomId);
+  return { messages, total, chatTotal };
 }

+ 51 - 13
backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts

@@ -130,16 +130,26 @@ export async function getInternalContactList(limit = 100): Promise<InternalConta
   return resp.data.contactList || [];
 }
 
-/** 联系人详情批量(补全姓名等) */
+/** 联系人详情批量(API-18 /contact/batchGetUserinfo) */
 export async function getContactDetailsBatch(userIds: string[]): Promise<ContactDetail[]> {
   if (!TOKEN || !GUID || userIds.length === 0) return [];
 
   try {
-    const resp = await callApi<{ contactList: ContactDetail[] }>(
-      '/contact/batchGetContactInfo',
-      { guid: GUID, userIdList: userIds },
-    );
-    return resp.data.contactList || [];
+    const resp = await callApi<{
+      contactList: Array<{
+        userId: string;
+        nickname?: string;
+        name?: string;
+        mobile?: string;
+        position?: string;
+      }>;
+    }>('/contact/batchGetUserinfo', { guid: GUID, userIdList: userIds });
+    return (resp.data.contactList || []).map((c) => ({
+      userId: c.userId,
+      name: c.nickname || c.name || c.userId,
+      mobile: c.mobile || '',
+      position: c.position || '',
+    }));
   } catch {
     return userIds.map((userId) => ({ userId, name: userId, mobile: '', position: '' }));
   }
@@ -181,11 +191,33 @@ export async function syncMsgPage(
   };
 }
 
+export interface RoomMemberRef {
+  userId: string;
+  nickname: string;
+}
+
+export interface RoomDetailDto {
+  roomId: string;
+  roomName: string;
+  memberCount: number;
+  ownerId: string;
+  members: RoomMemberRef[];
+}
+
+function parseRoomMember(raw: unknown): RoomMemberRef | null {
+  if (!raw || typeof raw !== 'object') return null;
+  const o = raw as Record<string, unknown>;
+  const userId = String(o.userId ?? o.memberId ?? '');
+  if (!userId) return null;
+  const nickname = String(o.nickname ?? o.name ?? o.memberName ?? o.displayName ?? '');
+  return { userId, nickname };
+}
+
 /** 批量获取群详情(API-08) */
 export async function batchGetRoomDetails(
   guid: string,
   roomIdList: string[],
-): Promise<Array<{ roomId: string; roomName: string; memberCount: number; ownerId: string }>> {
+): Promise<RoomDetailDto[]> {
   const ids = roomIdList.map((id) => String(id).trim()).filter(Boolean);
   if (!TOKEN || !guid || ids.length === 0) return [];
 
@@ -198,12 +230,18 @@ export async function batchGetRoomDetails(
     }>;
   }>('/room/batchGetRoomDetail', { guid, roomIdList: ids });
 
-  return (resp.data.roomList || []).map((r) => ({
-    roomId: String(r.roomId),
-    roomName: r.roomName || '',
-    memberCount: Array.isArray(r.memberList) ? r.memberList.length : 0,
-    ownerId: r.roomOwnerId ? String(r.roomOwnerId) : '',
-  }));
+  return (resp.data.roomList || []).map((r) => {
+    const members = (r.memberList || [])
+      .map(parseRoomMember)
+      .filter((m): m is RoomMemberRef => m !== null);
+    return {
+      roomId: String(r.roomId),
+      roomName: r.roomName || '',
+      memberCount: members.length,
+      ownerId: r.roomOwnerId ? String(r.roomOwnerId) : '',
+      members,
+    };
+  });
 }
 
 /** 获取 Token 和 Guid 的可用性 */

+ 67 - 0
backend/backend/tests_python/list_chat_rooms.mjs

@@ -0,0 +1,67 @@
+import Parse from 'parse/node.js';
+
+Parse.initialize('lami-ai');
+Parse.serverURL = 'https://server.sh-lami.com/parse';
+Parse.masterKey = process.env.PARSE_MASTER_KEY || '5s1gfOasPqx9JKsA';
+
+const CHAT = [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213];
+
+const q = new Parse.Query('Message');
+q.containedIn('msgType', CHAT);
+q.notEqualTo('roomId', null);
+q.limit(1000);
+q.descending('timestamp');
+const msgs = await q.find({ useMasterKey: true });
+
+const byRoom = new Map();
+for (const m of msgs) {
+  const roomId = m.get('roomId');
+  if (!roomId) continue;
+  if (!byRoom.has(roomId)) byRoom.set(roomId, { chat: 0, total: 0, samples: [] });
+  const e = byRoom.get(roomId);
+  e.chat++;
+  const content = String(m.get('content') || '').slice(0, 100);
+  const sender = m.get('senderName') || m.get('senderId') || '';
+  const ts = m.get('timestamp');
+  if (e.samples.length < 3) e.samples.push({ msgType: m.get('msgType'), sender, content, ts });
+}
+
+const qAll = new Parse.Query('Message');
+qAll.notEqualTo('roomId', null);
+qAll.limit(1000);
+const allMsgs = await qAll.find({ useMasterKey: true });
+for (const m of allMsgs) {
+  const roomId = m.get('roomId');
+  if (!roomId) continue;
+  if (!byRoom.has(roomId)) byRoom.set(roomId, { chat: 0, total: 0, samples: [] });
+  byRoom.get(roomId).total++;
+}
+
+const roomIds = [...byRoom.keys()];
+const gq = new Parse.Query('GroupChat');
+gq.containedIn('roomId', roomIds);
+gq.limit(500);
+const groups = await gq.find({ useMasterKey: true });
+const nameMap = new Map(groups.map((g) => [g.get('roomId'), g.get('name') || g.get('roomName') || '']));
+
+const rows = [...byRoom.entries()]
+  .map(([roomId, v]) => ({ roomId, name: nameMap.get(roomId) || '(无名)', ...v }))
+  .sort((a, b) => b.chat - a.chat);
+
+console.log('=== 有聊天记录的群 ===');
+const withChat = rows.filter((r) => r.chat > 0);
+console.log(`共 ${withChat.length} 个群有聊天消息\n`);
+for (const r of withChat.slice(0, 25)) {
+  console.log('---');
+  console.log(`群名: ${r.name}`);
+  console.log(`roomId: ${r.roomId}`);
+  console.log(`聊天 ${r.chat} 条 / 全部 ${r.total} 条`);
+  for (const s of r.samples) {
+    console.log(`  [${s.msgType}] ${s.sender}: ${s.content}`);
+  }
+}
+
+console.log('\n=== 只有撤回/系统消息、无聊天文字的群 (前10) ===');
+for (const r of rows.filter((x) => x.chat === 0 && x.total > 0).slice(0, 10)) {
+  console.log(`${r.name || '(无名)'} | ${r.roomId} | 共 ${r.total} 条非聊天`);
+}

+ 8 - 3
src/app/core/services/api/qiwe-api.service.ts

@@ -171,9 +171,14 @@ export class QiweApiService {
     return this.api.postResult<SyncMessagesResult>('/qiwe/sync-messages', options);
   }
 
-  listMessages(roomId: string, limit = 50, skip = 0) {
-    const params = new URLSearchParams({ roomId, limit: String(limit), skip: String(skip) });
-    return this.api.getResult<{ messages: MessageDto[]; total: number }>(
+  listMessages(roomId: string, limit = 50, skip = 0, chatOnly = true) {
+    const params = new URLSearchParams({
+      roomId,
+      limit: String(limit),
+      skip: String(skip),
+      chatOnly: chatOnly ? 'true' : 'false',
+    });
+    return this.api.getResult<{ messages: MessageDto[]; total: number; chatTotal: number }>(
       `/qiwe/messages?${params.toString()}`,
     );
   }

+ 48 - 11
src/app/features/group-management/group-detail/group-detail.component.html

@@ -129,17 +129,54 @@
       } @else if (messagesError) {
         <app-message-strip type="error" [message]="messagesError" />
       } @else if (messages.length > 0) {
-        <p class="text-xs text-surface-500 mb-3">共 {{ messagesTotal }} 条(展示最近 {{ messages.length }} 条)</p>
-        <app-data-table
-          [data]="messageRows"
-          [columns]="messageColumns"
-          [loading]="false"
-          [showSearch]="true"
-          [showPagination]="false"
-          [rowClickable]="false"
-          [maxHeight]="'640px'"
-          [pageSize]="100"
-        />
+        <div class="flex items-center justify-between mb-3 gap-3 flex-wrap">
+          <p class="text-xs text-surface-500">
+            聊天记录 {{ chatMessagesTotal }} 条(展示 {{ messages.length }} 条)
+            @if (messagesTotal > chatMessagesTotal) {
+              <span>,另有 {{ messagesTotal - chatMessagesTotal }} 条系统/撤回通知</span>
+            }
+          </p>
+          @if (messagesTotal > chatMessagesTotal) {
+            <button
+              type="button"
+              (click)="toggleSystemMessages()"
+              class="text-xs text-primary-600 hover:text-primary-700"
+            >
+              {{ showSystemMessages ? '隐藏系统/撤回消息' : '显示系统/撤回消息' }}
+            </button>
+          }
+        </div>
+        <div class="fiori-card max-h-[640px] overflow-y-auto divide-y divide-surface-100">
+          @for (m of messageRows; track m.id) {
+            <div class="px-4 py-3 hover:bg-surface-50 transition-colors">
+              <div class="flex items-start justify-between gap-3 mb-1.5">
+                <div class="flex items-center gap-2 min-w-0">
+                  <span class="text-sm font-semibold text-surface-900 shrink-0">{{ m.senderLabel }}</span>
+                  <span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 text-surface-500 shrink-0">{{ m.msgTypeLabel }}</span>
+                </div>
+                <span class="text-xs text-surface-400 shrink-0 whitespace-nowrap">{{ m.timeLabel }}</span>
+              </div>
+              <p class="text-sm text-surface-700 whitespace-pre-wrap break-words leading-relaxed">{{ m.contentLabel }}</p>
+            </div>
+          }
+        </div>
+      } @else if (messagesTotal > 0 && !showSystemMessages) {
+        <div class="space-y-3">
+          <app-empty-state
+            title="暂无聊天记录"
+            [description]="'该群已同步 ' + messagesTotal + ' 条消息,但都是撤回/系统通知,没有聊天文字。请换一个群查看,或重新同步历史消息。'"
+            [icon]="['fas','comments']"
+          />
+          <div class="text-center">
+            <button
+              type="button"
+              (click)="toggleSystemMessages()"
+              class="text-sm text-primary-600 hover:text-primary-700"
+            >
+              查看撤回/系统通知({{ messagesTotal }} 条)
+            </button>
+          </div>
+        </div>
       } @else if (group.messageCountTotal > 0) {
         <app-empty-state
           title="消息加载异常"

+ 37 - 13
src/app/features/group-management/group-detail/group-detail.component.ts

@@ -43,8 +43,10 @@ export class GroupDetailComponent implements OnInit {
   riskEvents: RiskEvent[] = [];
   messages: MessageDto[] = [];
   messagesTotal = 0;
+  chatMessagesTotal = 0;
   messagesLoading = false;
   messagesError = '';
+  showSystemMessages = false;
 
   private static readonly MSG_TYPE_LABELS: Record<number, string> = {
     0: '文本',
@@ -52,7 +54,7 @@ export class GroupDetailComponent implements OnInit {
     13: '链接',
     14: '图片',
     31: '应用消息',
-    2001: '系统通知',
+    2063: '撤回',
   };
 
   readonly tabs: TabDef[] = [
@@ -70,27 +72,42 @@ export class GroupDetailComponent implements OnInit {
     { key: 'complianceStatus', label: '状态', template: 'status' as const },
   ];
 
-  readonly messageColumns = [
-    { key: 'senderLabel', label: '发送人', width: '140px' },
-    { key: 'contentLabel', label: '内容' },
-    { key: 'msgTypeLabel', label: '类型', width: '90px' },
-    { key: 'timeLabel', label: '时间', width: '160px' },
-  ];
-
   get messageRows(): Array<MessageDto & { senderLabel: string; contentLabel: string; msgTypeLabel: string; timeLabel: string }> {
     return this.messages.map((m) => ({
       ...m,
-      senderLabel: m.senderName || m.senderId || '—',
+      senderLabel: this.formatSenderLabel(m),
       contentLabel: this.formatMessageContent(m),
       msgTypeLabel: GroupDetailComponent.MSG_TYPE_LABELS[m.msgType] ?? `类型${m.msgType}`,
       timeLabel: new Date(m.timestamp).toLocaleString('zh-CN'),
     }));
   }
 
+  private formatSenderLabel(m: MessageDto): string {
+    if (m.senderName && m.senderName !== m.senderId) return m.senderName;
+    if (!m.senderId) return '未知';
+    return m.senderId;
+  }
+
   private formatMessageContent(m: MessageDto): string {
-    const text = (m.content || '').trim();
-    if (text) return text.length > 200 ? `${text.slice(0, 200)}…` : text;
-    return `(${GroupDetailComponent.MSG_TYPE_LABELS[m.msgType] ?? '无文本内容'})`;
+    if (m.msgType === 2063) return '撤回了一条消息';
+    let text = (m.content || '').trim();
+    if (text.startsWith('{') || text.startsWith('[')) {
+      try {
+        const parsed = JSON.parse(text) as Record<string, unknown>;
+        if (parsed['revokeMsgUniqueIdentifier']) return '撤回了一条消息';
+        text = typeof parsed['content'] === 'string' ? parsed['content'].trim()
+          : typeof parsed['title'] === 'string' ? parsed['title'].trim()
+          : typeof parsed['linkUrl'] === 'string' ? parsed['linkUrl'].trim()
+          : '';
+      } catch {
+        /* keep raw */
+      }
+    }
+    if (text) return text;
+    const labels: Record<number, string> = {
+      14: '[图片]', 16: '[语音]', 15: '[文件]', 22: '[视频]', 13: '[链接]',
+    };
+    return labels[m.msgType] ?? `(${GroupDetailComponent.MSG_TYPE_LABELS[m.msgType] ?? '无文本内容'})`;
   }
 
   readonly riskColumns = [
@@ -151,15 +168,22 @@ export class GroupDetailComponent implements OnInit {
     if (!environment.useBackendApi) return;
     this.messagesLoading = true;
     this.messagesError = '';
-    const result = await this.qiweApi.listMessages(roomId, 100, 0);
+    const result = await this.qiweApi.listMessages(roomId, 100, 0, !this.showSystemMessages);
     if (result.ok && result.data) {
       this.messages = result.data.messages;
       this.messagesTotal = result.data.total;
+      this.chatMessagesTotal = result.data.chatTotal ?? result.data.messages.length;
     } else {
       this.messages = [];
       this.messagesTotal = 0;
+      this.chatMessagesTotal = 0;
       this.messagesError = result.error ?? '加载消息失败';
     }
     this.messagesLoading = false;
   }
+
+  toggleSystemMessages(): void {
+    this.showSystemMessages = !this.showSystemMessages;
+    if (this.group) void this.loadMessages(this.group.id);
+  }
 }