| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- 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<string, { id: string; name: string }[]> = {};
- dashboardGroupsByDistrict: Record<string, DashboardScopeGroupOption[]> = {};
- 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<void> {
- 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<string, number>();
- 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<string, number>();
- 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<string, number>();
- 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<string, number> = { 'A': 5, 'B': 4, 'C': 3, 'D': 2, 'E': 1 };
- function healthGradeRank(grade: string | undefined): number {
- return GRADE_ORDER[grade ?? ''] ?? 0;
- }
|