dashboard.component.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { Component, OnInit, inject, signal } from '@angular/core';
  2. import { Router } from '@angular/router';
  3. import { FaIconComponent } from '@fortawesome/angular-fontawesome';
  4. import {
  5. faChartPie, faUsers, faBolt, faHeartPulse,
  6. faTriangleExclamation, faChartLine, faChartBar, faUserGroup, faMapLocationDot
  7. } from '@fortawesome/free-solid-svg-icons';
  8. import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
  9. import { StatCardComponent } from '../../shared/components/stat-card/stat-card.component';
  10. import { ChartCardComponent } from '../../shared/components/chart-card/chart-card.component';
  11. import { DataTableComponent } from '../../shared/components/data-table/data-table.component';
  12. import {
  13. StoreScopePickerComponent,
  14. type StoreScopeSelection,
  15. } from '../../shared/components/store-scope-picker/store-scope-picker.component';
  16. import {
  17. ScopeBreadcrumbComponent,
  18. type DashboardScopeSelection,
  19. type DashboardScopeGroupOption,
  20. } from '../../shared/components/scope-breadcrumb/scope-breadcrumb.component';
  21. import { AuthStore } from '../../core/auth/auth.store';
  22. import { ParseDataService, type ParseStore } from '../../core/services/api/parse-data.service';
  23. import { isDirector, isRegionalSupervisor } from '../../core/models/role.constants';
  24. import type { Group, RiskEvent } from '../../core/models';
  25. import type { ChartData } from 'chart.js';
  26. import {
  27. buildDefaultStoreScope,
  28. buildSupervisorDefaultScope,
  29. buildLockedStoreScope,
  30. dashboardScopeToQuery,
  31. } from './dashboard-scope.util';
  32. @Component({
  33. selector: 'app-dashboard',
  34. standalone: true,
  35. imports: [
  36. FaIconComponent,
  37. PageHeaderComponent,
  38. StoreScopePickerComponent,
  39. ScopeBreadcrumbComponent,
  40. StatCardComponent,
  41. ChartCardComponent,
  42. DataTableComponent,
  43. ],
  44. templateUrl: './dashboard.component.html',
  45. })
  46. export class DashboardComponent implements OnInit {
  47. private readonly router = inject(Router);
  48. private readonly authStore = inject(AuthStore);
  49. private readonly parseData = inject(ParseDataService);
  50. protected readonly faChartPie = faChartPie;
  51. protected readonly faUsers = faUsers;
  52. protected readonly faUserGroup = faUserGroup;
  53. protected readonly faBolt = faBolt;
  54. protected readonly faHeartPulse = faHeartPulse;
  55. protected readonly faTriangleExclamation = faTriangleExclamation;
  56. protected readonly faChartLine = faChartLine;
  57. protected readonly faChartBar = faChartBar;
  58. protected readonly faMapLocationDot = faMapLocationDot;
  59. loading = true;
  60. showDashboardScopePicker = false;
  61. showStoreScopePicker = false;
  62. showGlobalScopeOption = true;
  63. showRegionScopeOption = false;
  64. regionScopeLabel = '区域汇总(一级)';
  65. scopeSubtitle = '';
  66. storeScopeOptions: Array<{ id: string; name: string; region?: string }> = [];
  67. storeScopeSelection: StoreScopeSelection = { level: 'store', label: '加载中…' };
  68. dashboardDistricts: string[] = [];
  69. dashboardStoresByDistrict: Record<string, { id: string; name: string }[]> = {};
  70. dashboardGroupsByDistrict: Record<string, DashboardScopeGroupOption[]> = {};
  71. dashboardScopeSelection: DashboardScopeSelection = { level: 'city', label: '全上海' };
  72. stats = {
  73. totalGroups: 0,
  74. totalMembers: 0,
  75. activeGroupRate: 0,
  76. avgHealthScore: 0,
  77. riskEventCount: 0,
  78. };
  79. activityChartData: ChartData<'doughnut'> = { labels: [], datasets: [] };
  80. lifecycleChartData: ChartData<'bar'> = { labels: [], datasets: [] };
  81. storeChartData: ChartData<'bar'> = { labels: [], datasets: [] };
  82. recentRiskEvents: RiskEvent[] = [];
  83. topGroups: Group[] = [];
  84. readonly riskEventColumns = [
  85. { key: 'title', label: '事件标题', sortable: true },
  86. { key: 'groupName', label: '所属群' },
  87. { key: 'severity', label: '严重等级', template: 'status' as const },
  88. { key: 'status', label: '状态', template: 'status' as const },
  89. { key: 'discoveredAt', label: '发现时间' },
  90. { key: 'actions', label: '操作', template: 'action' as const, width: '120px' }
  91. ];
  92. readonly topGroupColumns = [
  93. { key: 'name', label: '群名称', sortable: true },
  94. { key: 'communityName', label: '所属小区' },
  95. { key: 'healthGrade', label: '评级', template: 'status' as const },
  96. { key: 'messageCountToday', label: '今日消息数', sortable: true },
  97. { key: 'actions', label: '操作', template: 'action' as const, width: '120px' }
  98. ];
  99. protected readonly dashboardSubtitle = () => `数据来自 Parse · ${this.scopeSubtitle}`;
  100. ngOnInit(): void {
  101. const user = this.authStore.user();
  102. this.showDashboardScopePicker = isDirector(user?.role);
  103. this.showStoreScopePicker = isRegionalSupervisor(user?.role);
  104. this.showGlobalScopeOption = false;
  105. this.showRegionScopeOption = isRegionalSupervisor(user?.role);
  106. const districtLabel = user?.districtName || user?.regionCode || '';
  107. this.regionScopeLabel = districtLabel ? `${districtLabel}汇总(一级)` : '区域汇总(一级)';
  108. if (this.showDashboardScopePicker) {
  109. this.dashboardScopeSelection = { level: 'city', label: '全上海' };
  110. } else if (this.showStoreScopePicker) {
  111. this.storeScopeSelection = { level: 'store', label: '加载门店…' };
  112. } else {
  113. this.storeScopeSelection = buildLockedStoreScope(user);
  114. this.scopeSubtitle = this.storeScopeSelection.label;
  115. }
  116. void this.loadData();
  117. }
  118. protected onStoreScopeChange(scope: StoreScopeSelection): void {
  119. this.storeScopeSelection = scope;
  120. void this.loadData();
  121. }
  122. protected onDashboardScopeChange(scope: DashboardScopeSelection): void {
  123. this.dashboardScopeSelection = scope;
  124. void this.loadData();
  125. }
  126. private async loadData(): Promise<void> {
  127. this.loading = true;
  128. const user = this.authStore.user();
  129. const data = await this.parseData.getDashboardOverview();
  130. // 缓存原始数据
  131. this.dashboardDistricts = data.districts;
  132. this.dashboardStoresByDistrict = data.storesByDistrict;
  133. this.dashboardGroupsByDistrict = data.groupsByDistrict;
  134. // 根据 scope 过滤 groups 并重算 stats/charts
  135. const filteredGroups = this.filterGroupsByScope(data.allGroups, this.dashboardScopeSelection);
  136. this.recomputeFromGroups(filteredGroups);
  137. // 门店选择器数据
  138. if (this.showStoreScopePicker && data.stores?.length) {
  139. const supervisorId = user?.districtId || '';
  140. const accessible = isRegionalSupervisor(user?.role)
  141. ? data.stores.filter((s) => s.districtId === user!.districtId)
  142. : data.stores;
  143. this.storeScopeOptions = accessible.map((s) => ({ id: s.id, name: s.name, region: s.districtName }));
  144. if (this.storeScopeSelection.level !== 'global'
  145. && this.storeScopeSelection.level !== 'region'
  146. && !this.storeScopeSelection.storeId) {
  147. this.storeScopeSelection = isRegionalSupervisor(user?.role)
  148. ? buildSupervisorDefaultScope(user!, accessible.map((s) => ({ id: s.id, name: s.name, districtId: s.districtId, districtName: s.districtName } as ParseStore)))
  149. : buildDefaultStoreScope(accessible.map((s) => ({ id: s.id, name: s.name, districtId: s.districtId, districtName: s.districtName } as ParseStore)));
  150. }
  151. }
  152. this.scopeSubtitle = this.showDashboardScopePicker
  153. ? this.dashboardScopeSelection.label
  154. : this.storeScopeSelection.label;
  155. // 查询 RiskEvent,按当前 dashboard scope 过滤
  156. try {
  157. const scoped = await this.parseData.listRiskEvents();
  158. let filtered = scoped;
  159. if (this.dashboardScopeSelection.level === 'district') {
  160. const storeIds = (this.dashboardStoresByDistrict[this.dashboardScopeSelection.district!] || []).map(s => s.id);
  161. const idSet = new Set(storeIds);
  162. filtered = scoped.filter(e => idSet.has(e.store?.id || ''));
  163. } else if (this.dashboardScopeSelection.level === 'store') {
  164. filtered = scoped.filter(e => e.store?.id === this.dashboardScopeSelection.storeId);
  165. }
  166. this.recentRiskEvents = filtered
  167. .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
  168. .slice(0, 5);
  169. const pendingCount = filtered.filter(e => e.status === 'pending').length;
  170. this.stats = { ...this.stats, riskEventCount: pendingCount };
  171. } catch {
  172. this.recentRiskEvents = [];
  173. this.stats = { ...this.stats, riskEventCount: 0 };
  174. }
  175. this.loading = false;
  176. }
  177. protected onRiskEventClick(event: RiskEvent): void {
  178. this.router.navigate(['/risk-control'], { queryParams: { eventId: event.id } });
  179. }
  180. protected onGroupClick(event: Group): void {
  181. this.router.navigate(['/groups', event.id]);
  182. }
  183. protected navigateTo(path: string): void {
  184. this.router.navigate([path]);
  185. }
  186. /** 根据 scope 在前端过滤 groups */
  187. private filterGroupsByScope(allGroups: Group[], scope: DashboardScopeSelection): Group[] {
  188. switch (scope.level) {
  189. case 'city':
  190. return allGroups;
  191. case 'district': {
  192. const storeIds = (this.dashboardStoresByDistrict[scope.district!] || []).map(s => s.id);
  193. const idSet = new Set(storeIds);
  194. return allGroups.filter(g => idSet.has(g.storeId));
  195. }
  196. case 'store':
  197. return allGroups.filter(g => g.storeId === scope.storeId);
  198. case 'group':
  199. return allGroups.filter(g => g.id === scope.roomId);
  200. default:
  201. return allGroups;
  202. }
  203. }
  204. /** 从 groups 列表重新计算 stats + charts + tables */
  205. private recomputeFromGroups(groups: Group[]): void {
  206. const totalGroups = groups.length;
  207. const totalMembers = groups.reduce((sum, g) => sum + g.memberCount, 0);
  208. const activeCount = groups.filter(g => g.activityLevel === 'high' || g.activityLevel === 'medium').length;
  209. const avgHealth = totalGroups > 0
  210. ? Math.round(groups.reduce((sum, g) => sum + g.healthScore, 0) / totalGroups)
  211. : 0;
  212. this.stats = {
  213. ...this.stats,
  214. totalGroups,
  215. totalMembers,
  216. activeGroupRate: totalGroups > 0 ? Math.round((activeCount / totalGroups) * 100) : 0,
  217. avgHealthScore: avgHealth,
  218. };
  219. /* activity chart */
  220. const high = groups.filter(g => g.activityLevel === 'high').length;
  221. const medium = groups.filter(g => g.activityLevel === 'medium').length;
  222. const low = groups.filter(g => g.activityLevel === 'low').length;
  223. this.activityChartData = {
  224. labels: ['高活跃', '中活跃', '低活跃'],
  225. datasets: [{ data: [high, medium, low], backgroundColor: ['#188918', '#0070F2', '#E76500'], borderWidth: 0 }],
  226. };
  227. /* lifecycle chart */
  228. const lifecycleMap = new Map<string, number>();
  229. for (const p of ['pre_handover', 'near_handover', 'post_handover', 'legacy']) {
  230. lifecycleMap.set(p, groups.filter(g => g.lifecyclePhase === p).length);
  231. }
  232. this.lifecycleChartData = {
  233. labels: ['交房前', '临交房', '已交房', '老小区'],
  234. datasets: [{ label: '客户群数', data: Array.from(lifecycleMap.values()), backgroundColor: ['#0070F2', '#E76500', '#188918', '#8D8D90'], borderRadius: 4 }],
  235. };
  236. /* top groups */
  237. this.topGroups = [...groups].sort((a, b) => healthGradeRank(b.healthGrade) - healthGradeRank(a.healthGrade)).slice(0, 5);
  238. /* store comparison chart — depends on level */
  239. if (this.dashboardScopeSelection.level === 'city') {
  240. const storeMap = new Map<string, number>();
  241. groups.forEach(g => { if (g.storeName && g.storeName !== '—') storeMap.set(g.storeName, (storeMap.get(g.storeName) || 0) + 1); });
  242. this.storeChartData = {
  243. labels: Array.from(storeMap.keys()),
  244. datasets: [{ label: '客户群数量', data: Array.from(storeMap.values()), backgroundColor: ['#0070F2', '#188918', '#E76500', '#8D8D90', '#5B738B'], borderRadius: 4 }],
  245. };
  246. } else if (this.dashboardScopeSelection.level === 'district') {
  247. const district = this.dashboardScopeSelection.district!;
  248. const stores = this.dashboardStoresByDistrict[district] || [];
  249. const storeMap = new Map<string, number>();
  250. const storeIds = new Set(stores.map(s => s.id));
  251. groups.forEach(g => { if (storeIds.has(g.storeId)) storeMap.set(g.storeName || g.storeId, (storeMap.get(g.storeName || g.storeId) || 0) + 1); });
  252. this.storeChartData = {
  253. labels: Array.from(storeMap.keys()),
  254. datasets: [{ label: '客户群数量', data: Array.from(storeMap.values()), backgroundColor: ['#0070F2', '#188918', '#E76500', '#8D8D90', '#5B738B'], borderRadius: 4 }],
  255. };
  256. } else {
  257. // store / group 级别:只展示当前范围内的群分布
  258. const label = this.dashboardScopeSelection.storeName || this.dashboardScopeSelection.label;
  259. this.storeChartData = {
  260. labels: [label],
  261. datasets: [{ label: '客户群数量', data: [groups.length], backgroundColor: ['#0070F2'], borderRadius: 4 }],
  262. };
  263. }
  264. }
  265. }
  266. const GRADE_ORDER: Record<string, number> = { 'A': 5, 'B': 4, 'C': 3, 'D': 2, 'E': 1 };
  267. function healthGradeRank(grade: string | undefined): number {
  268. return GRADE_ORDER[grade ?? ''] ?? 0;
  269. }