import { Component, OnInit, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { faChartPie, faUsers, faBolt, faHeartPulse, faTriangleExclamation, faChartLine, faChartBar, faUserGroup, faMapLocationDot } from '@fortawesome/free-solid-svg-icons'; import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component'; import { StatCardComponent } from '../../shared/components/stat-card/stat-card.component'; import { ChartCardComponent } from '../../shared/components/chart-card/chart-card.component'; import { DataTableComponent } from '../../shared/components/data-table/data-table.component'; import { StoreScopePickerComponent, type StoreScopeSelection, } from '../../shared/components/store-scope-picker/store-scope-picker.component'; import { ScopeBreadcrumbComponent, type DashboardScopeSelection, type DashboardScopeGroupOption, } from '../../shared/components/scope-breadcrumb/scope-breadcrumb.component'; import { AuthStore } from '../../core/auth/auth.store'; import { ParseDataService, type ParseStore } from '../../core/services/api/parse-data.service'; import { isDirector, isRegionalSupervisor } from '../../core/models/role.constants'; import type { Group, RiskEvent } from '../../core/models'; import type { ChartData } from 'chart.js'; import { buildDefaultStoreScope, buildSupervisorDefaultScope, buildLockedStoreScope, dashboardScopeToQuery, } from './dashboard-scope.util'; @Component({ selector: 'app-dashboard', standalone: true, imports: [ FaIconComponent, PageHeaderComponent, StoreScopePickerComponent, ScopeBreadcrumbComponent, StatCardComponent, ChartCardComponent, DataTableComponent, ], templateUrl: './dashboard.component.html', }) export class DashboardComponent implements OnInit { private readonly router = inject(Router); private readonly authStore = inject(AuthStore); private readonly parseData = inject(ParseDataService); protected readonly faChartPie = faChartPie; protected readonly faUsers = faUsers; protected readonly faUserGroup = faUserGroup; protected readonly faBolt = faBolt; protected readonly faHeartPulse = faHeartPulse; protected readonly faTriangleExclamation = faTriangleExclamation; protected readonly faChartLine = faChartLine; protected readonly faChartBar = faChartBar; protected readonly faMapLocationDot = faMapLocationDot; loading = true; showDashboardScopePicker = false; showStoreScopePicker = false; showGlobalScopeOption = true; showRegionScopeOption = false; regionScopeLabel = '区域汇总(一级)'; scopeSubtitle = ''; storeScopeOptions: Array<{ id: string; name: string; region?: string }> = []; storeScopeSelection: StoreScopeSelection = { level: 'store', label: '加载中…' }; dashboardDistricts: string[] = []; dashboardStoresByDistrict: Record = {}; dashboardGroupsByDistrict: Record = {}; dashboardScopeSelection: DashboardScopeSelection = { level: 'city', label: '全上海' }; stats = { totalGroups: 0, totalMembers: 0, activeGroupRate: 0, avgHealthScore: 0, riskEventCount: 0, }; activityChartData: ChartData<'doughnut'> = { labels: [], datasets: [] }; lifecycleChartData: ChartData<'bar'> = { labels: [], datasets: [] }; storeChartData: ChartData<'bar'> = { labels: [], datasets: [] }; recentRiskEvents: RiskEvent[] = []; topGroups: Group[] = []; readonly riskEventColumns = [ { key: 'title', label: '事件标题', sortable: true }, { key: 'groupName', label: '所属群' }, { key: 'severity', label: '严重等级', template: 'status' as const }, { key: 'status', label: '状态', template: 'status' as const }, { key: 'discoveredAt', label: '发现时间' }, { key: 'actions', label: '操作', template: 'action' as const, width: '120px' } ]; readonly topGroupColumns = [ { key: 'name', label: '群名称', sortable: true }, { key: 'communityName', label: '所属小区' }, { key: 'healthGrade', label: '评级', template: 'status' as const }, { key: 'messageCountToday', label: '今日消息数', sortable: true }, { key: 'actions', label: '操作', template: 'action' as const, width: '120px' } ]; protected readonly dashboardSubtitle = () => `数据来自 Parse · ${this.scopeSubtitle}`; ngOnInit(): void { const user = this.authStore.user(); this.showDashboardScopePicker = isDirector(user?.role); this.showStoreScopePicker = isRegionalSupervisor(user?.role); this.showGlobalScopeOption = false; this.showRegionScopeOption = isRegionalSupervisor(user?.role); const districtLabel = user?.districtName || user?.regionCode || ''; this.regionScopeLabel = districtLabel ? `${districtLabel}汇总(一级)` : '区域汇总(一级)'; if (this.showDashboardScopePicker) { this.dashboardScopeSelection = { level: 'city', label: '全上海' }; } else if (this.showStoreScopePicker) { this.storeScopeSelection = { level: 'store', label: '加载门店…' }; } else { this.storeScopeSelection = buildLockedStoreScope(user); this.scopeSubtitle = this.storeScopeSelection.label; } void this.loadData(); } protected onStoreScopeChange(scope: StoreScopeSelection): void { this.storeScopeSelection = scope; void this.loadData(); } protected onDashboardScopeChange(scope: DashboardScopeSelection): void { this.dashboardScopeSelection = scope; void this.loadData(); } private async loadData(): Promise { this.loading = true; const user = this.authStore.user(); const data = await this.parseData.getDashboardOverview(); // 缓存原始数据 this.dashboardDistricts = data.districts; this.dashboardStoresByDistrict = data.storesByDistrict; this.dashboardGroupsByDistrict = data.groupsByDistrict; // 根据 scope 过滤 groups 并重算 stats/charts const filteredGroups = this.filterGroupsByScope(data.allGroups, this.dashboardScopeSelection); this.recomputeFromGroups(filteredGroups); // 门店选择器数据 if (this.showStoreScopePicker && data.stores?.length) { const supervisorId = user?.districtId || ''; const accessible = isRegionalSupervisor(user?.role) ? data.stores.filter((s) => s.districtId === user!.districtId) : data.stores; this.storeScopeOptions = accessible.map((s) => ({ id: s.id, name: s.name, region: s.districtName })); if (this.storeScopeSelection.level !== 'global' && this.storeScopeSelection.level !== 'region' && !this.storeScopeSelection.storeId) { this.storeScopeSelection = isRegionalSupervisor(user?.role) ? buildSupervisorDefaultScope(user!, accessible.map((s) => ({ id: s.id, name: s.name, districtId: s.districtId, districtName: s.districtName } as ParseStore))) : buildDefaultStoreScope(accessible.map((s) => ({ id: s.id, name: s.name, districtId: s.districtId, districtName: s.districtName } as ParseStore))); } } this.scopeSubtitle = this.showDashboardScopePicker ? this.dashboardScopeSelection.label : this.storeScopeSelection.label; // 查询 RiskEvent,按当前 dashboard scope 过滤 try { const scoped = await this.parseData.listRiskEvents(); let filtered = scoped; if (this.dashboardScopeSelection.level === 'district') { const storeIds = (this.dashboardStoresByDistrict[this.dashboardScopeSelection.district!] || []).map(s => s.id); const idSet = new Set(storeIds); filtered = scoped.filter(e => idSet.has(e.store?.id || '')); } else if (this.dashboardScopeSelection.level === 'store') { filtered = scoped.filter(e => e.store?.id === this.dashboardScopeSelection.storeId); } this.recentRiskEvents = filtered .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 5); const pendingCount = filtered.filter(e => e.status === 'pending').length; this.stats = { ...this.stats, riskEventCount: pendingCount }; } catch { this.recentRiskEvents = []; this.stats = { ...this.stats, riskEventCount: 0 }; } this.loading = false; } protected onRiskEventClick(event: RiskEvent): void { this.router.navigate(['/risk-control'], { queryParams: { eventId: event.id } }); } protected onGroupClick(event: Group): void { this.router.navigate(['/groups', event.id]); } protected navigateTo(path: string): void { this.router.navigate([path]); } /** 根据 scope 在前端过滤 groups */ private filterGroupsByScope(allGroups: Group[], scope: DashboardScopeSelection): Group[] { switch (scope.level) { case 'city': return allGroups; case 'district': { const storeIds = (this.dashboardStoresByDistrict[scope.district!] || []).map(s => s.id); const idSet = new Set(storeIds); return allGroups.filter(g => idSet.has(g.storeId)); } case 'store': return allGroups.filter(g => g.storeId === scope.storeId); case 'group': return allGroups.filter(g => g.id === scope.roomId); default: return allGroups; } } /** 从 groups 列表重新计算 stats + charts + tables */ private recomputeFromGroups(groups: Group[]): void { const totalGroups = groups.length; const totalMembers = groups.reduce((sum, g) => sum + g.memberCount, 0); const activeCount = groups.filter(g => g.activityLevel === 'high' || g.activityLevel === 'medium').length; const avgHealth = totalGroups > 0 ? Math.round(groups.reduce((sum, g) => sum + g.healthScore, 0) / totalGroups) : 0; this.stats = { ...this.stats, totalGroups, totalMembers, activeGroupRate: totalGroups > 0 ? Math.round((activeCount / totalGroups) * 100) : 0, avgHealthScore: avgHealth, }; /* activity chart */ const high = groups.filter(g => g.activityLevel === 'high').length; const medium = groups.filter(g => g.activityLevel === 'medium').length; const low = groups.filter(g => g.activityLevel === 'low').length; this.activityChartData = { labels: ['高活跃', '中活跃', '低活跃'], datasets: [{ data: [high, medium, low], backgroundColor: ['#188918', '#0070F2', '#E76500'], borderWidth: 0 }], }; /* lifecycle chart */ const lifecycleMap = new Map(); for (const p of ['pre_handover', 'near_handover', 'post_handover', 'legacy']) { lifecycleMap.set(p, groups.filter(g => g.lifecyclePhase === p).length); } this.lifecycleChartData = { labels: ['交房前', '临交房', '已交房', '老小区'], datasets: [{ label: '客户群数', data: Array.from(lifecycleMap.values()), backgroundColor: ['#0070F2', '#E76500', '#188918', '#8D8D90'], borderRadius: 4 }], }; /* top groups */ this.topGroups = [...groups].sort((a, b) => healthGradeRank(b.healthGrade) - healthGradeRank(a.healthGrade)).slice(0, 5); /* store comparison chart — depends on level */ if (this.dashboardScopeSelection.level === 'city') { const storeMap = new Map(); groups.forEach(g => { if (g.storeName && g.storeName !== '—') storeMap.set(g.storeName, (storeMap.get(g.storeName) || 0) + 1); }); this.storeChartData = { labels: Array.from(storeMap.keys()), datasets: [{ label: '客户群数量', data: Array.from(storeMap.values()), backgroundColor: ['#0070F2', '#188918', '#E76500', '#8D8D90', '#5B738B'], borderRadius: 4 }], }; } else if (this.dashboardScopeSelection.level === 'district') { const district = this.dashboardScopeSelection.district!; const stores = this.dashboardStoresByDistrict[district] || []; const storeMap = new Map(); const storeIds = new Set(stores.map(s => s.id)); groups.forEach(g => { if (storeIds.has(g.storeId)) storeMap.set(g.storeName || g.storeId, (storeMap.get(g.storeName || g.storeId) || 0) + 1); }); this.storeChartData = { labels: Array.from(storeMap.keys()), datasets: [{ label: '客户群数量', data: Array.from(storeMap.values()), backgroundColor: ['#0070F2', '#188918', '#E76500', '#8D8D90', '#5B738B'], borderRadius: 4 }], }; } else { // store / group 级别:只展示当前范围内的群分布 const label = this.dashboardScopeSelection.storeName || this.dashboardScopeSelection.label; this.storeChartData = { labels: [label], datasets: [{ label: '客户群数量', data: [groups.length], backgroundColor: ['#0070F2'], borderRadius: 4 }], }; } } } const GRADE_ORDER: Record = { 'A': 5, 'B': 4, 'C': 3, 'D': 2, 'E': 1 }; function healthGradeRank(grade: string | undefined): number { return GRADE_ORDER[grade ?? ''] ?? 0; }