Преглед изворни кода

feat: 消息同步、群目录对齐与看板范围选择器

实现企微历史消息同步与持久化,合并群列表/会话/消息三路来源并补全真实群名;修复群详情消息展示与累计消息统计,增强看板三级范围筛选并移除工单 mock 数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 пре 3 месеци
родитељ
комит
351d621382
36 измењених фајлова са 1628 додато и 173 уклоњено
  1. 3 0
      backend/backend/src/apps/pc/auth/services/auth.service.ts
  2. 3 1
      backend/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts
  3. 98 5
      backend/backend/src/apps/pc/dashboard/services/dashboard.service.ts
  4. 2 0
      backend/backend/src/apps/pc/health/server.ts
  5. 23 22
      backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts
  6. 39 0
      backend/backend/src/apps/pc/qiwe/controllers/messages.controller.ts
  7. 16 5
      backend/backend/src/apps/pc/qiwe/controllers/sync.controller.ts
  8. 5 1
      backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts
  9. 161 8
      backend/backend/src/apps/pc/qiwe/services/groups.service.ts
  10. 209 0
      backend/backend/src/apps/pc/qiwe/services/message-persist.service.ts
  11. 119 0
      backend/backend/src/apps/pc/qiwe/services/message-sync.service.ts
  12. 61 0
      backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts
  13. 66 2
      backend/backend/src/apps/pc/qiwe/services/sync.service.ts
  14. 22 25
      backend/backend/src/apps/pc/qiwe/services/webhook.service.ts
  15. 8 0
      backend/backend/src/index.ts
  16. 102 0
      backend/backend/src/shared/auth/dashboard-scope.util.ts
  17. 77 18
      backend/backend/src/shared/auth/data-scope.service.ts
  18. 4 0
      backend/backend/src/shared/db/schema-setup.ts
  19. 35 5
      backend/backend/tests_python/run_auto_test.mjs
  20. 1 0
      src/app/core/models/index.ts
  21. 18 2
      src/app/core/services/api/dashboard-api.service.ts
  22. 48 0
      src/app/core/services/api/qiwe-api.service.ts
  23. 3 28
      src/app/core/services/mock-data.service.ts
  24. 13 0
      src/app/features/dashboard/dashboard-scope.util.ts
  25. 11 3
      src/app/features/dashboard/dashboard.component.html
  26. 57 16
      src/app/features/dashboard/dashboard.component.ts
  27. 34 2
      src/app/features/group-management/group-detail/group-detail.component.html
  28. 60 0
      src/app/features/group-management/group-detail/group-detail.component.ts
  29. 28 5
      src/app/features/group-management/group-list/group-list.component.ts
  30. 2 4
      src/app/features/risk-control/work-orders/work-orders.component.ts
  31. 1 4
      src/app/features/workspace/workspace-issues/workspace-issues.component.ts
  32. 104 0
      src/app/shared/components/dashboard-scope-picker/dashboard-scope-picker.component.html
  33. 170 0
      src/app/shared/components/dashboard-scope-picker/dashboard-scope-picker.component.ts
  34. 1 1
      src/app/shared/components/data-table/data-table.component.html
  35. 5 0
      src/app/shared/components/data-table/data-table.component.ts
  36. 19 16
      src/app/shared/components/page-header/page-header.component.html

+ 3 - 0
backend/backend/src/apps/pc/auth/services/auth.service.ts

@@ -167,6 +167,9 @@ export async function register(data: {
   if (!isValidEmail(email)) {
     throw new AppError(400, 'INVALID_EMAIL', '邮箱格式不正确');
   }
+  if (!data.password || data.password.length < 6) {
+    throw new AppError(400, 'INVALID_PASSWORD', '密码长度不能少于6位');
+  }
 
   const phone = normalizePhone(data.phone);
   const existingPhone = await findUserByPhone(phone);

+ 3 - 1
backend/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts

@@ -7,7 +7,9 @@ export async function handleDashboardOverview(req: Request, res: Response): Prom
   const user = await requireUser(req);
   const scopeLevel = typeof req.query.scopeLevel === 'string' ? req.query.scopeLevel : undefined;
   const storeId = typeof req.query.storeId === 'string' ? req.query.storeId : undefined;
+  const district = typeof req.query.district === 'string' ? req.query.district : undefined;
+  const roomId = typeof req.query.roomId === 'string' ? req.query.roomId : undefined;
 
-  const overview = await getDashboardOverview(user, { scopeLevel, storeId });
+  const overview = await getDashboardOverview(user, { scopeLevel, storeId, district, roomId });
   sendSuccess(res, overview);
 }

+ 98 - 5
backend/backend/src/apps/pc/dashboard/services/dashboard.service.ts

@@ -5,7 +5,12 @@ import {
   type DataScopeRequest,
   type ResolvedDataScope,
 } from '../../../../shared/auth/data-scope.service.js';
+import {
+  buildCommunityDistrictIndex,
+  parseShanghaiDistrict,
+} from '../../../../shared/auth/dashboard-scope.util.js';
 import { listGroupChats, type GroupChatDto } from '../../qiwe/services/groups.service.js';
+import { listCommunities } from '../../qiwe/services/community.service.js';
 import { listStores, type StoreDto } from '../../qiwe/services/organization.service.js';
 
 export interface DashboardStatsDto {
@@ -20,15 +25,26 @@ export interface DashboardStatsDto {
   conversionRate: number;
 }
 
+export interface DashboardScopeGroupOption {
+  roomId: string;
+  roomName: string;
+  district: string;
+}
+
 export interface DashboardOverviewDto {
   stats: DashboardStatsDto;
   activityDistribution: { high: number; medium: number; low: number; inactive: number };
   lifecycleDistribution: Array<{ phase: string; label: string; count: number }>;
   healthGradeDistribution: Record<string, number>;
   storeComparison: Array<{ storeName: string; groupCount: number }>;
+  districtComparison: Array<{ district: string; groupCount: number }>;
   topGroups: GroupChatDto[];
   recentRiskEvents: [];
   stores: StoreDto[];
+  /** 上海区级列表(二级) */
+  districts: string[];
+  /** 按区分组的群列表(三级) */
+  groupsByDistrict: Record<string, DashboardScopeGroupOption[]>;
   scope: ResolvedDataScope;
   viewer: Pick<UserDto, 'id' | 'name' | 'role' | 'storeId' | 'storeName' | 'regionCode'>;
 }
@@ -78,8 +94,8 @@ function buildActivityDistribution(groups: GroupChatDto[]) {
 function buildStoreComparison(groups: GroupChatDto[]) {
   const storeMap = new Map<string, number>();
   for (const group of groups) {
-    if (!group.storeName) continue;
-    storeMap.set(group.storeName, (storeMap.get(group.storeName) || 0) + 1);
+    const name = group.storeName || '未绑定门店';
+    storeMap.set(name, (storeMap.get(name) || 0) + 1);
   }
   return Array.from(storeMap.entries()).map(([storeName, groupCount]) => ({
     storeName,
@@ -87,6 +103,49 @@ function buildStoreComparison(groups: GroupChatDto[]) {
   }));
 }
 
+function buildDistrictComparison(
+  groups: GroupChatDto[],
+  communityIndex: ReturnType<typeof buildCommunityDistrictIndex>,
+) {
+  const map = new Map<string, number>();
+  for (const group of groups) {
+    let district = '未分区';
+    if (group.communityId) {
+      district = communityIndex.byCommunityId.get(group.communityId) || '未分区';
+    } else if (group.communityName) {
+      district = parseShanghaiDistrict(group.communityName) || '未分区';
+    }
+    map.set(district, (map.get(district) || 0) + 1);
+  }
+  return Array.from(map.entries())
+    .map(([district, groupCount]) => ({ district, groupCount }))
+    .sort((a, b) => b.groupCount - a.groupCount);
+}
+
+function buildGroupsByDistrict(
+  groups: GroupChatDto[],
+  communityIndex: ReturnType<typeof buildCommunityDistrictIndex>,
+): Record<string, DashboardScopeGroupOption[]> {
+  const map = new Map<string, DashboardScopeGroupOption[]>();
+
+  for (const g of groups) {
+    let district = '未分区';
+    if (g.communityId) {
+      district = communityIndex.byCommunityId.get(g.communityId) || '未分区';
+    }
+    const list = map.get(district) || [];
+    list.push({ roomId: g.roomId, roomName: g.roomName || g.roomId, district });
+    map.set(district, list);
+  }
+
+  for (const [key, list] of map) {
+    list.sort((a, b) => a.roomName.localeCompare(b.roomName, 'zh-CN'));
+    map.set(key, list);
+  }
+
+  return Object.fromEntries(map);
+}
+
 function buildLifecycleDistribution(groups: GroupChatDto[]) {
   const labels: Record<string, string> = {
     pre_handover: '交房前',
@@ -115,27 +174,51 @@ export async function getDashboardOverview(
   user: UserDto,
   scopeRequest: DataScopeRequest = {},
 ): Promise<DashboardOverviewDto> {
-  const [allGroups, stores, scope] = await Promise.all([
+  const [allGroups, stores, communities, scope] = await Promise.all([
     listGroupChats(),
     listStores(),
+    listCommunities(),
     resolveDataScope(user, scopeRequest),
   ]);
 
-  const groups = filterGroupsByDataScope(allGroups, scope);
+  const communityIndex = buildCommunityDistrictIndex(communities);
+  const cityScoped = filterGroupsByDataScope(allGroups, {
+    ...scope,
+    level: scope.level === 'district' || scope.level === 'group' ? 'city' : scope.level,
+    district: undefined,
+    roomId: undefined,
+  }, communityIndex);
+
+  const groups = filterGroupsByDataScope(allGroups, scope, communityIndex);
   const topGroups = [...groups]
     .sort((a, b) => b.messageCountToday - a.messageCountToday)
     .slice(0, 10);
 
+  const groupsByDistrict = buildGroupsByDistrict(cityScoped, communityIndex);
+  const districts = [
+    ...communityIndex.districts,
+    ...(groupsByDistrict['未分区']?.length ? ['未分区'] : []),
+  ].filter((d, i, arr) => arr.indexOf(d) === i);
+
   return {
     stats: computeStats(groups),
     activityDistribution: buildActivityDistribution(groups),
     lifecycleDistribution: buildLifecycleDistribution(groups),
     healthGradeDistribution: buildHealthGradeDistribution(groups),
     storeComparison: buildStoreComparison(scope.level === 'global' ? allGroups : groups),
+    districtComparison: buildDistrictComparison(
+      scope.level === 'city' ? cityScoped : groups,
+      communityIndex,
+    ),
     topGroups,
     recentRiskEvents: [],
     stores,
-    scope,
+    districts,
+    groupsByDistrict,
+    scope: {
+      ...scope,
+      label: scope.label || resolveScopeLabel(scope),
+    },
     viewer: {
       id: user.id,
       name: user.name,
@@ -146,3 +229,13 @@ export async function getDashboardOverview(
     },
   };
 }
+
+function resolveScopeLabel(scope: ResolvedDataScope): string {
+  if (scope.label) return scope.label;
+  if (scope.level === 'city') return '全上海';
+  if (scope.level === 'district' && scope.district) return scope.district;
+  if (scope.level === 'group') return '单个客户群';
+  if (scope.level === 'global') return '全部门店汇总';
+  if (scope.storeName) return scope.storeName;
+  return '数据范围';
+}

+ 2 - 0
backend/backend/src/apps/pc/health/server.ts

@@ -9,6 +9,8 @@ app.listen(PORT, () => {
   console.log(`[PC Server] 版本信息:   http://localhost:${PORT}/api/version`);
   console.log(`[PC Server] Webhook:     http://localhost:${PORT}/api/qiwe/webhook`);
   console.log(`[PC Server] 群同步:      http://localhost:${PORT}/api/qiwe/sync-groups`);
+  console.log(`[PC Server] 消息同步:    http://localhost:${PORT}/api/qiwe/sync-messages`);
+  console.log(`[PC Server] 消息列表:    http://localhost:${PORT}/api/qiwe/messages?roomId=`);
   console.log(`[PC Server] 登录:        http://localhost:${PORT}/api/auth/login`);
   console.log(`[PC Server] Parse:       ${process.env.PARSE_SERVER_URL}`);
 });

+ 23 - 22
backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts

@@ -6,6 +6,8 @@ import {
   filterGroupsByDataScope,
   resolveDataScope,
 } from '../../../../shared/auth/data-scope.service.js';
+import { buildCommunityDistrictIndex } from '../../../../shared/auth/dashboard-scope.util.js';
+import { listCommunities } from '../services/community.service.js';
 import { getGroupChatByRoomId, listGroupChats } from '../services/groups.service.js';
 import { listStores } from '../services/organization.service.js';
 
@@ -20,33 +22,30 @@ export async function handleListGroups(req: Request, res: Response): Promise<voi
   const user = await requireUser(req);
   const scopeLevelQ = typeof req.query.scopeLevel === 'string' ? req.query.scopeLevel : undefined;
   const storeIdForScope = typeof req.query.storeId === 'string' ? req.query.storeId : undefined;
+  const districtQ = typeof req.query.district === 'string' ? req.query.district : undefined;
+  const roomIdQ = typeof req.query.roomId === 'string' ? req.query.roomId : undefined;
 
   const scope = await resolveDataScope(user, {
     scopeLevel: scopeLevelQ,
-    storeId: scopeLevelQ === 'store' ? storeIdForScope : undefined,
+    storeId: storeIdForScope,
+    district: districtQ,
+    roomId: roomIdQ,
   });
 
-  let filterStoreId: string | undefined;
-  if (scope.level === 'store') {
-    filterStoreId = scope.storeId;
-  } else if (storeIdForScope) {
-    if (user.role !== 'director') {
-      throw new AppError(403, 'SCOPE_DENIED', '无权按该门店筛选');
-    }
-    filterStoreId = storeIdForScope;
-  }
-
-  const groups = await listGroupChats({
-    storeId: filterStoreId || undefined,
-    activityLevel: typeof req.query.activityLevel === 'string' ? req.query.activityLevel : undefined,
-    healthStatus: typeof req.query.healthStatus === 'string' ? req.query.healthStatus : undefined,
-    healthGrade: typeof req.query.healthGrade === 'string' ? req.query.healthGrade : undefined,
-    lifecyclePhase: typeof req.query.lifecyclePhase === 'string' ? req.query.lifecyclePhase : undefined,
-    hasDocument: parseBoolean(req.query.hasDocument),
-    q: typeof req.query.q === 'string' ? req.query.q.trim() : undefined,
-  });
+  const [communities, groups] = await Promise.all([
+    listCommunities(),
+    listGroupChats({
+      activityLevel: typeof req.query.activityLevel === 'string' ? req.query.activityLevel : undefined,
+      healthStatus: typeof req.query.healthStatus === 'string' ? req.query.healthStatus : undefined,
+      healthGrade: typeof req.query.healthGrade === 'string' ? req.query.healthGrade : undefined,
+      lifecyclePhase: typeof req.query.lifecyclePhase === 'string' ? req.query.lifecyclePhase : undefined,
+      hasDocument: parseBoolean(req.query.hasDocument),
+      q: typeof req.query.q === 'string' ? req.query.q.trim() : undefined,
+    }),
+  ]);
 
-  const scopedGroups = filterGroupsByDataScope(groups, scope);
+  const communityIndex = buildCommunityDistrictIndex(communities);
+  const scopedGroups = filterGroupsByDataScope(groups, scope, communityIndex);
   const stores = await listStores();
   sendSuccess(res, { groups: scopedGroups, total: scopedGroups.length, stores, scope });
 }
@@ -64,8 +63,10 @@ export async function handleGetGroup(req: Request, res: Response): Promise<void>
     throw new AppError(404, 'GROUP_NOT_FOUND', '群不存在');
   }
 
+  const communities = await listCommunities();
+  const communityIndex = buildCommunityDistrictIndex(communities);
   const scope = await resolveDataScope(user, {});
-  const [scoped] = filterGroupsByDataScope([group], scope);
+  const [scoped] = filterGroupsByDataScope([group], scope, communityIndex);
   if (!scoped) {
     throw new AppError(403, 'SCOPE_DENIED', '无权查看该群');
   }

+ 39 - 0
backend/backend/src/apps/pc/qiwe/controllers/messages.controller.ts

@@ -0,0 +1,39 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { checkParseDatabase } from '../../../../shared/db/parse-health.service.js';
+import { listMessagesByRoom } from '../services/message-persist.service.js';
+import { syncMessagesFromQiWe } from '../services/message-sync.service.js';
+import { getQiWeConfig } from '../services/qiwe-api.service.js';
+
+export async function handleSyncMessages(req: Request, res: Response): Promise<void> {
+  const config = getQiWeConfig();
+  if (!config.token || !config.guid) {
+    throw new AppError(500, 'MISSING_CONFIG', '请在 .env 中配置 QIWE_TOKEN 和 QIWE_GUID');
+  }
+
+  const body = req.body ?? {};
+  const guid = typeof body.guid === 'string' && body.guid.trim() ? body.guid.trim() : config.guid;
+  const msgSeq = body.msgSeq !== undefined ? Number(body.msgSeq) : undefined;
+  const limit = body.limit !== undefined ? Number(body.limit) : undefined;
+  const resetCursor = Boolean(body.resetCursor);
+  const maxPages = body.maxPages !== undefined ? Number(body.maxPages) : undefined;
+
+  const sync = await syncMessagesFromQiWe({ guid, msgSeq, limit, maxPages, resetCursor });
+  const database = await checkParseDatabase();
+
+  sendSuccess(res, { sync, database });
+}
+
+export async function handleListMessages(req: Request, res: Response): Promise<void> {
+  const roomId = typeof req.query.roomId === 'string' ? req.query.roomId.trim() : '';
+  if (!roomId) {
+    throw new AppError(400, 'INVALID_QUERY', '请提供 roomId');
+  }
+
+  const limit = Math.min(Number(req.query.limit) || 50, 200);
+  const skip = Math.max(Number(req.query.skip) || 0, 0);
+
+  const result = await listMessagesByRoom(roomId, limit, skip);
+  sendSuccess(res, result);
+}

+ 16 - 5
backend/backend/src/apps/pc/qiwe/controllers/sync.controller.ts

@@ -2,7 +2,8 @@ import type { Request, Response } from 'express';
 import { AppError } from '../../../../shared/errors/app-error.js';
 import { sendSuccess } from '../../../../shared/http/response.js';
 import { checkParseDatabase } from '../../../../shared/db/parse-health.service.js';
-import { syncGroupsFromQiWe } from '../services/sync.service.js';
+import { reconcileGroupCatalog } from '../services/sync.service.js';
+import { backfillGroupChatsFromMessages } from '../services/groups.service.js';
 import { getQiWeConfig } from '../services/qiwe-api.service.js';
 
 export async function handleSyncGroups(_req: Request, res: Response): Promise<void> {
@@ -16,11 +17,21 @@ export async function handleSyncGroups(_req: Request, res: Response): Promise<vo
     throw new AppError(503, 'DB_UNAVAILABLE', dbBefore.error || '无法连接 Parse 数据库', { database: dbBefore });
   }
 
-  const sync = await syncGroupsFromQiWe();
+  const catalog = await reconcileGroupCatalog();
   const dbAfter = await checkParseDatabase();
 
-  sendSuccess(res, { sync, database: dbAfter });
-  if (sync.errors.length > 0) {
-    console.warn(`[Sync] 部分群写入失败: ${sync.errors.length} 条`);
+  sendSuccess(res, { catalog, sync: catalog.rooms, database: dbAfter });
+  if (catalog.rooms.errors.length > 0) {
+    console.warn(`[Sync] 部分群写入失败: ${catalog.rooms.errors.length} 条`);
   }
 }
+
+/** 按 Message 表中的 roomId 补全 GroupChat,不调用企微接口 */
+export async function handleBackfillGroupsFromMessages(_req: Request, res: Response): Promise<void> {
+  const config = getQiWeConfig();
+  if (!config.guid) {
+    throw new AppError(500, 'MISSING_CONFIG', '请在 .env 中配置 QIWE_GUID');
+  }
+  const created = await backfillGroupChatsFromMessages(config.guid);
+  sendSuccess(res, { created });
+}

+ 5 - 1
backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts

@@ -1,7 +1,8 @@
 import { Router } from 'express';
 import { asyncHandler } from '../../../../shared/http/async-handler.js';
 import { handleWebhook } from '../controllers/webhook.controller.js';
-import { handleSyncGroups } from '../controllers/sync.controller.js';
+import { handleSyncGroups, handleBackfillGroupsFromMessages } from '../controllers/sync.controller.js';
+import { handleSyncMessages, handleListMessages } from '../controllers/messages.controller.js';
 import { handleListGroups, handleGetGroup } from '../controllers/groups.controller.js';
 import { handleListStores } from '../controllers/stores.controller.js';
 import { handleListCommunities } from '../controllers/community.controller.js';
@@ -24,5 +25,8 @@ router.get('/groups', asyncHandler(handleListGroups));
 router.get('/groups/:roomId', asyncHandler(handleGetGroup));
 router.post('/webhook', asyncHandler(handleWebhook));
 router.post('/sync-groups', asyncHandler(handleSyncGroups));
+router.post('/backfill-groups-from-messages', asyncHandler(handleBackfillGroupsFromMessages));
+router.post('/sync-messages', asyncHandler(handleSyncMessages));
+router.get('/messages', asyncHandler(handleListMessages));
 
 export default router;

+ 161 - 8
backend/backend/src/apps/pc/qiwe/services/groups.service.ts

@@ -3,6 +3,7 @@ import { deriveHealthMetrics } from '../utils/group-metrics.util.js';
 import { type LifecyclePhase } from '../utils/lifecycle.util.js';
 import { getCommunityLifecycleMap } from './community.service.js';
 import { resolveOwnerName } from './organization.service.js';
+import { batchGetRoomDetails } from './qiwe-api.service.js';
 
 export interface GroupChatDto {
   id: string;
@@ -29,6 +30,7 @@ export interface GroupChatDto {
   documentPinned: boolean;
   documentInNotice: boolean;
   messageCountToday: number;
+  messageCountTotal: number;
   memberChange24h: number;
   updatedAt: string;
 }
@@ -55,19 +57,82 @@ async function countMessagesToday(roomId: string): Promise<number> {
   return query.count({ useMasterKey: true });
 }
 
+async function countMessagesTotal(roomId: string): Promise<number> {
+  const query = new Parse.Query('Message');
+  query.equalTo('roomId', roomId);
+  return query.count({ useMasterKey: true });
+}
+
+/** 消息入库时发现群不存在则创建占位群,便于列表与详情按 roomId 关联 */
+export async function ensureGroupChatForRoom(roomId: string, guid: string): Promise<void> {
+  if (!roomId || !guid) return;
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const existing = await query.first({ useMasterKey: true });
+  if (existing) return;
+
+  const obj = new Parse.Object('GroupChat');
+  obj.set('roomId', roomId);
+  obj.set('guid', guid);
+  obj.set('roomName', `群 ${roomId.slice(-6)}`);
+  obj.set('status', 'active');
+  obj.set('memberCount', 0);
+  initNewGroupDefaults(obj);
+  await obj.save(null, { useMasterKey: true });
+  console.log(`[GroupChat] 由消息自动创建群 roomId=${roomId}`);
+}
+
+/** 根据已同步的群消息补全 GroupChat(roomId 与 Message 对齐) */
+export async function backfillGroupChatsFromMessages(guid: string): Promise<number> {
+  const query = new Parse.Query('Message');
+  query.equalTo('guid', guid);
+  query.equalTo('isGroupChat', 1);
+  query.exists('roomId');
+  query.limit(5000);
+  query.select('roomId');
+  const rows = await query.find({ useMasterKey: true });
+
+  const roomIds = new Set<string>();
+  for (const row of rows) {
+    const rid = row.get('roomId');
+    const key = rid ? String(rid) : '';
+    if (key.length >= 14 && /^\d+$/.test(key)) roomIds.add(key);
+  }
+
+  let created = 0;
+  for (const roomId of roomIds) {
+    const before = new Parse.Query('GroupChat');
+    before.equalTo('roomId', roomId);
+    before.equalTo('guid', guid);
+    const had = await before.first({ useMasterKey: true });
+    if (had) continue;
+    await ensureGroupChatForRoom(roomId, guid);
+    created++;
+  }
+  if (created > 0) {
+    console.log(`[GroupChat] 从消息补全 ${created} 个群记录`);
+  }
+  return created;
+}
+
 async function enrichGroup(
   obj: Parse.Object,
   lifecycleMap: Map<string, LifecyclePhase>,
   persist = true,
 ): Promise<GroupChatDto> {
   const roomId = obj.get('roomId') as string;
+  const guid = (obj.get('guid') as string) || '';
   const memberCount = obj.get('memberCount') ?? 0;
   const status = obj.get('status') || 'active';
   const hasDocument = obj.get('hasDocument') === true;
   const documentPinned = obj.get('documentPinned') === true;
   const documentInNotice = obj.get('documentInNotice') === true;
   const memberChange24h = obj.get('memberChange24h') ?? 0;
-  const messageCountToday = await countMessagesToday(roomId);
+  const [messageCountToday, messageCountTotal] = await Promise.all([
+    countMessagesToday(roomId),
+    countMessagesTotal(roomId),
+  ]);
 
   const metrics = deriveHealthMetrics({
     memberCount,
@@ -93,6 +158,7 @@ async function enrichGroup(
   }
 
   obj.set('messageCountToday', messageCountToday);
+  obj.set('messageCountTotal', messageCountTotal);
   obj.set('activityLevel', metrics.activityLevel);
   obj.set('opsActivityScore', metrics.opsActivityScore);
   obj.set('customerActivityScore', metrics.customerActivityScore);
@@ -134,15 +200,95 @@ function toDto(obj: Parse.Object): GroupChatDto {
     documentPinned: obj.get('documentPinned') === true,
     documentInNotice: obj.get('documentInNotice') === true,
     messageCountToday: obj.get('messageCountToday') ?? 0,
+    messageCountTotal: obj.get('messageCountTotal') ?? 0,
     memberChange24h: obj.get('memberChange24h') ?? 0,
     updatedAt: (obj.get('updatedAt') as Date)?.toISOString?.() || new Date().toISOString(),
   };
 }
 
+const ACTIVE_QIWE_GUID = process.env.QIWE_GUID || '';
+
+function scoreGroupForDedupe(g: GroupChatDto): number {
+  let score = 0;
+  if (g.guid === ACTIVE_QIWE_GUID) score += 100;
+  if (g.messageCountTotal > 0) score += 50 + Math.min(g.messageCountTotal, 1000);
+  if (g.roomName && !g.roomName.startsWith('群 ')) score += 20;
+  if (g.memberCount > 0) score += 10;
+  return score;
+}
+
+/** 同一 roomId 可能有多条记录(不同 guid / 来源),保留信息最完整的一条 */
+function dedupeGroupsByRoomId(groups: GroupChatDto[]): GroupChatDto[] {
+  const map = new Map<string, GroupChatDto>();
+  for (const g of groups) {
+    if (!g.roomId) continue;
+    const existing = map.get(g.roomId);
+    if (!existing || scoreGroupForDedupe(g) > scoreGroupForDedupe(existing)) {
+      map.set(g.roomId, g);
+    }
+  }
+  return [...map.values()];
+}
+
+function isPlaceholderRoomName(name: string): boolean {
+  return !name || name === '未命名群' || /^群 \d{6}$/.test(name);
+}
+
+/** 向企微拉取真实群名,替换「群 500656」类占位名 */
+export async function enrichPlaceholderGroupNames(guid: string, batchSize = 30): Promise<number> {
+  if (!guid) return 0;
+
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('guid', guid);
+  query.limit(200);
+  const groups = await query.find({ useMasterKey: true });
+  const targets = groups.filter((g) => isPlaceholderRoomName(String(g.get('roomName') || '')));
+  if (targets.length === 0) return 0;
+
+  const roomIds = targets.slice(0, batchSize).map((g) => String(g.get('roomId')));
+  let details: Awaited<ReturnType<typeof batchGetRoomDetails>>;
+  try {
+    details = await batchGetRoomDetails(guid, roomIds);
+  } catch (err: unknown) {
+    const message = err instanceof Error ? err.message : String(err);
+    console.warn(`[GroupChat] 拉取群详情失败: ${message}`);
+    return 0;
+  }
+
+  const detailMap = new Map(details.map((d) => [d.roomId, d]));
+  let updated = 0;
+  for (const obj of targets) {
+    const roomId = String(obj.get('roomId'));
+    const detail = detailMap.get(roomId);
+    if (!detail?.roomName) continue;
+    obj.set('roomName', detail.roomName);
+    if (detail.memberCount > 0) obj.set('memberCount', detail.memberCount);
+    if (detail.ownerId) obj.set('ownerId', detail.ownerId);
+    await obj.save(null, { useMasterKey: true });
+    updated++;
+  }
+  if (updated > 0) {
+    console.log(`[GroupChat] 已补全 ${updated} 个群的真实名称`);
+  }
+  return updated;
+}
+
+/**
+ * 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群(不调企微,速度快)
+ */
+export async function prepareGroupListForDisplay(): Promise<number> {
+  if (!ACTIVE_QIWE_GUID) return 0;
+  const created = await backfillGroupChatsFromMessages(ACTIVE_QIWE_GUID);
+  await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID);
+  return created;
+}
+
 export async function listGroupChats(
   filters: GroupListFilters = {},
   limit = 500,
 ): Promise<GroupChatDto[]> {
+  await prepareGroupListForDisplay();
+
   const query = new Parse.Query('GroupChat');
   query.descending('updatedAt');
 
@@ -171,25 +317,32 @@ export async function listGroupChats(
   }
 
   if (!filters.q) {
-    return filtered;
+    return dedupeGroupsByRoomId(filtered).sort((a, b) => b.messageCountTotal - a.messageCountTotal);
   }
 
   const q = filters.q.toLowerCase();
-  return filtered.filter((g) =>
+  return dedupeGroupsByRoomId(filtered.filter((g) =>
     g.roomName.toLowerCase().includes(q)
     || g.ownerName.toLowerCase().includes(q)
     || g.communityName.toLowerCase().includes(q)
-    || g.storeName.toLowerCase().includes(q),
-  );
+    || g.storeName.toLowerCase().includes(q)
+    || g.roomId.includes(q),
+  ));
 }
 
 export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto | null> {
+  await prepareGroupListForDisplay();
+
   const query = new Parse.Query('GroupChat');
   query.equalTo('roomId', roomId);
-  const obj = await query.first({ useMasterKey: true });
-  if (!obj) return null;
+  query.limit(20);
+  const rows = await query.find({ useMasterKey: true });
+  if (rows.length === 0) return null;
+
   const lifecycleMap = await getCommunityLifecycleMap();
-  return enrichGroup(obj, lifecycleMap);
+  const enriched = await Promise.all(rows.map((obj) => enrichGroup(obj, lifecycleMap)));
+  const [best] = dedupeGroupsByRoomId(enriched);
+  return best ?? null;
 }
 
 export async function migrateGroupBusinessFields(): Promise<void> {

+ 209 - 0
backend/backend/src/apps/pc/qiwe/services/message-persist.service.ts

@@ -0,0 +1,209 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import { ensureGroupChatForRoom } from './groups.service.js';
+
+export interface MessagePersistInput {
+  msgUniqueIdentifier: string;
+  roomId: string | null;
+  senderId: string;
+  receiverId?: string | null;
+  senderName?: string;
+  content: string;
+  atList?: string[];
+  msgType: number;
+  isGroupChat: boolean;
+  timestamp: Date;
+  guid: string;
+  seq?: number;
+}
+
+export type PersistMessageResult = 'created' | 'skipped';
+
+/** 企微真实客户群 ID 通常为 14 位以上纯数字;短 ID 且等于 senderId 多为应用/系统消息 */
+export function isLikelyWecomGroupRoomId(
+  fromRoomId: number | string | undefined | null,
+  senderId?: number | string,
+): boolean {
+  const room = fromRoomId != null ? String(fromRoomId) : '';
+  if (!room || room === '0') return false;
+  if (room === String(senderId ?? '')) return false;
+  return room.length >= 14 && /^\d+$/.test(room);
+}
+
+function extractContent(msgType: number, msgData: Record<string, unknown> = {}): string {
+  const text = typeof msgData.content === 'string' ? msgData.content : '';
+  if (text) return text;
+  if (typeof msgData.linkUrl === 'string') return msgData.linkUrl;
+  if (typeof msgData.title === 'string') return msgData.title;
+  const moreDetail = msgData.moreDetail;
+  if (Array.isArray(moreDetail)) {
+    const parts = moreDetail
+      .map((item) => (item && typeof item === 'object' && 'text' in item ? String((item as { text?: string }).text || '') : ''))
+      .filter(Boolean);
+    if (parts.length > 0) return parts.join(' ');
+  }
+  const keys = Object.keys(msgData);
+  if (keys.length === 0) return '';
+  try {
+    return JSON.stringify(msgData);
+  } catch {
+    return '';
+  }
+}
+
+export function buildSyncMsgUniqueId(
+  guid: string,
+  item: {
+    msgUniqueIdentifier?: string;
+    fromRoomId?: number | string;
+    seq?: number;
+    msgServerId?: number;
+  },
+): string {
+  if (item.msgUniqueIdentifier) return item.msgUniqueIdentifier;
+  const roomId = item.fromRoomId ?? 0;
+  const seq = item.seq ?? item.msgServerId ?? 0;
+  return `sync:${guid}:${roomId}:${seq}`;
+}
+
+export async function persistMessage(input: MessagePersistInput): Promise<PersistMessageResult> {
+  const query = new Parse.Query('Message');
+  query.equalTo('msgUniqueIdentifier', input.msgUniqueIdentifier);
+  const existing = await query.first({ useMasterKey: true });
+  if (existing) return 'skipped';
+
+  const obj = new Parse.Object('Message');
+  obj.set('msgUniqueIdentifier', input.msgUniqueIdentifier);
+  obj.set('roomId', input.roomId);
+  obj.set('senderId', input.senderId);
+  obj.set('receiverId', input.receiverId ?? null);
+  if (input.senderName) obj.set('senderName', input.senderName);
+  obj.set('content', input.content);
+  obj.set('atList', input.atList ?? []);
+  obj.set('msgType', input.msgType);
+  obj.set('isGroupChat', input.isGroupChat ? 1 : 0);
+  obj.set('timestamp', input.timestamp);
+  obj.set('guid', input.guid);
+  if (input.seq !== undefined) obj.set('seq', input.seq);
+  await obj.save(null, { useMasterKey: true });
+  if (input.isGroupChat && input.roomId) {
+    await ensureGroupChatForRoom(input.roomId, input.guid);
+  }
+  return 'created';
+}
+
+export async function persistWebhookChatMessage(event: {
+  msgUniqueIdentifier: string;
+  fromRoomId: number;
+  senderId: number;
+  receiverId?: number;
+  senderName?: string;
+  msgType: number;
+  timestamp: number;
+  guid: string;
+  msgData?: Record<string, unknown>;
+  seq?: number;
+}): Promise<PersistMessageResult> {
+  const isGroup = isLikelyWecomGroupRoomId(event.fromRoomId, event.senderId);
+  const msgData = event.msgData || {};
+  return persistMessage({
+    msgUniqueIdentifier: event.msgUniqueIdentifier,
+    roomId: isGroup ? String(event.fromRoomId) : null,
+    senderId: String(event.senderId),
+    receiverId: event.receiverId ? String(event.receiverId) : null,
+    senderName: event.senderName,
+    content: extractContent(event.msgType, msgData),
+    atList: Array.isArray(msgData.atList) ? (msgData.atList as string[]) : [],
+    msgType: event.msgType,
+    isGroupChat: isGroup,
+    timestamp: new Date(event.timestamp * 1000),
+    guid: event.guid,
+    seq: event.seq,
+  });
+}
+
+export async function persistSyncMsgItem(
+  guid: string,
+  raw: {
+    fromRoomId?: number | string;
+    senderId?: number | string;
+    receiverId?: number | string;
+    senderName?: string;
+    msgType?: number;
+    timestamp?: number;
+    seq?: number;
+    msgServerId?: number;
+    msgUniqueIdentifier?: string;
+    msgData?: Record<string, unknown>;
+  },
+): Promise<PersistMessageResult> {
+  const msgType = raw.msgType ?? 0;
+  const fromRoomId = raw.fromRoomId ?? 0;
+  const isGroup = isLikelyWecomGroupRoomId(fromRoomId, raw.senderId);
+  const msgData = raw.msgData || {};
+
+  return persistMessage({
+    msgUniqueIdentifier: buildSyncMsgUniqueId(guid, raw),
+    roomId: isGroup ? String(fromRoomId) : null,
+    senderId: String(raw.senderId ?? ''),
+    receiverId: raw.receiverId ? String(raw.receiverId) : null,
+    senderName: raw.senderName,
+    content: extractContent(msgType, msgData),
+    atList: Array.isArray(msgData.atList) ? (msgData.atList as string[]) : [],
+    msgType,
+    isGroupChat: isGroup,
+    timestamp: new Date((raw.timestamp ?? 0) * 1000),
+    guid,
+    seq: raw.seq ?? raw.msgServerId,
+  });
+}
+
+export interface MessageDto {
+  id: string;
+  msgUniqueIdentifier: string;
+  roomId: string | null;
+  senderId: string;
+  senderName: string;
+  content: string;
+  msgType: number;
+  timestamp: string;
+  guid: string;
+  seq?: number;
+}
+
+export function toMessageDto(obj: Parse.Object): MessageDto {
+  return {
+    id: obj.id!,
+    msgUniqueIdentifier: obj.get('msgUniqueIdentifier') || '',
+    roomId: obj.get('roomId') ?? null,
+    senderId: obj.get('senderId') || '',
+    senderName: obj.get('senderName') || '',
+    content: obj.get('content') || '',
+    msgType: obj.get('msgType') ?? 0,
+    timestamp: (obj.get('timestamp') as Date)?.toISOString?.() || new Date().toISOString(),
+    guid: obj.get('guid') || '',
+    seq: obj.get('seq'),
+  };
+}
+
+export async function listMessagesByRoom(
+  roomId: string,
+  limit = 50,
+  skip = 0,
+): Promise<{ messages: MessageDto[]; total: number }> {
+  const query = new Parse.Query('Message');
+  query.equalTo('roomId', roomId);
+  query.descending('timestamp');
+
+  const countQuery = new Parse.Query('Message');
+  countQuery.equalTo('roomId', roomId);
+
+  const [rows, total] = await Promise.all([
+    query.skip(skip).limit(limit).find({ useMasterKey: true }),
+    countQuery.count({ useMasterKey: true }),
+  ]);
+
+  return {
+    messages: rows.map(toMessageDto),
+    total,
+  };
+}

+ 119 - 0
backend/backend/src/apps/pc/qiwe/services/message-sync.service.ts

@@ -0,0 +1,119 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import { syncMsgPage } from './qiwe-api.service.js';
+import { persistSyncMsgItem } from './message-persist.service.js';
+import { backfillGroupChatsFromMessages } from './groups.service.js';
+
+const DEFAULT_PAGE_LIMIT = 50;
+const DEFAULT_MAX_PAGES = 30;
+
+function cursorKey(guid: string): string {
+  return `msg_sync_seq:${guid}`;
+}
+
+export async function getMessageSyncCursor(guid: string): Promise<number> {
+  const query = new Parse.Query('SystemMigration');
+  query.equalTo('key', cursorKey(guid));
+  const row = await query.first({ useMasterKey: true });
+  return row?.get('valueNumber') ?? 0;
+}
+
+export async function setMessageSyncCursor(guid: string, msgSeq: number): Promise<void> {
+  const query = new Parse.Query('SystemMigration');
+  query.equalTo('key', cursorKey(guid));
+  let row = await query.first({ useMasterKey: true });
+
+  if (!row) {
+    row = new Parse.Object('SystemMigration');
+    row.set('key', cursorKey(guid));
+  }
+
+  row.set('valueNumber', msgSeq);
+  row.set('completedAt', new Date());
+  await row.save(null, { useMasterKey: true });
+}
+
+export interface MessageSyncResult {
+  guid: string;
+  startSeq: number;
+  endSeq: number;
+  pages: number;
+  created: number;
+  skipped: number;
+  hasMore: boolean;
+  groupsBackfilled?: number;
+  errors: string[];
+}
+
+export async function syncMessagesFromQiWe(options: {
+  guid: string;
+  msgSeq?: number;
+  limit?: number;
+  maxPages?: number;
+  resetCursor?: boolean;
+}): Promise<MessageSyncResult> {
+  const guid = options.guid;
+  const limit = options.limit ?? DEFAULT_PAGE_LIMIT;
+  const maxPages = options.maxPages ?? DEFAULT_MAX_PAGES;
+  let msgSeq = options.resetCursor ? 0 : (options.msgSeq ?? await getMessageSyncCursor(guid));
+
+  const result: MessageSyncResult = {
+    guid,
+    startSeq: msgSeq,
+    endSeq: msgSeq,
+    pages: 0,
+    created: 0,
+    skipped: 0,
+    hasMore: false,
+    errors: [],
+  };
+
+  console.log(`[MsgSync] 开始同步 guid=${guid} msgSeq=${msgSeq} limit=${limit}`);
+
+  while (result.pages < maxPages) {
+    let pageData;
+    try {
+      pageData = await syncMsgPage(guid, msgSeq, limit);
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      result.errors.push(message);
+      break;
+    }
+
+    result.pages++;
+    const list = pageData.syncMsgList || [];
+
+    for (const raw of list) {
+      try {
+        const status = await persistSyncMsgItem(guid, raw);
+        if (status === 'created') result.created++;
+        else result.skipped++;
+      } catch (err: unknown) {
+        const message = err instanceof Error ? err.message : String(err);
+        result.errors.push(`persist: ${message}`);
+      }
+    }
+
+    const nextSeq = pageData.travelSyncKey ?? msgSeq;
+    result.endSeq = nextSeq;
+    await setMessageSyncCursor(guid, nextSeq);
+
+    const hasMore = pageData.hasMore === 1 || pageData.hasMore === true;
+    result.hasMore = hasMore;
+
+    console.log(
+      `[MsgSync] 第 ${result.pages} 页: batch=${list.length} created=${result.created} skipped=${result.skipped} nextSeq=${nextSeq} hasMore=${hasMore}`,
+    );
+
+    if (!hasMore) break;
+    msgSeq = nextSeq;
+  }
+
+  const groupsBackfilled = await backfillGroupChatsFromMessages(guid);
+  result.groupsBackfilled = groupsBackfilled;
+
+  console.log(
+    `[MsgSync] 完成 pages=${result.pages} created=${result.created} skipped=${result.skipped} endSeq=${result.endSeq} groupsBackfilled=${groupsBackfilled}`,
+  );
+
+  return result;
+}

+ 61 - 0
backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts

@@ -145,6 +145,67 @@ export async function getContactDetailsBatch(userIds: string[]): Promise<Contact
   }
 }
 
+export interface SyncMsgListItem {
+  fromRoomId?: number | string;
+  senderId?: number | string;
+  receiverId?: number | string;
+  senderName?: string;
+  msgType?: number;
+  timestamp?: number;
+  seq?: number;
+  msgServerId?: number;
+  msgUniqueIdentifier?: string;
+  msgData?: Record<string, unknown>;
+}
+
+/** 同步历史消息分页(API-06 /msg/syncMsg) */
+export async function syncMsgPage(
+  guid: string,
+  msgSeq: number,
+  limit = 50,
+): Promise<{
+  hasMore: number | boolean;
+  travelSyncKey: number;
+  syncMsgList: SyncMsgListItem[];
+}> {
+  const resp = await callApi<{
+    hasMore: number | boolean;
+    travelSyncKey: number;
+    syncMsgList: SyncMsgListItem[];
+  }>('/msg/syncMsg', { guid, msgSeq, limit });
+
+  return {
+    hasMore: resp.data.hasMore ?? 0,
+    travelSyncKey: resp.data.travelSyncKey ?? msgSeq,
+    syncMsgList: resp.data.syncMsgList || [],
+  };
+}
+
+/** 批量获取群详情(API-08) */
+export async function batchGetRoomDetails(
+  guid: string,
+  roomIdList: string[],
+): Promise<Array<{ roomId: string; roomName: string; memberCount: number; ownerId: string }>> {
+  const ids = roomIdList.map((id) => String(id).trim()).filter(Boolean);
+  if (!TOKEN || !guid || ids.length === 0) return [];
+
+  const resp = await callApi<{
+    roomList: Array<{
+      roomId: string;
+      roomName: string;
+      roomOwnerId?: string;
+      memberList?: unknown[];
+    }>;
+  }>('/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) : '',
+  }));
+}
+
 /** 获取 Token 和 Guid 的可用性 */
 export function getQiWeConfig() {
   return { apiBase: API_BASE, token: TOKEN, guid: GUID };

+ 66 - 2
backend/backend/src/apps/pc/qiwe/services/sync.service.ts

@@ -1,9 +1,73 @@
 import Parse from '../../../../shared/db/parse-client.js';
-import { getAllRooms } from './qiwe-api.service.js';
-import { initNewGroupDefaults } from './groups.service.js';
+import { getAllRooms, getSessionList } from './qiwe-api.service.js';
+import { backfillGroupChatsFromMessages, enrichPlaceholderGroupNames, initNewGroupDefaults } from './groups.service.js';
 
 const GUID = process.env.QIWE_GUID || '';
 
+/** 从会话列表补全群(getRoomList 为空时,群会话 sessionType=1 仍可能有数据) */
+export async function syncGroupSessionsFromQiWe(): Promise<{
+  created: number;
+  updated: number;
+  skipped: number;
+}> {
+  const result = { created: 0, updated: 0, skipped: 0 };
+  const sessions = await getSessionList(1);
+  console.log(`[Sync] 会话列表中群会话 ${sessions.length} 个`);
+
+  for (const session of sessions) {
+    const roomId = String(session.sessionId ?? '');
+    if (!roomId || roomId.length < 14 || !/^\d+$/.test(roomId)) {
+      result.skipped++;
+      continue;
+    }
+
+    try {
+      const query = new Parse.Query('GroupChat');
+      query.equalTo('roomId', roomId);
+      query.equalTo('guid', GUID);
+      const existing = await query.first({ useMasterKey: true });
+
+      if (existing) {
+        existing.set('status', 'active');
+        await existing.save(null, { useMasterKey: true });
+        result.updated++;
+      } else {
+        const obj = new Parse.Object('GroupChat');
+        obj.set('roomId', roomId);
+        obj.set('guid', GUID);
+        obj.set('roomName', `群 ${roomId.slice(-6)}`);
+        obj.set('status', 'active');
+        obj.set('memberCount', 0);
+        initNewGroupDefaults(obj);
+        await obj.save(null, { useMasterKey: true });
+        result.created++;
+      }
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      console.warn(`[Sync] 会话群 roomId=${roomId} 写入失败: ${message}`);
+    }
+  }
+
+  return result;
+}
+
+/** 一次性对齐群目录:企微群列表 + 会话 + 历史消息 */
+export async function reconcileGroupCatalog(): Promise<{
+  rooms: { created: number; updated: number; errors: string[] };
+  sessions: { created: number; updated: number; skipped: number };
+  fromMessages: number;
+  namesUpdated: number;
+}> {
+  const rooms = await syncGroupsFromQiWe();
+  const sessions = await syncGroupSessionsFromQiWe();
+  const fromMessages = await backfillGroupChatsFromMessages(GUID);
+  const namesUpdated = await enrichPlaceholderGroupNames(GUID);
+  console.log(
+    `[Sync] 群目录对齐完成 rooms+${rooms.created}/${rooms.updated} sessions+${sessions.created} messages+${fromMessages} names+${namesUpdated}`,
+  );
+  return { rooms, sessions, fromMessages, namesUpdated };
+}
+
 export async function syncGroupsFromQiWe(): Promise<{
   created: number;
   updated: number;

+ 22 - 25
backend/backend/src/apps/pc/qiwe/services/webhook.service.ts

@@ -1,5 +1,6 @@
 import Parse from '../../../../shared/db/parse-client.js';
 import { initNewGroupDefaults } from './groups.service.js';
+import { persistWebhookChatMessage } from './message-persist.service.js';
 
 interface CallbackEvent {
   guid: string;
@@ -159,30 +160,26 @@ async function handleGroupDismiss(event: CallbackEvent) {
   console.log(`[群解散] roomId=${roomId}`);
 }
 
-async function handleTextMessage(event: CallbackEvent) {
-  const query = new Parse.Query('Message');
-  query.equalTo('msgUniqueIdentifier', event.msgUniqueIdentifier);
-  const existing = await query.first({ useMasterKey: true });
-  if (existing) return;
-
-  const isGroup = event.fromRoomId && event.fromRoomId !== 0;
-
+async function handleChatMessage(event: CallbackEvent) {
   const msgData = event.msgData || {};
-  const obj = new Parse.Object('Message');
-  obj.set('msgUniqueIdentifier', event.msgUniqueIdentifier);
-  obj.set('roomId', isGroup ? String(event.fromRoomId) : null);
-  obj.set('senderId', String(event.senderId));
-  obj.set('receiverId', event.receiverId ? String(event.receiverId) : null);
-  obj.set('content', msgData.content || '');
-  obj.set('atList', msgData.atList || []);
-  obj.set('msgType', event.msgType);
-  obj.set('isGroupChat', isGroup ? 1 : 0);
-  obj.set('timestamp', new Date(event.timestamp * 1000));
-  obj.set('guid', event.guid);
-  await obj.save(null, { useMasterKey: true });
-
-  const tag = isGroup ? `roomId=${event.fromRoomId}` : '私聊';
-  console.log(`[文本消息] ${tag} sender=${event.senderId} content=${(msgData.content || '').slice(0, 50)}`);
+  const status = await persistWebhookChatMessage({
+    msgUniqueIdentifier: event.msgUniqueIdentifier,
+    fromRoomId: event.fromRoomId,
+    senderId: event.senderId,
+    receiverId: event.receiverId,
+    senderName: event.senderName,
+    msgType: event.msgType,
+    timestamp: event.timestamp,
+    guid: event.guid,
+    msgData,
+    seq: event.seq,
+  });
+
+  if (status === 'created') {
+    const tag = event.fromRoomId ? `roomId=${event.fromRoomId}` : '私聊';
+    const preview = (msgData.content || msgData.linkUrl || '').toString().slice(0, 50);
+    console.log(`[消息入库] ${tag} type=${event.msgType} sender=${event.senderId} ${preview}`);
+  }
 }
 
 export async function processWebhookEvent(event: CallbackEvent): Promise<void> {
@@ -190,8 +187,8 @@ export async function processWebhookEvent(event: CallbackEvent): Promise<void> {
 
   // 普通消息
   if (cmd === 15000) {
-    if (msgType === 0 || msgType === 2) {
-      await handleTextMessage(event);
+    if (msgType === 0 || msgType === 2 || msgType === 13) {
+      await handleChatMessage(event);
       return;
     }
     if (msgType === 1001) { await handleGroupNameChange(event); return; }

+ 8 - 0
backend/backend/src/index.ts

@@ -4,6 +4,7 @@ import './shared/db/parse-client.js';
 import { ensureSchemas } from './shared/db/schema-setup.js';
 import { bootstrapOrganization } from './shared/db/organization-bootstrap.js';
 import { bootstrapAuth } from './apps/pc/auth/services/auth.service.js';
+import { prepareGroupListForDisplay } from './apps/pc/qiwe/services/groups.service.js';
 
 async function bootstrap(): Promise<void> {
   console.log('[Setup] 检查 Parse Schema...');
@@ -12,6 +13,13 @@ async function bootstrap(): Promise<void> {
 
   await bootstrapOrganization();
   await bootstrapAuth();
+
+  if (process.env.QIWE_GUID) {
+    const created = await prepareGroupListForDisplay();
+    if (created > 0) {
+      console.log(`[Setup] 已从历史消息补全 ${created} 个群`);
+    }
+  }
 }
 
 await bootstrap();

+ 102 - 0
backend/backend/src/shared/auth/dashboard-scope.util.ts

@@ -0,0 +1,102 @@
+import type { GroupChatDto } from '../../apps/pc/qiwe/services/groups.service.js';
+import type { CommunityDto } from '../../apps/pc/qiwe/services/community.service.js';
+
+/** 上海城市范围默认门店编码 */
+export const SHANGHAI_STORE_CODE = 'SH001';
+
+/** 从小区地址解析上海区级名称,如「上海市黄浦区…」→「黄浦区」 */
+export function parseShanghaiDistrict(address: string): string | null {
+  if (!address || !address.includes('上海')) return null;
+  const m = address.match(/上海市([^市区县]+[市区县])/);
+  return m?.[1] ?? null;
+}
+
+export function isShanghaiAddress(address: string): boolean {
+  return Boolean(address && address.includes('上海'));
+}
+
+export interface CommunityDistrictIndex {
+  byCommunityId: Map<string, string>;
+  districts: string[];
+}
+
+export function buildCommunityDistrictIndex(communities: CommunityDto[]): CommunityDistrictIndex {
+  const byCommunityId = new Map<string, string>();
+  const districtSet = new Set<string>();
+
+  for (const c of communities) {
+    const district = parseShanghaiDistrict(c.address) || '其他';
+    byCommunityId.set(c.id, district);
+    if (isShanghaiAddress(c.address)) {
+      districtSet.add(district);
+    }
+  }
+
+  const districts = Array.from(districtSet).sort((a, b) => {
+    if (a === '其他') return 1;
+    if (b === '其他') return -1;
+    return a.localeCompare(b, 'zh-CN');
+  });
+
+  return { byCommunityId, districts };
+}
+
+export function filterGroupsForDashboardScope(
+  groups: GroupChatDto[],
+  scope: {
+    level: string;
+    storeId?: string;
+    shanghaiStoreId?: string;
+    district?: string;
+    roomId?: string;
+    storeIds?: string[];
+    communityIndex?: CommunityDistrictIndex;
+  },
+): GroupChatDto[] {
+  if (scope.level === 'group' && scope.roomId) {
+    return groups.filter((g) => g.roomId === scope.roomId);
+  }
+
+  let result = groups;
+
+  if (scope.level === 'city' && scope.shanghaiStoreId) {
+    const idx = scope.communityIndex;
+    result = result.filter((g) => {
+      if (!g.storeId || g.storeId === scope.shanghaiStoreId) return true;
+      if (g.communityId && idx?.byCommunityId.has(g.communityId)) {
+        const comm = idx.byCommunityId.get(g.communityId)!;
+        return comm !== '其他';
+      }
+      return false;
+    });
+  } else if (scope.level === 'global') {
+    // 全局:全部群(含未绑定门店的企微同步群)
+    result = groups;
+  } else if (scope.level === 'region' && scope.storeIds?.length) {
+    const allowed = new Set(scope.storeIds);
+    result = result.filter((g) => !g.storeId || allowed.has(g.storeId));
+  } else if (scope.level === 'store' && scope.storeId) {
+    // 门店:匹配门店 ID,或未绑定门店的群(企微同步常见)
+    result = result.filter((g) => !g.storeId || g.storeId === scope.storeId);
+  }
+
+  if (scope.level === 'district' && scope.district && scope.communityIndex) {
+    const idx = scope.communityIndex;
+    if (scope.shanghaiStoreId) {
+      result = result.filter((g) => {
+        if (!g.storeId || g.storeId === scope.shanghaiStoreId) return true;
+        if (g.communityId && idx.byCommunityId.has(g.communityId)) {
+          return idx.byCommunityId.get(g.communityId)! !== '其他';
+        }
+        return false;
+      });
+    }
+    result = result.filter((g) => {
+      if (!g.communityId) return scope.district === '未分区';
+      const d = idx.byCommunityId.get(g.communityId) || '未分区';
+      return d === scope.district;
+    });
+  }
+
+  return result;
+}

+ 77 - 18
backend/backend/src/shared/auth/data-scope.service.ts

@@ -2,13 +2,20 @@ import { AppError } from '../errors/app-error.js';
 import type { UserDto, UserRole } from '../../apps/pc/auth/services/auth.service.js';
 import type { GroupChatDto } from '../../apps/pc/qiwe/services/groups.service.js';
 import { listStores } from '../../apps/pc/qiwe/services/organization.service.js';
+import {
+  filterGroupsForDashboardScope,
+  SHANGHAI_STORE_CODE,
+  type CommunityDistrictIndex,
+} from './dashboard-scope.util.js';
 
-export type ScopeLevel = 'global' | 'region' | 'store';
+export type ScopeLevel = 'global' | 'region' | 'store' | 'city' | 'district' | 'group';
 
 export interface DataScopeRequest {
   scopeLevel?: string;
   storeId?: string;
   regionCode?: string;
+  district?: string;
+  roomId?: string;
 }
 
 export interface ResolvedDataScope {
@@ -18,17 +25,27 @@ export interface ResolvedDataScope {
   regionCode?: string;
   storeName?: string;
   ownerWecomId?: string;
+  /** 全上海范围对应的门店 ID(上海总部) */
+  shanghaiStoreId?: string;
+  district?: string;
+  roomId?: string;
+  label?: string;
 }
 
-export function filterGroupsByDataScope(groups: GroupChatDto[], scope: ResolvedDataScope): GroupChatDto[] {
-  let result = groups;
-
-  if (scope.level === 'store' && scope.storeId) {
-    result = result.filter((g) => g.storeId === scope.storeId);
-  } else if (scope.level === 'region' && scope.storeIds?.length) {
-    const allowed = new Set(scope.storeIds);
-    result = result.filter((g) => g.storeId && allowed.has(g.storeId));
-  }
+export function filterGroupsByDataScope(
+  groups: GroupChatDto[],
+  scope: ResolvedDataScope,
+  communityIndex?: CommunityDistrictIndex,
+): GroupChatDto[] {
+  let result = filterGroupsForDashboardScope(groups, {
+    level: scope.level,
+    storeId: scope.storeId,
+    shanghaiStoreId: scope.shanghaiStoreId,
+    district: scope.district,
+    roomId: scope.roomId,
+    storeIds: scope.storeIds,
+    communityIndex,
+  });
 
   if (scope.ownerWecomId) {
     result = result.filter((g) => g.ownerId === scope.ownerWecomId);
@@ -38,7 +55,8 @@ export function filterGroupsByDataScope(groups: GroupChatDto[], scope: ResolvedD
 }
 
 function parseScopeLevel(raw?: string): ScopeLevel | undefined {
-  if (raw === 'global' || raw === 'region' || raw === 'store') return raw;
+  const allowed: ScopeLevel[] = ['global', 'region', 'store', 'city', 'district', 'group'];
+  if (raw && allowed.includes(raw as ScopeLevel)) return raw as ScopeLevel;
   return undefined;
 }
 
@@ -47,13 +65,23 @@ async function storesInRegion(regionCode: string) {
   return stores.filter((s) => s.region === regionCode);
 }
 
-async function defaultDirectorScope(): Promise<ResolvedDataScope> {
+async function getShanghaiStoreId(): Promise<string | undefined> {
   const stores = await listStores();
-  const first = stores[0];
-  if (first) {
-    return { level: 'store', storeId: first.id, storeName: first.name };
+  return stores.find((s) => s.code === SHANGHAI_STORE_CODE)?.id;
+}
+
+async function defaultDirectorScope(): Promise<ResolvedDataScope> {
+  const shanghaiStoreId = await getShanghaiStoreId();
+  if (shanghaiStoreId) {
+    return {
+      level: 'city',
+      shanghaiStoreId,
+      storeId: shanghaiStoreId,
+      storeName: '上海总部',
+      label: '全上海',
+    };
   }
-  return { level: 'global' };
+  return { level: 'global', label: '全部门店汇总' };
 }
 
 async function defaultSupervisorScope(regionCode: string): Promise<ResolvedDataScope> {
@@ -83,11 +111,40 @@ export async function resolveDataScope(
 ): Promise<ResolvedDataScope> {
   const requestedLevel = parseScopeLevel(request.scopeLevel);
   const requestedStoreId = request.storeId?.trim() || undefined;
+  const requestedDistrict = request.district?.trim() || undefined;
+  const requestedRoomId = request.roomId?.trim() || undefined;
+  const shanghaiStoreId = await getShanghaiStoreId();
 
   switch (user.role as UserRole) {
     case 'director': {
+      if (requestedLevel === 'group' && requestedRoomId) {
+        return {
+          level: 'group',
+          roomId: requestedRoomId,
+          district: requestedDistrict,
+          shanghaiStoreId,
+          label: '单个客户群',
+        };
+      }
+      if (requestedLevel === 'district' && requestedDistrict) {
+        return {
+          level: 'district',
+          district: requestedDistrict,
+          shanghaiStoreId,
+          label: requestedDistrict,
+        };
+      }
+      if (requestedLevel === 'city') {
+        return {
+          level: 'city',
+          shanghaiStoreId,
+          storeId: shanghaiStoreId,
+          storeName: '上海总部',
+          label: '全上海',
+        };
+      }
       if (requestedLevel === 'global') {
-        return { level: 'global' };
+        return { level: 'global', label: '全部门店汇总' };
       }
       if (requestedLevel === 'store' && requestedStoreId) {
         const stores = await listStores();
@@ -95,7 +152,7 @@ export async function resolveDataScope(
         if (!store) {
           throw new AppError(400, 'INVALID_STORE', '门店不存在');
         }
-        return { level: 'store', storeId: store.id, storeName: store.name };
+        return { level: 'store', storeId: store.id, storeName: store.name, label: store.name };
       }
       return defaultDirectorScope();
     }
@@ -155,6 +212,8 @@ export function scopeToQueryParams(scope: ResolvedDataScope): DataScopeRequest {
     scopeLevel: scope.level,
     storeId: scope.storeId,
     regionCode: scope.regionCode,
+    district: scope.district,
+    roomId: scope.roomId,
   };
 }
 

+ 4 - 0
backend/backend/src/shared/db/schema-setup.ts

@@ -63,6 +63,7 @@ export async function ensureSchemas(): Promise<void> {
     documentPinned: 'Boolean',
     documentInNotice: 'Boolean',
     messageCountToday: 'Number',
+    messageCountTotal: 'Number',
     memberChange24h: 'Number',
     lifecyclePhase: 'String',
     healthGrade: 'String',
@@ -104,6 +105,7 @@ export async function ensureSchemas(): Promise<void> {
     msgUniqueIdentifier: 'String',
     roomId: 'String',
     senderId: 'String',
+    senderName: 'String',
     receiverId: 'String',
     content: 'String',
     atList: 'Array',
@@ -111,6 +113,7 @@ export async function ensureSchemas(): Promise<void> {
     isGroupChat: 'Number',
     timestamp: 'Date',
     guid: 'String',
+    seq: 'Number',
   });
 
   await ensureClassFields('AppUser', {
@@ -156,5 +159,6 @@ export async function ensureSchemas(): Promise<void> {
   await ensureClassFields('SystemMigration', {
     key: 'String',
     completedAt: 'Date',
+    valueNumber: 'Number',
   });
 }

+ 35 - 5
backend/backend/tests_python/run_auto_test.mjs

@@ -90,9 +90,9 @@ const t = director.token;
 
 // 4. Director scope
 const dirDefault = await get('/dashboard/overview', t);
-if (dirDefault.j?.data?.scope?.level === 'store') {
-  pass('director default scope is store', dirDefault.j.data.scope.storeName || '');
-} else fail('director default scope is store', dirDefault.j?.data?.scope?.level);
+if (dirDefault.j?.data?.scope?.level === 'city') {
+  pass('director default scope is city', dirDefault.j.data.scope.label || '');
+} else fail('director default scope is city', dirDefault.j?.data?.scope?.level);
 
 const dirGlobal = await get('/dashboard/overview?scopeLevel=global', t);
 if (dirGlobal.j?.data?.scope?.level === 'global') {
@@ -167,7 +167,37 @@ if (sync.j?.success) {
   }
 }
 
-// 13. Org sync
+// 12. QiWe sync-messages
+const msgSync = await post('/qiwe/sync-messages', null, { limit: 20, maxPages: 3 });
+if (msgSync.j?.success) {
+  const s = msgSync.j.data?.sync;
+  const dbMsg = msgSync.j.data?.database?.counts?.message;
+  pass('sync-messages API', `created=${s?.created} skipped=${s?.skipped} total=${dbMsg}`);
+} else {
+  const msg = msgSync.j?.error?.message || msgSync.status;
+  if (String(msg).includes('不在线') || String(msg).includes('QiWe')) {
+    fail('sync-messages API', `QiWe: ${msg}`);
+  } else {
+    fail('sync-messages API', msg);
+  }
+}
+
+// 13. Messages list (if groups exist)
+const sampleRoom = gGlobal.j?.data?.groups?.[0]?.roomId;
+if (sampleRoom) {
+  const msgs = await get(`/qiwe/messages?roomId=${encodeURIComponent(sampleRoom)}&limit=5`, t);
+  if (msgs.j?.success && Array.isArray(msgs.j.data?.messages)) {
+    pass('messages list API', `room=${sampleRoom} total=${msgs.j.data.total}`);
+  } else {
+    fail('messages list API', JSON.stringify(msgs.j?.error));
+  }
+} else if (globalCount === 0) {
+  pass('messages list API skipped', 'no groups');
+} else {
+  fail('messages list API', 'no sample room');
+}
+
+// 14. Org sync
 const org = await post('/qiwe/org/sync', t);
 if (org.j?.success) {
   pass('org sync', `members=${org.j.data?.members} source=${org.j.data?.source}`);
@@ -175,7 +205,7 @@ if (org.j?.success) {
   fail('org sync', org.j?.error?.message || org.status);
 }
 
-// 14. Risk API not implemented yet (expected 404)
+// 15. Risk API not implemented yet (expected 404)
 const risk = await get('/risk/work-orders', t);
 if (risk.status === 404) {
   pass('risk API not implemented (404 expected)', 'frontend still uses mock');

+ 1 - 0
src/app/core/models/index.ts

@@ -89,6 +89,7 @@ export interface Group {
   documentInNotice: boolean;
   activityLevel: 'high' | 'medium' | 'low' | 'inactive';
   messageCountToday: number;
+  messageCountTotal: number;
   memberChange24h: number;
   tags: string[];
 }

+ 18 - 2
src/app/core/services/api/dashboard-api.service.ts

@@ -15,12 +15,21 @@ export interface DashboardStatsDto {
   conversionRate: number;
 }
 
+export interface DashboardScopeGroupOption {
+  roomId: string;
+  roomName: string;
+  district: string;
+}
+
 export interface ResolvedScopeDto {
-  level: 'global' | 'region' | 'store';
+  level: 'global' | 'region' | 'store' | 'city' | 'district' | 'group';
   storeId?: string;
   storeIds?: string[];
   regionCode?: string;
   storeName?: string;
+  district?: string;
+  roomId?: string;
+  label?: string;
 }
 
 export interface DashboardOverviewDto {
@@ -29,16 +38,21 @@ export interface DashboardOverviewDto {
   lifecycleDistribution: Array<{ phase: string; label: string; count: number }>;
   healthGradeDistribution: Record<string, number>;
   storeComparison: Array<{ storeName: string; groupCount: number }>;
+  districtComparison: Array<{ district: string; groupCount: number }>;
   topGroups: GroupChatDto[];
   recentRiskEvents: [];
   stores: StoreDto[];
+  districts: string[];
+  groupsByDistrict: Record<string, DashboardScopeGroupOption[]>;
   scope: ResolvedScopeDto;
   viewer: { id: string; name: string; role: string; storeId: string; storeName: string; regionCode?: string };
 }
 
 export interface DashboardScopeQuery {
-  scopeLevel?: 'global' | 'region' | 'store';
+  scopeLevel?: 'global' | 'region' | 'store' | 'city' | 'district' | 'group';
   storeId?: string;
+  district?: string;
+  roomId?: string;
 }
 
 @Injectable({ providedIn: 'root' })
@@ -49,6 +63,8 @@ export class DashboardApiService {
     const params = new URLSearchParams();
     if (scope.scopeLevel) params.set('scopeLevel', scope.scopeLevel);
     if (scope.storeId) params.set('storeId', scope.storeId);
+    if (scope.district) params.set('district', scope.district);
+    if (scope.roomId) params.set('roomId', scope.roomId);
     const query = params.toString();
     const path = query ? `/dashboard/overview?${query}` : '/dashboard/overview';
     return this.api.getResult<DashboardOverviewDto>(path);

+ 48 - 0
src/app/core/services/api/qiwe-api.service.ts

@@ -28,6 +28,7 @@ export interface GroupChatDto {
   documentPinned: boolean;
   documentInNotice: boolean;
   messageCountToday: number;
+  messageCountTotal: number;
   memberChange24h: number;
   updatedAt: string;
 }
@@ -72,12 +73,47 @@ export interface GroupListFilters {
 
 export interface SyncGroupsResult {
   sync: { created: number; updated: number; errors: string[] };
+  catalog?: {
+    rooms: { created: number; updated: number; errors: string[] };
+    sessions: { created: number; updated: number; skipped: number };
+    fromMessages: number;
+  };
   database: {
     connected: boolean;
     counts?: { groupChat: number; groupMember: number; message: number };
   };
 }
 
+export interface MessageSyncResult {
+  guid: string;
+  startSeq: number;
+  endSeq: number;
+  pages: number;
+  created: number;
+  skipped: number;
+  hasMore: boolean;
+  groupsBackfilled?: number;
+  errors: string[];
+}
+
+export interface MessageDto {
+  id: string;
+  msgUniqueIdentifier: string;
+  roomId: string | null;
+  senderId: string;
+  senderName: string;
+  content: string;
+  msgType: number;
+  timestamp: string;
+  guid: string;
+  seq?: number;
+}
+
+export interface SyncMessagesResult {
+  sync: MessageSyncResult;
+  database: SyncGroupsResult['database'];
+}
+
 @Injectable({ providedIn: 'root' })
 export class QiweApiService {
   private readonly api = inject(ApiClientService);
@@ -131,6 +167,17 @@ export class QiweApiService {
     return this.api.postResult<SyncGroupsResult>('/qiwe/sync-groups', {});
   }
 
+  syncMessages(options: { msgSeq?: number; limit?: number; maxPages?: number; resetCursor?: boolean } = {}) {
+    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 }>(
+      `/qiwe/messages?${params.toString()}`,
+    );
+  }
+
   testWebhook(payload: unknown) {
     return this.api.postResult<null>('/qiwe/webhook', payload);
   }
@@ -159,6 +206,7 @@ export class QiweApiService {
       documentInNotice: dto.documentInNotice,
       activityLevel: dto.activityLevel as Group['activityLevel'],
       messageCountToday: dto.messageCountToday,
+      messageCountTotal: dto.messageCountTotal ?? 0,
       memberChange24h: dto.memberChange24h,
       tags: dto.status === 'active' ? ['企微同步'] : ['已解散'],
     };

+ 3 - 28
src/app/core/services/mock-data.service.ts

@@ -213,6 +213,7 @@ export class MockDataService {
           documentInNotice: Math.random() > 0.4,
           activityLevel: statuses[Math.floor(Math.random() * statuses.length)],
           messageCountToday: msgCount,
+          messageCountTotal: msgCount + Math.floor(Math.random() * 200),
           memberChange24h: Math.floor(Math.random() * 10) - 3,
           tags: [],
         });
@@ -288,33 +289,7 @@ export class MockDataService {
   }
 
   private generateWorkOrders(): void {
-    const events: RiskEvent[] = (this as any).risk_events_cache || [];
-    const orders: WorkOrder[] = [];
-    const statuses: Array<WorkOrder['status']> = ['open', 'in_progress', 'resolved', 'closed'];
-    let wid = 1;
-
-    for (const ev of events.filter(e => e.status === 'processing' || e.status === 'pending')) {
-      const status = statuses[Math.floor(Math.random() * statuses.length)];
-      orders.push({
-        id: `wo${wid}`,
-        riskEventId: ev.id,
-        title: ev.title,
-        description: ev.description,
-        groupName: ev.groupName,
-        communityName: ev.communityName,
-        assignedTo: 'u3',
-        assignedToName: '王运营',
-        priority: ev.severity,
-        status,
-        createdAt: ev.createdAt,
-        deadline: new Date(2025, 4, 30),
-        resolvedAt: status === 'resolved' ? new Date() : undefined,
-        resolution: status === 'resolved' ? '已联系当事人处理完毕' : undefined,
-        resolutionLog: ['系统自动生成工单', '已分配给运营处理'],
-      });
-      wid++;
-    }
-    (this as any).work_orders_cache = orders;
+    (this as any).work_orders_cache = [];
   }
 
   private generateRiskKeywords(): void {
@@ -483,7 +458,7 @@ export class MockDataService {
   getGroups(): Group[] { return (this as any).groups_cache || []; }
   getDocuments(): Document[] { return (this as any).documents_cache || []; }
   getRiskEvents(): RiskEvent[] { return (this as any).risk_events_cache || []; }
-  getWorkOrders(): WorkOrder[] { return (this as any).work_orders_cache || []; }
+  getWorkOrders(): WorkOrder[] { return []; }
   getRiskKeywords(): RiskKeyword[] { return (this as any).risk_keywords_cache || []; }
   getContentMaterials(): ContentMaterial[] { return (this as any).content_materials_cache || []; }
   getWeeklyPlans(): WeeklyPlan[] { return (this as any).weekly_plans_cache || []; }

+ 13 - 0
src/app/features/dashboard/dashboard-scope.util.ts

@@ -1,6 +1,7 @@
 import type { Group, User } from '../../core/models';
 import { getRoleLabel } from '../../core/models/role.constants';
 import type { StoreScopeSelection } from '../../shared/components/store-scope-picker/store-scope-picker.component';
+import type { DashboardScopeSelection } from '../../shared/components/dashboard-scope-picker/dashboard-scope-picker.component';
 import type { StoreDto } from '../../core/services/api/qiwe-api.service';
 
 export function buildDefaultStoreScope(stores: StoreDto[], fallbackName = '门店'): StoreScopeSelection {
@@ -55,6 +56,18 @@ export function storeScopeToQuery(scope: StoreScopeSelection): {
   };
 }
 
+export function dashboardScopeToQuery(scope: DashboardScopeSelection): {
+  scopeLevel: DashboardScopeSelection['level'];
+  district?: string;
+  roomId?: string;
+} {
+  return {
+    scopeLevel: scope.level,
+    district: scope.level === 'district' || scope.level === 'group' ? scope.district : undefined,
+    roomId: scope.level === 'group' ? scope.roomId : undefined,
+  };
+}
+
 export function filterGroupsByStoreScope(allGroups: Group[], scope: StoreScopeSelection): Group[] {
   if (scope.level === 'global') return allGroups;
   if (scope.level === 'region' && scope.regionCode) {

+ 11 - 3
src/app/features/dashboard/dashboard.component.html

@@ -3,9 +3,17 @@
   [subtitle]="dashboardSubtitle()"
   [icon]="faChartPie"
 >
-  @if (showStoreScopePicker) {
+  @if (showDashboardScopePicker) {
+    <app-dashboard-scope-picker
+      headerCenter
+      [districts]="dashboardDistricts"
+      [groupsByDistrict]="dashboardGroupsByDistrict"
+      [selection]="dashboardScopeSelection"
+      (selectionChange)="onDashboardScopeChange($event)"
+    />
+  } @else if (showStoreScopePicker) {
     <app-store-scope-picker
-      headerActions
+      headerCenter
       [stores]="storeScopeOptions"
       [selection]="storeScopeSelection"
       [showGlobalOption]="showGlobalScopeOption"
@@ -127,7 +135,7 @@
     (cardClick)="navigateTo('/communities')"
   />
   <app-chart-card
-    title="门店群数量对比"
+    [title]="showDashboardScopePicker ? '各区群数量对比' : '门店群数量对比'"
     [chartType]="'bar'"
     [chartData]="storeChartData"
     [loading]="loading"

+ 57 - 16
src/app/features/dashboard/dashboard.component.ts

@@ -14,6 +14,11 @@ import {
   StoreScopePickerComponent,
   type StoreScopeSelection,
 } from '../../shared/components/store-scope-picker/store-scope-picker.component';
+import {
+  DashboardScopePickerComponent,
+  type DashboardScopeSelection,
+  type DashboardScopeGroupOption,
+} from '../../shared/components/dashboard-scope-picker/dashboard-scope-picker.component';
 import { AuthStore } from '../../core/auth/auth.store';
 import { MockDataService } from '../../core/services/mock-data.service';
 import { DashboardApiService } from '../../core/services/api/dashboard-api.service';
@@ -28,6 +33,7 @@ import {
   buildSupervisorDefaultScope,
   buildLockedStoreScope,
   computeDashboardStats,
+  dashboardScopeToQuery,
   filterGroupsByStoreScope,
   filterGroupsForUser,
   filterRiskEventsByGroups,
@@ -41,6 +47,7 @@ import {
     FaIconComponent,
     PageHeaderComponent,
     StoreScopePickerComponent,
+    DashboardScopePickerComponent,
     StatCardComponent,
     ChartCardComponent,
     DataTableComponent,
@@ -67,6 +74,7 @@ export class DashboardComponent implements OnInit {
   protected readonly faChartBar = faChartBar;
 
   loading = true;
+  showDashboardScopePicker = false;
   showStoreScopePicker = false;
   showGlobalScopeOption = true;
   showRegionScopeOption = false;
@@ -74,9 +82,13 @@ export class DashboardComponent implements OnInit {
   scopeSubtitle = '';
   dataSource = signal<'api' | 'mock'>('mock');
 
-  storeScopeOptions: Array<{ id: string; name: string }> = [];
+  storeScopeOptions: Array<{ id: string; name: string; region?: string }> = [];
   storeScopeSelection: StoreScopeSelection = { level: 'store', label: '加载中…' };
 
+  dashboardDistricts: string[] = [];
+  dashboardGroupsByDistrict: Record<string, DashboardScopeGroupOption[]> = {};
+  dashboardScopeSelection: DashboardScopeSelection = { level: 'city', label: '全上海' };
+
   stats = {
     totalGroups: 0,
     totalMembers: 0,
@@ -121,12 +133,15 @@ export class DashboardComponent implements OnInit {
   ngOnInit(): void {
     const user = this.authStore.user();
 
-    this.showStoreScopePicker = isDirector(user?.role) || isRegionalSupervisor(user?.role);
-    this.showGlobalScopeOption = isDirector(user?.role);
+    this.showDashboardScopePicker = isDirector(user?.role);
+    this.showStoreScopePicker = isRegionalSupervisor(user?.role);
+    this.showGlobalScopeOption = false;
     this.showRegionScopeOption = isRegionalSupervisor(user?.role);
     this.regionScopeLabel = user?.regionCode ? `${user.regionCode}区域汇总(一级)` : '区域汇总(一级)';
 
-    if (this.showStoreScopePicker) {
+    if (this.showDashboardScopePicker) {
+      this.dashboardScopeSelection = { level: 'city', label: '全上海' };
+    } else if (this.showStoreScopePicker) {
       this.storeScopeSelection = { level: 'store', label: '加载门店…' };
     } else {
       this.storeScopeSelection = buildLockedStoreScope(user);
@@ -140,12 +155,20 @@ export class DashboardComponent implements OnInit {
     void this.loadData();
   }
 
+  protected onDashboardScopeChange(scope: DashboardScopeSelection): void {
+    this.dashboardScopeSelection = scope;
+    void this.loadData();
+  }
+
   private async loadData(): Promise<void> {
     this.loading = true;
     const user = this.authStore.user();
 
     if (environment.useBackendApi) {
-      const result = await this.dashboardApi.getOverview(storeScopeToQuery(this.storeScopeSelection));
+      const scopeQuery = this.showDashboardScopePicker
+        ? dashboardScopeToQuery(this.dashboardScopeSelection)
+        : storeScopeToQuery(this.storeScopeSelection);
+      const result = await this.dashboardApi.getOverview(scopeQuery);
       if (result.ok && result.data) {
         const data = result.data;
         this.dataSource.set('api');
@@ -153,26 +176,37 @@ export class DashboardComponent implements OnInit {
         this.topGroups = data.topGroups.map((g) => this.qiweApi.mapToGroup(g));
         this.recentRiskEvents = [];
 
+        this.dashboardDistricts = data.districts || [];
+        this.dashboardGroupsByDistrict = data.groupsByDistrict || {};
+
         if (this.showStoreScopePicker && data.stores?.length) {
           const accessible = isRegionalSupervisor(user?.role)
             ? data.stores.filter((s) => s.region === user?.regionCode)
             : data.stores;
           this.storeScopeOptions = accessible.map((s) => ({ id: s.id, name: s.name, region: s.region }));
-          if (data.scope) {
+          if (data.scope && !this.showDashboardScopePicker) {
             this.storeScopeSelection = {
               level: data.scope.level as StoreScopeSelection['level'],
               storeId: data.scope.storeId,
               regionCode: data.scope.regionCode,
-              label:
-                data.scope.level === 'global'
-                  ? '全部门店汇总(一级)'
-                  : data.scope.level === 'region'
-                    ? this.regionScopeLabel
-                    : (data.scope.storeName || '门店'),
+              label: data.scope.label || data.scope.storeName || '门店',
             };
           }
         }
 
+        if (this.showDashboardScopePicker && data.scope) {
+          const keepLabel =
+            this.dashboardScopeSelection.level === data.scope.level
+            && this.dashboardScopeSelection.label;
+          this.dashboardScopeSelection = {
+            level: (data.scope.level as DashboardScopeSelection['level']) || 'city',
+            district: data.scope.district ?? this.dashboardScopeSelection.district,
+            roomId: data.scope.roomId ?? this.dashboardScopeSelection.roomId,
+            roomName: this.dashboardScopeSelection.roomName,
+            label: keepLabel || data.scope.label || '全上海',
+          };
+        }
+
         this.lifecycleChartData = {
           labels: (data.lifecycleDistribution || []).map((x) => x.label),
           datasets: [{
@@ -183,7 +217,9 @@ export class DashboardComponent implements OnInit {
           }],
         };
 
-        this.scopeSubtitle = this.storeScopeSelection.label;
+        this.scopeSubtitle = this.showDashboardScopePicker
+          ? this.dashboardScopeSelection.label
+          : this.storeScopeSelection.label;
 
         this.activityChartData = {
           labels: ['高活跃', '中活跃', '低活跃', '不活跃'],
@@ -199,12 +235,17 @@ export class DashboardComponent implements OnInit {
           }],
         };
 
+        const useDistrictChart = this.showDashboardScopePicker && (data.districtComparison?.length ?? 0) > 0;
         this.storeChartData = {
-          labels: data.storeComparison.map((s) => s.storeName),
+          labels: useDistrictChart
+            ? data.districtComparison!.map((s) => s.district)
+            : data.storeComparison.map((s) => s.storeName),
           datasets: [{
             label: '客户群数量',
-            data: data.storeComparison.map((s) => s.groupCount),
-            backgroundColor: ['#0070F2', '#188918', '#E76500', '#E76500'],
+            data: useDistrictChart
+              ? data.districtComparison!.map((s) => s.groupCount)
+              : data.storeComparison.map((s) => s.groupCount),
+            backgroundColor: ['#0070F2', '#188918', '#E76500', '#8D8D90', '#5B738B'],
             borderRadius: 4,
           }],
         };

+ 34 - 2
src/app/features/group-management/group-detail/group-detail.component.html

@@ -27,7 +27,7 @@
     <!-- Info stat cards -->
     <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
       <app-stat-card title="成员数" [value]="group.memberCount" unit="人" [icon]="['fas','users']" color="info" />
-      <app-stat-card title="今日消息" [value]="group.messageCountToday" unit="条" [icon]="['fas','comments']" color="primary" />
+      <app-stat-card title="群消息" [value]="group.messageCountTotal" unit="条" [icon]="['fas','comments']" color="primary" />
       <app-stat-card title="24h成员变化" [value]="group.memberChange24h" unit="人" [icon]="['fas','user-plus']" [color]="group.memberChange24h >= 0 ? 'positive' : 'negative'" />
       <app-stat-card title="健康分" [value]="group.healthScore" unit="分" [icon]="['fas','heart']" [color]="group.healthScore >= 80 ? 'positive' : group.healthScore >= 60 ? 'critical' : 'negative'" />
     </div>
@@ -103,6 +103,10 @@
             <dt class="text-xs font-medium text-surface-500">成员数</dt>
             <dd class="text-sm text-surface-800 mt-0.5">{{ group.memberCount }} 人</dd>
           </div>
+          <div>
+            <dt class="text-xs font-medium text-surface-500">群消息(累计)</dt>
+            <dd class="text-sm text-surface-800 mt-0.5">{{ group.messageCountTotal }} 条</dd>
+          </div>
           <div>
             <dt class="text-xs font-medium text-surface-500">今日消息</dt>
             <dd class="text-sm text-surface-800 mt-0.5">{{ group.messageCountToday }} 条</dd>
@@ -120,7 +124,35 @@
     }
 
     @if (activeTab === 'messages') {
-      <app-empty-state title="群消息" description="消息数据将在群消息模块中展示" [icon]="['fas','comments']" />
+      @if (messagesLoading) {
+        <p class="text-sm text-surface-500 py-8 text-center">加载消息中…</p>
+      } @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"
+        />
+      } @else if (group.messageCountTotal > 0) {
+        <app-empty-state
+          title="消息加载异常"
+          description="列表显示有 {{ group.messageCountTotal }} 条消息,但详情未加载到。请返回后重新同步,或刷新页面重试。"
+          [icon]="['fas','comments']"
+        />
+      } @else {
+        <app-empty-state
+          title="暂无群消息"
+          [description]="dataSource() === 'api' ? '请先在群列表执行同步,或等待 Webhook 推送新消息' : '本地演示模式无消息数据'"
+          [icon]="['fas','comments']"
+        />
+      }
     }
 
     @if (activeTab === 'members') {

+ 60 - 0
src/app/features/group-management/group-detail/group-detail.component.ts

@@ -6,6 +6,7 @@ import { MockDataService } from '../../../core/services/mock-data.service';
 import { QiweApiService } from '../../../core/services/api/qiwe-api.service';
 import { environment } from '../../../../environments/environment';
 import type { Group, Document, RiskEvent } from '../../../core/models';
+import type { MessageDto } from '../../../core/services/api/qiwe-api.service';
 import { StatCardComponent } from '../../../shared/components/stat-card/stat-card.component';
 import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
 import { MessageStripComponent } from '../../../shared/components/message-strip/message-strip.component';
@@ -40,6 +41,19 @@ export class GroupDetailComponent implements OnInit {
 
   documents: Document[] = [];
   riskEvents: RiskEvent[] = [];
+  messages: MessageDto[] = [];
+  messagesTotal = 0;
+  messagesLoading = false;
+  messagesError = '';
+
+  private static readonly MSG_TYPE_LABELS: Record<number, string> = {
+    0: '文本',
+    2: '文本',
+    13: '链接',
+    14: '图片',
+    31: '应用消息',
+    2001: '系统通知',
+  };
 
   readonly tabs: TabDef[] = [
     { key: 'info', label: '基本信息' },
@@ -56,6 +70,29 @@ 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 || '—',
+      contentLabel: this.formatMessageContent(m),
+      msgTypeLabel: GroupDetailComponent.MSG_TYPE_LABELS[m.msgType] ?? `类型${m.msgType}`,
+      timeLabel: new Date(m.timestamp).toLocaleString('zh-CN'),
+    }));
+  }
+
+  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] ?? '无文本内容'})`;
+  }
+
   readonly riskColumns = [
     { key: 'title', label: '事件名称' },
     { key: 'type', label: '类型' },
@@ -81,6 +118,10 @@ export class GroupDetailComponent implements OnInit {
         this.dataSource.set('api');
         this.documents = [];
         this.riskEvents = [];
+        if (this.group.messageCountTotal > 0) {
+          this.activeTab = 'messages';
+        }
+        await this.loadMessages(id);
         this.loading = false;
         return;
       }
@@ -101,5 +142,24 @@ export class GroupDetailComponent implements OnInit {
 
   setActiveTab(key: string): void {
     this.activeTab = key;
+    if (key === 'messages' && this.group && environment.useBackendApi && this.messages.length === 0) {
+      void this.loadMessages(this.group.id);
+    }
+  }
+
+  private async loadMessages(roomId: string): Promise<void> {
+    if (!environment.useBackendApi) return;
+    this.messagesLoading = true;
+    this.messagesError = '';
+    const result = await this.qiweApi.listMessages(roomId, 100, 0);
+    if (result.ok && result.data) {
+      this.messages = result.data.messages;
+      this.messagesTotal = result.data.total;
+    } else {
+      this.messages = [];
+      this.messagesTotal = 0;
+      this.messagesError = result.error ?? '加载消息失败';
+    }
+    this.messagesLoading = false;
   }
 }

+ 28 - 5
src/app/features/group-management/group-list/group-list.component.ts

@@ -72,6 +72,7 @@ export class GroupListComponent implements OnInit {
     { key: 'communityName', label: '所属小区', sortable: true },
     { key: 'storeName', label: '门店' },
     { key: 'memberCount', label: '成员数', sortable: true },
+    { key: 'messageCountTotal', label: '群消息', sortable: true },
     { key: 'messageCountToday', label: '今日消息', sortable: true },
     { key: 'activityLevel', label: '活跃度', template: 'status' as const },
     { key: 'lifecyclePhase', label: '生命周期', template: 'status' as const },
@@ -159,7 +160,7 @@ export class GroupListComponent implements OnInit {
   async onSyncGroups(): Promise<void> {
     if (!environment.useBackendApi) return;
     this.syncing.set(true);
-    this.statusMessage.set({ type: 'info', text: '正在从企微平台同步群列表…' });
+    this.statusMessage.set({ type: 'info', text: '正在对齐群目录(企微群 + 会话 + 历史消息)…' });
 
     const result = await this.qiweApi.syncGroups();
     this.syncing.set(false);
@@ -169,12 +170,34 @@ export class GroupListComponent implements OnInit {
       return;
     }
 
-    const { sync, database } = result.data;
+    const { sync, database, catalog } = result.data;
+    const sessionPart = catalog?.sessions;
+    const msgBackfill = catalog?.fromMessages ?? 0;
+    let statusText = `群目录对齐:企微 ${sync.created} 新建 / ${sync.updated} 更新`;
+    if (sessionPart) {
+      statusText += `,会话补全 ${sessionPart.created} 个`;
+    }
+    if (msgBackfill > 0) {
+      statusText += `,消息发现 ${msgBackfill} 个新群`;
+    }
+    statusText += `。当前共 ${database.counts?.groupChat ?? 0} 个群记录`;
+
+    this.statusMessage.set({ type: 'info', text: `${statusText};正在同步历史消息…` });
+    const msgResult = await this.qiweApi.syncMessages({ limit: 50, maxPages: 30 });
+    if (msgResult.ok && msgResult.data) {
+      const m = msgResult.data.sync;
+      statusText += `;消息新增 ${m.created} 条(库内 ${msgResult.data.database.counts?.message ?? 0} 条)`;
+      if (m.groupsBackfilled) {
+        statusText += `,又发现 ${m.groupsBackfilled} 个有消息的群`;
+      }
+      if (m.errors.length) statusText += `,${m.errors.length} 条异常`;
+    } else {
+      statusText += `;消息同步失败:${msgResult.error ?? '未知错误'}`;
+    }
+
     this.statusMessage.set({
       type: sync.errors.length > 0 ? 'info' : 'success',
-      text: `同步完成:新建 ${sync.created} 个,更新 ${sync.updated} 个${
-        sync.errors.length ? `,${sync.errors.length} 条失败` : ''
-      }。数据库群 ${database.counts?.groupChat ?? 0} 个`,
+      text: statusText,
     });
     await this.loadData();
   }

+ 2 - 4
src/app/features/risk-control/work-orders/work-orders.component.ts

@@ -97,10 +97,8 @@ export class WorkOrdersComponent implements OnInit {
 
   loadData(): void {
     this.loading.set(true);
-    setTimeout(() => {
-      this.workOrders.set(this.mockData.getWorkOrders());
-      this.loading.set(false);
-    }, 300);
+    this.workOrders.set([]);
+    this.loading.set(false);
   }
 
   toggleExpand(id: string): void {

+ 1 - 4
src/app/features/workspace/workspace-issues/workspace-issues.component.ts

@@ -11,7 +11,6 @@ import { PageHeaderComponent } from '../../../shared/components/page-header/page
 import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
 import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
 import type { FilterOption } from '../../../shared/components/filter-bar/filter-bar.component';
-import { MockDataService } from '../../../core/services/mock-data.service';
 import type { WorkOrder } from '../../../core/models';
 
 interface KanbanColumn {
@@ -36,7 +35,6 @@ interface KanbanColumn {
 })
 export class WorkspaceIssuesComponent implements OnInit {
   private readonly router = inject(Router);
-  private readonly mockData = inject(MockDataService);
 
   protected readonly faTriangleExclamation = faTriangleExclamation;
   protected readonly faClock = faClock;
@@ -125,8 +123,7 @@ export class WorkspaceIssuesComponent implements OnInit {
   private readonly searchTerm = signal('');
 
   ngOnInit(): void {
-    const orders = this.mockData.getWorkOrders();
-    this.allWorkOrders.set(orders);
+    this.allWorkOrders.set([]);
     this.loading.set(false);
   }
 

+ 104 - 0
src/app/shared/components/dashboard-scope-picker/dashboard-scope-picker.component.html

@@ -0,0 +1,104 @@
+<div class="relative inline-block text-left">
+  <button
+    type="button"
+    (click)="togglePanel()"
+    class="inline-flex items-center gap-2 min-w-[240px] max-w-[320px] h-10 px-3 rounded-lg border border-surface-200 dark:border-surface-300 bg-white dark:bg-surface-100 text-sm text-surface-800 hover:border-primary-400 transition-colors"
+  >
+    <fa-icon [icon]="faMapLocationDot" class="text-primary-500 shrink-0" />
+    <span class="flex-1 truncate text-left">{{ currentSelection().label }}</span>
+    <fa-icon [icon]="faChevronDown" size="xs" class="text-surface-400 shrink-0" />
+  </button>
+
+  @if (open()) {
+    <div
+      class="absolute left-1/2 -translate-x-1/2 z-50 mt-2 flex rounded-lg border border-surface-200 dark:border-surface-300 bg-white dark:bg-surface-100 shadow-lg overflow-hidden"
+    >
+      <!-- 一级 · 城市 -->
+      <div class="w-40 shrink-0 border-r border-surface-100 dark:border-surface-300">
+        <div class="px-3 py-2 text-xs text-surface-400 border-b border-surface-100 dark:border-surface-300 bg-surface-50">
+          一级 · 城市
+        </div>
+        <ul class="max-h-72 overflow-y-auto py-1">
+          <li>
+            <button
+              type="button"
+              (mouseenter)="hoverCity()"
+              (click)="selectCity()"
+              class="w-full px-3 py-2.5 text-sm text-left transition-colors flex items-center justify-between gap-2 hover:bg-surface-50"
+              [class.bg-primary-50]="menuDepth() >= 2 || isCityActive()"
+              [class.text-primary-700]="menuDepth() >= 2 || isCityActive()"
+            >
+              <span>全上海</span>
+              <fa-icon [icon]="faChevronRight" class="text-xs text-surface-400 shrink-0" />
+            </button>
+          </li>
+        </ul>
+      </div>
+
+      <!-- 二级 · 行政区(悬停全上海后显示) -->
+      @if (menuDepth() >= 2) {
+        <div
+          class="w-40 shrink-0 border-r border-surface-100 dark:border-surface-300"
+          (mouseenter)="hoverCity()"
+        >
+          <div class="px-3 py-2 text-xs text-surface-400 border-b border-surface-100 dark:border-surface-300 bg-surface-50">
+            二级 · 行政区
+          </div>
+          <ul class="max-h-72 overflow-y-auto py-1">
+            @for (d of districts; track d) {
+              <li>
+                <button
+                  type="button"
+                  (mouseenter)="hoverDistrict(d)"
+                  (click)="onDistrictClick(d)"
+                  class="w-full px-3 py-2.5 text-sm text-left transition-colors flex items-center justify-between gap-2 hover:bg-surface-50"
+                  [class.bg-primary-50]="isDistrictHighlighted(d)"
+                  [class.text-primary-700]="isDistrictHighlighted(d) || isDistrictSelected(d)"
+                >
+                  <span class="truncate">{{ d }}</span>
+                  @if (districtHasGroups(d)) {
+                    <fa-icon [icon]="faChevronRight" class="text-xs text-surface-400 shrink-0" />
+                  }
+                </button>
+              </li>
+            }
+            @if (districts.length === 0) {
+              <li class="px-3 py-3 text-xs text-surface-400 text-center">暂无分区</li>
+            }
+          </ul>
+        </div>
+      }
+
+      <!-- 三级 · 客户群(悬停行政区后显示) -->
+      @if (menuDepth() >= 3 && activeDistrict()) {
+        <div
+          class="w-52 shrink-0"
+          (mouseenter)="hoverDistrict(activeDistrict()!)"
+        >
+          <div class="px-3 py-2 text-xs text-surface-400 border-b border-surface-100 dark:border-surface-300 bg-surface-50 truncate">
+            三级 · {{ activeDistrict() }}
+          </div>
+          <ul class="max-h-72 overflow-y-auto py-1">
+            @for (g of groupsInActiveDistrict(); track g.roomId) {
+              <li>
+                <button
+                  type="button"
+                  (click)="selectGroup(g)"
+                  class="w-full px-3 py-2.5 text-sm text-left truncate transition-colors hover:bg-surface-50"
+                  [class.bg-primary-50]="isGroupActive(g.roomId)"
+                  [class.text-primary-700]="isGroupActive(g.roomId)"
+                  [title]="g.roomName"
+                >
+                  {{ g.roomName }}
+                </button>
+              </li>
+            }
+            @if (groupsInActiveDistrict().length === 0) {
+              <li class="px-3 py-3 text-xs text-surface-400 text-center">该区暂无客户群</li>
+            }
+          </ul>
+        </div>
+      }
+    </div>
+  }
+</div>

+ 170 - 0
src/app/shared/components/dashboard-scope-picker/dashboard-scope-picker.component.ts

@@ -0,0 +1,170 @@
+import {
+  Component,
+  ElementRef,
+  HostListener,
+  Input,
+  Output,
+  EventEmitter,
+  inject,
+  signal,
+  computed,
+} from '@angular/core';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import {
+  faChevronDown,
+  faChevronRight,
+  faMapLocationDot,
+} from '@fortawesome/free-solid-svg-icons';
+
+export type DashboardScopeLevel = 'city' | 'district' | 'group';
+
+export interface DashboardScopeGroupOption {
+  roomId: string;
+  roomName: string;
+  district: string;
+}
+
+export interface DashboardScopeSelection {
+  level: DashboardScopeLevel;
+  district?: string;
+  roomId?: string;
+  roomName?: string;
+  label: string;
+}
+
+@Component({
+  selector: 'app-dashboard-scope-picker',
+  standalone: true,
+  imports: [FaIconComponent],
+  templateUrl: './dashboard-scope-picker.component.html',
+})
+export class DashboardScopePickerComponent {
+  private readonly elementRef = inject(ElementRef);
+
+  protected readonly faChevronDown = faChevronDown;
+  protected readonly faChevronRight = faChevronRight;
+  protected readonly faMapLocationDot = faMapLocationDot;
+
+  @Input() districts: string[] = [];
+  @Input() groupsByDistrict: Record<string, DashboardScopeGroupOption[]> = {};
+  @Input() set selection(value: DashboardScopeSelection | null) {
+    if (value) {
+      this.currentSelection.set(value);
+    }
+  }
+  @Output() selectionChange = new EventEmitter<DashboardScopeSelection>();
+
+  protected readonly open = signal(false);
+  protected readonly menuDepth = signal<1 | 2 | 3>(1);
+  protected readonly activeDistrict = signal<string | null>(null);
+  protected readonly currentSelection = signal<DashboardScopeSelection>({
+    level: 'city',
+    label: '全上海',
+  });
+
+  protected readonly groupsInActiveDistrict = computed(() => {
+    const d = this.activeDistrict();
+    if (!d) return [];
+    return this.groupsByDistrict[d] || [];
+  });
+
+  protected togglePanel(): void {
+    const next = !this.open();
+    this.open.set(next);
+    if (next) {
+      this.syncMenuFromSelection();
+    }
+  }
+
+  protected hoverCity(): void {
+    this.menuDepth.set(2);
+  }
+
+  protected hoverDistrict(district: string): void {
+    this.activeDistrict.set(district);
+    this.menuDepth.set(this.districtHasGroups(district) ? 3 : 2);
+  }
+
+  protected onDistrictClick(district: string): void {
+    this.selectDistrict(district);
+  }
+
+  protected selectCity(): void {
+    this.applySelection({ level: 'city', label: '全上海' });
+  }
+
+  protected selectDistrict(district: string): void {
+    this.applySelection({ level: 'district', district, label: district });
+  }
+
+  protected selectGroup(group: DashboardScopeGroupOption): void {
+    this.applySelection({
+      level: 'group',
+      district: group.district,
+      roomId: group.roomId,
+      roomName: group.roomName,
+      label: group.roomName,
+    });
+  }
+
+  protected isCityActive(): boolean {
+    return this.currentSelection().level === 'city';
+  }
+
+  protected isDistrictHighlighted(district: string): boolean {
+    return this.activeDistrict() === district || this.isDistrictSelected(district);
+  }
+
+  protected isDistrictSelected(district: string): boolean {
+    const s = this.currentSelection();
+    return (
+      (s.level === 'district' && s.district === district)
+      || (s.level === 'group' && s.district === district)
+    );
+  }
+
+  protected isGroupActive(roomId: string): boolean {
+    const s = this.currentSelection();
+    return s.level === 'group' && s.roomId === roomId;
+  }
+
+  protected districtHasGroups(district: string): boolean {
+    return (this.groupsByDistrict[district]?.length ?? 0) > 0;
+  }
+
+  private syncMenuFromSelection(): void {
+    const s = this.currentSelection();
+    if (s.level === 'group' && s.district) {
+      this.menuDepth.set(3);
+      this.activeDistrict.set(s.district);
+      return;
+    }
+    if (s.level === 'district' && s.district) {
+      this.menuDepth.set(this.districtHasGroups(s.district) ? 3 : 2);
+      this.activeDistrict.set(s.district);
+      return;
+    }
+    this.menuDepth.set(1);
+    this.activeDistrict.set(null);
+  }
+
+  private resetMenu(): void {
+    this.menuDepth.set(1);
+    this.activeDistrict.set(null);
+  }
+
+  private applySelection(selection: DashboardScopeSelection): void {
+    this.currentSelection.set(selection);
+    this.selectionChange.emit(selection);
+    this.open.set(false);
+    this.resetMenu();
+  }
+
+  @HostListener('document:click', ['$event'])
+  protected onDocumentClick(event: MouseEvent): void {
+    if (!this.elementRef.nativeElement.contains(event.target)) {
+      this.open.set(false);
+      this.resetMenu();
+    }
+  }
+}

+ 1 - 1
src/app/shared/components/data-table/data-table.component.html

@@ -59,7 +59,7 @@
           </tr>
         </thead>
         <tbody>
-          @for (row of pagedData; track row['id'] || $index) {
+          @for (row of displayData; track row['id'] ?? row['msgUniqueIdentifier'] ?? $index) {
             <tr
               class="border-b border-surface-50 dark:border-surface-200 hover:bg-surface-50 dark:hover:bg-surface-200 transition-colors group"
               [class.cursor-pointer]="rowClickable"

+ 5 - 0
src/app/shared/components/data-table/data-table.component.ts

@@ -59,6 +59,11 @@ export class DataTableComponent<T extends Record<string, any>> {
     return this.filteredData.slice(start, start + this.pageSize);
   }
 
+  /** 关闭分页时展示全部行,避免只显示前 10 条 */
+  get displayData(): T[] {
+    return this.showPagination ? this.pagedData : this.filteredData;
+  }
+
   get pages(): number[] {
     return Array.from({ length: this.totalPages }, (_, i) => i + 1);
   }

+ 19 - 16
src/app/shared/components/page-header/page-header.component.html

@@ -1,29 +1,32 @@
-<div class="flex items-center justify-between px-6 py-4 border-b border-surface-200 dark:border-surface-300 bg-white dark:bg-surface-100">
-  <div class="flex items-center gap-3">
+<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4 px-6 py-4 border-b border-surface-200 dark:border-surface-300 bg-white dark:bg-surface-100">
+  <div class="flex items-center gap-3 min-w-0">
     @if (icon) {
-      <div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-50/20 text-primary-600 dark:text-primary-400 flex items-center justify-center">
+      <div class="w-9 h-9 rounded-lg bg-primary-50 dark:bg-primary-50/20 text-primary-600 dark:text-primary-400 flex items-center justify-center shrink-0">
         <fa-icon [icon]="icon" size="lg" />
       </div>
     }
-    <div>
+    <div class="min-w-0">
       <h1 class="text-lg font-bold text-surface-900 dark:text-surface-900">{{ title }}</h1>
       @if (subtitle) {
-        <p class="text-sm text-surface-500 dark:text-surface-500 mt-0.5">{{ subtitle }}</p>
+        <p class="text-sm text-surface-500 dark:text-surface-500 mt-0.5 truncate">{{ subtitle }}</p>
       }
     </div>
   </div>
-  <div class="flex items-center gap-3">
+  <div class="justify-self-center">
+    <ng-content select="[headerCenter]" />
+  </div>
+  <div class="flex items-center gap-3 justify-self-end">
     <ng-content select="[headerActions]" />
     @if (showAction && actionLabel) {
-    <button
-      (click)="action.emit()"
-      class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
-    >
-      @if (actionIcon) {
-        <fa-icon [icon]="actionIcon" size="sm" />
-      }
-      {{ actionLabel }}
-    </button>
-  }
+      <button
+        (click)="action.emit()"
+        class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-medium hover:bg-primary-700 transition-colors"
+      >
+        @if (actionIcon) {
+          <fa-icon [icon]="actionIcon" size="sm" />
+        }
+        {{ actionLabel }}
+      </button>
+    }
   </div>
 </div>