diff --git a/.gitignore b/.gitignore index 318c802e..280d3c56 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,13 @@ backend/.idea/ frontend/dist/ +frontend1/dist/ backend/target/ frontend/node_modules/ +frontend1/node_modules/ frontend/.cert/ +frontend1/.cert/ *.log diff --git a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java index 2b46c777..e57bc50d 100644 --- a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java +++ b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java @@ -3,6 +3,7 @@ package com.unis.crm; import com.unis.crm.config.WecomProperties; import com.unis.crm.config.InternalAuthProperties; import com.unis.crm.config.OmsProperties; +import java.util.TimeZone; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -16,6 +17,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; public class UnisCrmBackendApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); SpringApplication.run(UnisCrmBackendApplication.class, args); } } diff --git a/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java new file mode 100644 index 00000000..fb4c13d5 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java @@ -0,0 +1,105 @@ +package com.unis.crm.common; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class DashboardAnalyticsSchemaInitializer implements ApplicationRunner { + + private final DataSource dataSource; + + public DashboardAnalyticsSchemaInitializer(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public void run(ApplicationArguments args) { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + create table if not exists dashboard_analytics_panel_config ( + id bigint generated by default as identity primary key, + tenant_id bigint not null, + enabled boolean not null default false, + title varchar(100) not null default '经营分析', + subtitle varchar(255), + empty_state_text varchar(255), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uk_dashboard_analytics_panel_tenant unique (tenant_id) + ) + """); + statement.execute(""" + create table if not exists dashboard_analytics_card_config ( + id bigint generated by default as identity primary key, + tenant_id bigint not null, + card_key varchar(100) not null, + title varchar(100) not null, + subtitle varchar(255), + render_type varchar(20) not null default 'metric', + sql_template text not null, + value_field varchar(100) not null default 'value', + description_field varchar(100), + category_field varchar(100), + color_field varchar(100), + display_text_config text, + unit varchar(20), + value_type varchar(20) not null default 'number', + sort_direction varchar(20) not null default 'sql', + display_limit integer, + layout_type varchar(20) not null default 'vertical', + full_row boolean not null default false, + link_path varchar(255), + sort_order integer not null default 0, + enabled boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uk_dashboard_analytics_card_tenant_key unique (tenant_id, card_key) + ) + """); + statement.execute(""" + create index if not exists idx_dashboard_analytics_card_tenant_sort + on dashboard_analytics_card_config (tenant_id, sort_order asc, id asc) + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists render_type varchar(20) not null default 'metric' + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists category_field varchar(100) + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists color_field varchar(100) + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists display_text_config text + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists sort_direction varchar(20) not null default 'sql' + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists display_limit integer + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists layout_type varchar(20) not null default 'vertical' + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists full_row boolean not null default false + """); + } catch (SQLException exception) { + throw new IllegalStateException("Failed to initialize dashboard analytics schema", exception); + } + } +} diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java index 118323fe..de7d05ec 100644 --- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -32,8 +32,12 @@ public class OpportunitySchemaInitializer implements ApplicationRunner { try (Statement statement = connection.createStatement()) { statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint"); statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)"); + statement.execute("alter table crm_opportunity add column if not exists latest_progress text"); + statement.execute("alter table crm_opportunity add column if not exists next_plan text"); statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz"); statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)"); + statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'"); + statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'"); statement.execute("comment on column crm_opportunity.archived_at is '归档时间'"); } ensureArchivedAtStorage(connection); diff --git a/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java b/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java new file mode 100644 index 00000000..b23df0eb --- /dev/null +++ b/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java @@ -0,0 +1,47 @@ +package com.unis.crm.controller; + +import com.unis.crm.common.ApiResponse; +import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO; +import com.unis.crm.dto.dashboard.DashboardAnalyticsPanelDTO; +import com.unis.crm.dto.dashboardanalytics.DashboardAnalyticsConfigDTO; +import com.unis.crm.service.DashboardAnalyticsConfigService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/sys/api/admin") +public class DashboardAnalyticsAdminController { + + private final DashboardAnalyticsConfigService dashboardAnalyticsConfigService; + + public DashboardAnalyticsAdminController(DashboardAnalyticsConfigService dashboardAnalyticsConfigService) { + this.dashboardAnalyticsConfigService = dashboardAnalyticsConfigService; + } + + @GetMapping("/dashboard-analytics-config") + public ApiResponse getConfig(@RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(dashboardAnalyticsConfigService.getConfig(tenantId)); + } + + @PutMapping("/dashboard-analytics-config") + public ApiResponse updateConfig(@Valid @RequestBody DashboardAnalyticsConfigDTO payload) { + return ApiResponse.success(dashboardAnalyticsConfigService.saveConfig(payload)); + } + + @GetMapping("/dashboard-analytics-config/preview") + public ApiResponse preview(@RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(dashboardAnalyticsConfigService.preview(tenantId)); + } + + @GetMapping("/dashboard-analytics-config/preview/card-detail") + public ApiResponse previewCardDetail( + @RequestParam(value = "tenantId", required = false) Long tenantId, + @RequestParam("cardKey") String cardKey) { + return ApiResponse.success(dashboardAnalyticsConfigService.previewCard(tenantId, cardKey)); + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/DashboardController.java b/backend/src/main/java/com/unis/crm/controller/DashboardController.java index ee0c2331..a83a257a 100644 --- a/backend/src/main/java/com/unis/crm/controller/DashboardController.java +++ b/backend/src/main/java/com/unis/crm/controller/DashboardController.java @@ -1,6 +1,7 @@ package com.unis.crm.controller; import com.unis.crm.common.ApiResponse; +import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO; import com.unis.crm.dto.dashboard.DashboardHomeDTO; import com.unis.crm.service.DashboardService; import com.unisbase.common.annotation.Log; @@ -38,4 +39,11 @@ public class DashboardController { dashboardService.completeTodo(userId, todoId); return ApiResponse.success(null); } + + @GetMapping("/analytics-cards/{cardKey}") + public ApiResponse getAnalyticsCardDetail( + @RequestHeader("X-User-Id") @Min(1) Long userId, + @PathVariable("cardKey") String cardKey) { + return ApiResponse.success(dashboardService.getAnalyticsCardDetail(userId, cardKey)); + } } diff --git a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java new file mode 100644 index 00000000..9333c809 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java @@ -0,0 +1,176 @@ +package com.unis.crm.dto.dashboard; + +public class DashboardAnalyticsCardDTO { + + private Long id; + private String cardKey; + private String title; + private String subtitle; + private String renderType; + private String description; + private String value; + private String valueText; + private String valueType; + private String unit; + private String displayTextConfig; + private String linkPath; + private String layoutType; + private Boolean fullRow; + private Integer sortOrder; + private String errorMessage; + private Integer totalCount; + private Boolean hasMore; + private java.util.List chartData; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCardKey() { + return cardKey; + } + + public void setCardKey(String cardKey) { + this.cardKey = cardKey; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSubtitle() { + return subtitle; + } + + public void setSubtitle(String subtitle) { + this.subtitle = subtitle; + } + + public String getRenderType() { + return renderType; + } + + public void setRenderType(String renderType) { + this.renderType = renderType; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getValueText() { + return valueText; + } + + public void setValueText(String valueText) { + this.valueText = valueText; + } + + public String getValueType() { + return valueType; + } + + public void setValueType(String valueType) { + this.valueType = valueType; + } + + public String getUnit() { + return unit; + } + + public void setUnit(String unit) { + this.unit = unit; + } + + public String getDisplayTextConfig() { + return displayTextConfig; + } + + public void setDisplayTextConfig(String displayTextConfig) { + this.displayTextConfig = displayTextConfig; + } + + public String getLinkPath() { + return linkPath; + } + + public void setLinkPath(String linkPath) { + this.linkPath = linkPath; + } + + public String getLayoutType() { + return layoutType; + } + + public void setLayoutType(String layoutType) { + this.layoutType = layoutType; + } + + public Boolean getFullRow() { + return fullRow; + } + + public void setFullRow(Boolean fullRow) { + this.fullRow = fullRow; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Integer getTotalCount() { + return totalCount; + } + + public void setTotalCount(Integer totalCount) { + this.totalCount = totalCount; + } + + public Boolean getHasMore() { + return hasMore; + } + + public void setHasMore(Boolean hasMore) { + this.hasMore = hasMore; + } + + public java.util.List getChartData() { + return chartData; + } + + public void setChartData(java.util.List chartData) { + this.chartData = chartData; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsChartPointDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsChartPointDTO.java new file mode 100644 index 00000000..a36c8b00 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsChartPointDTO.java @@ -0,0 +1,68 @@ +package com.unis.crm.dto.dashboard; + +public class DashboardAnalyticsChartPointDTO { + + private String label; + private String value; + private String valueText; + private String secondaryValue; + private String secondaryValueText; + private String description; + private String color; + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getValueText() { + return valueText; + } + + public void setValueText(String valueText) { + this.valueText = valueText; + } + + public String getSecondaryValue() { + return secondaryValue; + } + + public void setSecondaryValue(String secondaryValue) { + this.secondaryValue = secondaryValue; + } + + public String getSecondaryValueText() { + return secondaryValueText; + } + + public void setSecondaryValueText(String secondaryValueText) { + this.secondaryValueText = secondaryValueText; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsPanelDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsPanelDTO.java new file mode 100644 index 00000000..716495b8 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsPanelDTO.java @@ -0,0 +1,52 @@ +package com.unis.crm.dto.dashboard; + +import java.util.List; + +public class DashboardAnalyticsPanelDTO { + + private Boolean enabled; + private String title; + private String subtitle; + private String emptyStateText; + private List cards; + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSubtitle() { + return subtitle; + } + + public void setSubtitle(String subtitle) { + this.subtitle = subtitle; + } + + public String getEmptyStateText() { + return emptyStateText; + } + + public void setEmptyStateText(String emptyStateText) { + this.emptyStateText = emptyStateText; + } + + public List getCards() { + return cards; + } + + public void setCards(List cards) { + this.cards = cards; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java index 53f19651..10a8670a 100644 --- a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java @@ -9,29 +9,36 @@ public class DashboardHomeDTO { private String jobTitle; private String deptName; private Long onboardingDays; + private Boolean statsCardVisible; private Boolean todoCardVisible; private Boolean activityCardVisible; + private Boolean analyticsCardVisible; private List stats; private List todos; private List activities; + private DashboardAnalyticsPanelDTO analyticsPanel; public DashboardHomeDTO() { } public DashboardHomeDTO(Long userId, String realName, String jobTitle, String deptName, Long onboardingDays, - Boolean todoCardVisible, Boolean activityCardVisible, + Boolean statsCardVisible, Boolean todoCardVisible, Boolean activityCardVisible, + Boolean analyticsCardVisible, List stats, List todos, - List activities) { + List activities, DashboardAnalyticsPanelDTO analyticsPanel) { this.userId = userId; this.realName = realName; this.jobTitle = jobTitle; this.deptName = deptName; this.onboardingDays = onboardingDays; + this.statsCardVisible = statsCardVisible; this.todoCardVisible = todoCardVisible; this.activityCardVisible = activityCardVisible; + this.analyticsCardVisible = analyticsCardVisible; this.stats = stats; this.todos = todos; this.activities = activities; + this.analyticsPanel = analyticsPanel; } public Long getUserId() { @@ -74,6 +81,14 @@ public class DashboardHomeDTO { this.onboardingDays = onboardingDays; } + public Boolean getStatsCardVisible() { + return statsCardVisible; + } + + public void setStatsCardVisible(Boolean statsCardVisible) { + this.statsCardVisible = statsCardVisible; + } + public Boolean getTodoCardVisible() { return todoCardVisible; } @@ -90,6 +105,14 @@ public class DashboardHomeDTO { this.activityCardVisible = activityCardVisible; } + public Boolean getAnalyticsCardVisible() { + return analyticsCardVisible; + } + + public void setAnalyticsCardVisible(Boolean analyticsCardVisible) { + this.analyticsCardVisible = analyticsCardVisible; + } + public List getStats() { return stats; } @@ -113,4 +136,12 @@ public class DashboardHomeDTO { public void setActivities(List activities) { this.activities = activities; } + + public DashboardAnalyticsPanelDTO getAnalyticsPanel() { + return analyticsPanel; + } + + public void setAnalyticsPanel(DashboardAnalyticsPanelDTO analyticsPanel) { + this.analyticsPanel = analyticsPanel; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsCardConfigDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsCardConfigDTO.java new file mode 100644 index 00000000..a29499f4 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsCardConfigDTO.java @@ -0,0 +1,229 @@ +package com.unis.crm.dto.dashboardanalytics; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class DashboardAnalyticsCardConfigDTO { + + private Long id; + + @NotBlank(message = "卡片编码不能为空") + @Size(max = 100, message = "卡片编码长度不能超过100") + private String cardKey; + + @NotBlank(message = "卡片标题不能为空") + @Size(max = 100, message = "卡片标题长度不能超过100") + private String title; + + @Size(max = 255, message = "卡片副标题长度不能超过255") + private String subtitle; + + @NotBlank(message = "展示类型不能为空") + @Size(max = 20, message = "展示类型长度不能超过20") + private String renderType; + + @NotBlank(message = "查询 SQL 不能为空") + private String sqlTemplate; + + @NotBlank(message = "数值字段不能为空") + @Size(max = 100, message = "数值字段长度不能超过100") + private String valueField; + + @Size(max = 100, message = "说明字段长度不能超过100") + private String descriptionField; + + @Size(max = 100, message = "分类字段长度不能超过100") + private String categoryField; + + @Size(max = 100, message = "颜色字段长度不能超过100") + private String colorField; + + private String displayTextConfig; + + @Size(max = 20, message = "单位长度不能超过20") + private String unit; + + @NotBlank(message = "展示类型不能为空") + @Size(max = 20, message = "展示类型长度不能超过20") + private String valueType; + + @Size(max = 20, message = "数据排序方式长度不能超过20") + private String sortDirection; + + private Integer displayLimit; + + @Size(max = 20, message = "布局方式长度不能超过20") + private String layoutType; + + private Boolean fullRow; + + @Size(max = 255, message = "跳转路径长度不能超过255") + private String linkPath; + + @NotNull(message = "排序不能为空") + private Integer sortOrder; + + @NotNull(message = "启用状态不能为空") + private Boolean enabled; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getCardKey() { + return cardKey; + } + + public void setCardKey(String cardKey) { + this.cardKey = cardKey; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSubtitle() { + return subtitle; + } + + public void setSubtitle(String subtitle) { + this.subtitle = subtitle; + } + + public String getRenderType() { + return renderType; + } + + public void setRenderType(String renderType) { + this.renderType = renderType; + } + + public String getSqlTemplate() { + return sqlTemplate; + } + + public void setSqlTemplate(String sqlTemplate) { + this.sqlTemplate = sqlTemplate; + } + + public String getValueField() { + return valueField; + } + + public void setValueField(String valueField) { + this.valueField = valueField; + } + + public String getDescriptionField() { + return descriptionField; + } + + public void setDescriptionField(String descriptionField) { + this.descriptionField = descriptionField; + } + + public String getCategoryField() { + return categoryField; + } + + public void setCategoryField(String categoryField) { + this.categoryField = categoryField; + } + + public String getColorField() { + return colorField; + } + + public void setColorField(String colorField) { + this.colorField = colorField; + } + + public String getDisplayTextConfig() { + return displayTextConfig; + } + + public void setDisplayTextConfig(String displayTextConfig) { + this.displayTextConfig = displayTextConfig; + } + + public String getUnit() { + return unit; + } + + public void setUnit(String unit) { + this.unit = unit; + } + + public String getValueType() { + return valueType; + } + + public void setValueType(String valueType) { + this.valueType = valueType; + } + + public String getLinkPath() { + return linkPath; + } + + public String getLayoutType() { + return layoutType; + } + + public void setLayoutType(String layoutType) { + this.layoutType = layoutType; + } + + public Boolean getFullRow() { + return fullRow; + } + + public void setFullRow(Boolean fullRow) { + this.fullRow = fullRow; + } + + public Integer getDisplayLimit() { + return displayLimit; + } + + public void setDisplayLimit(Integer displayLimit) { + this.displayLimit = displayLimit; + } + + public String getSortDirection() { + return sortDirection; + } + + public void setSortDirection(String sortDirection) { + this.sortDirection = sortDirection; + } + + public void setLinkPath(String linkPath) { + this.linkPath = linkPath; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsConfigDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsConfigDTO.java new file mode 100644 index 00000000..a4fc7f8a --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsConfigDTO.java @@ -0,0 +1,75 @@ +package com.unis.crm.dto.dashboardanalytics; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; + +public class DashboardAnalyticsConfigDTO { + + private Long tenantId; + + @NotNull(message = "首页经营分析启用状态不能为空") + private Boolean enabled; + + @Size(max = 100, message = "模块标题长度不能超过100") + private String title; + + @Size(max = 255, message = "模块副标题长度不能超过255") + private String subtitle; + + @Size(max = 255, message = "空状态文案长度不能超过255") + private String emptyStateText; + + @Valid + private List cards = new ArrayList<>(); + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getSubtitle() { + return subtitle; + } + + public void setSubtitle(String subtitle) { + this.subtitle = subtitle; + } + + public String getEmptyStateText() { + return emptyStateText; + } + + public void setEmptyStateText(String emptyStateText) { + this.emptyStateText = emptyStateText; + } + + public List getCards() { + return cards; + } + + public void setCards(List cards) { + this.cards = cards; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java index 85e79600..cd2703aa 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java @@ -41,6 +41,8 @@ public class CreateOpportunityRequest { private Long salesExpansionId; private Long channelExpansionId; private String competitorName; + private String latestProgress; + private String nextPlan; private String description; public Long getId() { @@ -163,6 +165,22 @@ public class CreateOpportunityRequest { this.competitorName = competitorName; } + public String getLatestProgress() { + return latestProgress; + } + + public void setLatestProgress(String latestProgress) { + this.latestProgress = latestProgress; + } + + public String getNextPlan() { + return nextPlan; + } + + public void setNextPlan(String nextPlan) { + this.nextPlan = nextPlan; + } + public String getDescription() { return description; } diff --git a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java index b3fc020e..c6b4ae81 100644 --- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java @@ -139,6 +139,11 @@ public interface WorkMapper { @Param("content") String content, @Param("nextAction") String nextAction); + int updateOpportunitySnapshot( + @Param("opportunityId") Long opportunityId, + @Param("latestProgress") String latestProgress, + @Param("nextPlan") String nextPlan); + int deleteTodosByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId); int deleteTodosByBizType(@Param("userId") Long userId, @Param("bizType") String bizType); diff --git a/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java b/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java new file mode 100644 index 00000000..5d021b14 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java @@ -0,0 +1,1161 @@ +package com.unis.crm.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.unis.crm.common.BusinessException; +import com.unis.crm.common.UnauthorizedException; +import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO; +import com.unis.crm.dto.dashboard.DashboardAnalyticsChartPointDTO; +import com.unis.crm.dto.dashboard.DashboardAnalyticsPanelDTO; +import com.unis.crm.dto.dashboardanalytics.DashboardAnalyticsCardConfigDTO; +import com.unis.crm.dto.dashboardanalytics.DashboardAnalyticsConfigDTO; +import com.unisbase.security.PermissionService; +import com.unisbase.security.SpringSecurityTenantProvider; +import com.unisbase.security.SpringSecurityUserProvider; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +public class DashboardAnalyticsConfigService { + + private static final Logger log = LoggerFactory.getLogger(DashboardAnalyticsConfigService.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String VIEW_PERM = "dashboard_analytics_config:view"; + private static final String UPDATE_PERM = "dashboard_analytics_config:update"; + private static final String PREVIEW_PERM = "dashboard_analytics_config:preview"; + private static final Set ALLOWED_VALUE_TYPES = Set.of("number", "amount", "percent", "text"); + private static final Set ALLOWED_RENDER_TYPES = Set.of("metric", "line", "bar", "pie", "ring", "funnel", "ranking", "table"); + private static final Set ALLOWED_SORT_DIRECTIONS = Set.of("sql", "asc", "desc"); + private static final Set ALLOWED_LAYOUT_TYPES = Set.of("vertical", "horizontal"); + private static final Set ALLOWED_SQL_PARAMS = Set.of( + "tenantId", + "currentUserId", + "today", + "yesterday", + "monthStart", + "monthEnd", + "nextMonthStart", + "weekStart", + "weekEnd", + "now"); + private static final Pattern NAMED_PARAM_PATTERN = Pattern.compile("(? SIGN_CATEGORY_OPTIONS = List.of( + new CategoryOption("未签约", "#2563eb", null), + new CategoryOption("已签约", "#14b8a6", null)); + + private final JdbcTemplate jdbcTemplate; + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + private final PermissionService permissionService; + private final SpringSecurityTenantProvider tenantProvider; + private final SpringSecurityUserProvider userProvider; + + public DashboardAnalyticsConfigService( + JdbcTemplate jdbcTemplate, + NamedParameterJdbcTemplate namedParameterJdbcTemplate, + PermissionService permissionService, + SpringSecurityTenantProvider tenantProvider, + SpringSecurityUserProvider userProvider) { + this.jdbcTemplate = jdbcTemplate; + this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; + this.permissionService = permissionService; + this.tenantProvider = tenantProvider; + this.userProvider = userProvider; + } + + public DashboardAnalyticsConfigDTO getConfig(Long tenantId) { + requirePermission(VIEW_PERM, "无权查看首页经营分析配置"); + return loadConfig(resolveTenantId(tenantId)); + } + + @Transactional + public boolean saveConfig(DashboardAnalyticsConfigDTO payload) { + requirePermission(UPDATE_PERM, "无权修改首页经营分析配置"); + Long tenantId = resolveTenantId(payload == null ? null : payload.getTenantId()); + DashboardAnalyticsConfigDTO normalized = normalizeConfig(payload, tenantId); + jdbcTemplate.update(""" + insert into dashboard_analytics_panel_config ( + tenant_id, + enabled, + title, + subtitle, + empty_state_text, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, now(), now()) + on conflict (tenant_id) do update + set enabled = excluded.enabled, + title = excluded.title, + subtitle = excluded.subtitle, + empty_state_text = excluded.empty_state_text, + updated_at = now() + """, + tenantId, + normalized.getEnabled(), + normalized.getTitle(), + normalized.getSubtitle(), + normalized.getEmptyStateText()); + + jdbcTemplate.update("delete from dashboard_analytics_card_config where tenant_id = ?", tenantId); + for (DashboardAnalyticsCardConfigDTO card : normalized.getCards()) { + jdbcTemplate.update(""" + insert into dashboard_analytics_card_config ( + tenant_id, + card_key, + title, + subtitle, + render_type, + sql_template, + value_field, + description_field, + category_field, + color_field, + display_text_config, + unit, + value_type, + sort_direction, + display_limit, + layout_type, + full_row, + link_path, + sort_order, + enabled, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + """, + tenantId, + card.getCardKey(), + card.getTitle(), + card.getSubtitle(), + card.getRenderType(), + card.getSqlTemplate(), + card.getValueField(), + emptyToNull(card.getDescriptionField()), + emptyToNull(card.getCategoryField()), + emptyToNull(card.getColorField()), + emptyToNull(card.getDisplayTextConfig()), + emptyToNull(card.getUnit()), + card.getValueType(), + card.getSortDirection(), + normalizeDisplayLimit(card.getDisplayLimit()), + card.getLayoutType(), + Boolean.TRUE.equals(card.getFullRow()), + emptyToNull(card.getLinkPath()), + card.getSortOrder(), + card.getEnabled()); + } + return true; + } + + public DashboardAnalyticsPanelDTO preview(Long tenantId) { + requirePermission(PREVIEW_PERM, "无权预览首页经营分析配置"); + Long resolvedTenantId = resolveTenantId(tenantId); + Long currentUserId = userProvider.getCurrentUserId(); + if (currentUserId == null || currentUserId <= 0) { + throw new UnauthorizedException("登录已失效,请重新登录"); + } + return buildPanel(resolvedTenantId, currentUserId, true); + } + + public DashboardAnalyticsPanelDTO getDashboardPanel(Long currentUserId) { + Long tenantId = tenantProvider.getCurrentTenantId(); + if (tenantId == null || tenantId <= 0 || currentUserId == null || currentUserId <= 0) { + return disabledPanel(); + } + return buildPanel(tenantId, currentUserId, false); + } + + public DashboardAnalyticsCardDTO previewCard(Long tenantId, String cardKey) { + requirePermission(PREVIEW_PERM, "无权预览首页经营分析配置"); + Long resolvedTenantId = resolveTenantId(tenantId); + Long currentUserId = userProvider.getCurrentUserId(); + if (currentUserId == null || currentUserId <= 0) { + throw new UnauthorizedException("登录已失效,请重新登录"); + } + DashboardAnalyticsCardConfigDTO cardConfig = findCardConfig(resolvedTenantId, cardKey, true); + return executeCardWithDetailFallback(resolvedTenantId, currentUserId, cardConfig); + } + + public DashboardAnalyticsCardDTO getDashboardCardDetail(Long currentUserId, String cardKey) { + Long tenantId = tenantProvider.getCurrentTenantId(); + if (tenantId == null || tenantId <= 0 || currentUserId == null || currentUserId <= 0) { + throw new BusinessException("未找到当前租户或登录用户"); + } + DashboardAnalyticsCardConfigDTO cardConfig = findCardConfig(tenantId, cardKey, false); + if (!Boolean.TRUE.equals(cardConfig.getEnabled())) { + throw new BusinessException("卡片未启用或不存在"); + } + return executeCardWithDetailFallback(tenantId, currentUserId, cardConfig); + } + + private DashboardAnalyticsCardDTO executeCardWithDetailFallback( + Long tenantId, + Long currentUserId, + DashboardAnalyticsCardConfigDTO cardConfig) { + try { + return executeCard(tenantId, currentUserId, cardConfig, false); + } catch (Exception exception) { + log.warn("Failed to load full dashboard analytics detail for card {} in tenant {}, fallback to limited data", + cardConfig.getCardKey(), tenantId, exception); + return executeCard(tenantId, currentUserId, cardConfig, true); + } + } + + private DashboardAnalyticsPanelDTO buildPanel(Long tenantId, Long currentUserId, boolean includeErrors) { + DashboardAnalyticsConfigDTO config = loadConfig(tenantId); + DashboardAnalyticsPanelDTO panel = new DashboardAnalyticsPanelDTO(); + panel.setEnabled(config.getEnabled()); + panel.setTitle(config.getTitle()); + panel.setSubtitle(config.getSubtitle()); + panel.setEmptyStateText(config.getEmptyStateText()); + if (!Boolean.TRUE.equals(config.getEnabled())) { + panel.setCards(List.of()); + return panel; + } + + List cards = new ArrayList<>(); + for (DashboardAnalyticsCardConfigDTO cardConfig : config.getCards()) { + if (!Boolean.TRUE.equals(cardConfig.getEnabled())) { + continue; + } + try { + cards.add(executeCard(tenantId, currentUserId, cardConfig, true)); + } catch (Exception exception) { + log.warn("Failed to execute dashboard analytics card {} for tenant {}", cardConfig.getCardKey(), tenantId, exception); + if (includeErrors) { + DashboardAnalyticsCardDTO failed = new DashboardAnalyticsCardDTO(); + failed.setId(cardConfig.getId()); + failed.setCardKey(cardConfig.getCardKey()); + failed.setTitle(cardConfig.getTitle()); + failed.setSubtitle(cardConfig.getSubtitle()); + failed.setRenderType(cardConfig.getRenderType()); + failed.setValue("0"); + failed.setValueText("查询失败"); + failed.setValueType(cardConfig.getValueType()); + failed.setUnit(cardConfig.getUnit()); + failed.setDisplayTextConfig(cardConfig.getDisplayTextConfig()); + failed.setLinkPath(cardConfig.getLinkPath()); + failed.setLayoutType(cardConfig.getLayoutType()); + failed.setFullRow(cardConfig.getFullRow()); + failed.setSortOrder(cardConfig.getSortOrder()); + failed.setErrorMessage(exception.getMessage()); + failed.setHasMore(false); + failed.setTotalCount(0); + failed.setChartData(List.of()); + cards.add(failed); + } + } + } + cards.sort(Comparator.comparing(DashboardAnalyticsCardDTO::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(DashboardAnalyticsCardDTO::getId, Comparator.nullsLast(Long::compareTo))); + panel.setCards(cards); + return panel; + } + + private DashboardAnalyticsCardDTO executeCard( + Long tenantId, + Long currentUserId, + DashboardAnalyticsCardConfigDTO config, + boolean applyDisplayLimit) { + ExecutableSql executableSql = prepareExecutableSql(config.getSqlTemplate(), tenantId, currentUserId); + List> rows = namedParameterJdbcTemplate.queryForList(executableSql.sql(), executableSql.params()); + Map row = rows.isEmpty() ? Map.of() : rows.get(0); + String renderType = normalizeRenderType(config.getRenderType()); + + DashboardAnalyticsCardDTO dto = new DashboardAnalyticsCardDTO(); + dto.setId(config.getId()); + dto.setCardKey(config.getCardKey()); + dto.setTitle(config.getTitle()); + dto.setSubtitle(config.getSubtitle()); + dto.setRenderType(renderType); + dto.setValueType(config.getValueType()); + dto.setUnit(config.getUnit()); + dto.setDisplayTextConfig(config.getDisplayTextConfig()); + dto.setLinkPath(config.getLinkPath()); + dto.setLayoutType(config.getLayoutType()); + dto.setFullRow(config.getFullRow()); + dto.setSortOrder(config.getSortOrder()); + if ("metric".equals(renderType)) { + Object valueObject = readValue(row, config.getValueField(), "value"); + Object descriptionObject = readValue(row, config.getDescriptionField(), "description"); + dto.setDescription(toDisplayString(descriptionObject)); + dto.setValue(toDisplayString(valueObject)); + dto.setValueText(formatValue(valueObject, config.getValueType(), config.getUnit())); + dto.setHasMore(false); + dto.setTotalCount(rows.isEmpty() ? 0 : 1); + dto.setChartData(List.of()); + } else { + dto.setDescription(""); + dto.setValue(""); + dto.setValueText(""); + VisualizationDataResult visualization = buildVisualizationData(rows, config, renderType, applyDisplayLimit); + dto.setHasMore(visualization.hasMore()); + dto.setTotalCount(visualization.totalCount()); + dto.setChartData(visualization.points()); + } + return dto; + } + + private ExecutableSql prepareExecutableSql(String sqlTemplate, Long tenantId, Long currentUserId) { + MapSqlParameterSource params = buildSqlParams(tenantId, currentUserId); + String executableSql = applyDataScopeMacros(sqlTemplate, tenantId, currentUserId, params); + return new ExecutableSql(executableSql, params); + } + + private DashboardAnalyticsConfigDTO loadConfig(Long tenantId) { + DashboardAnalyticsConfigDTO dto = jdbcTemplate.query(""" + select tenant_id, enabled, title, subtitle, empty_state_text + from dashboard_analytics_panel_config + where tenant_id = ? + limit 1 + """, + resultSet -> resultSet.next() ? mapPanel(resultSet) : null, + tenantId); + if (dto == null) { + dto = defaultConfig(tenantId); + } + dto.setCards(loadCards(tenantId)); + return dto; + } + + private DashboardAnalyticsConfigDTO mapPanel(ResultSet resultSet) throws SQLException { + DashboardAnalyticsConfigDTO dto = new DashboardAnalyticsConfigDTO(); + dto.setTenantId(resultSet.getLong("tenant_id")); + dto.setEnabled(resultSet.getBoolean("enabled")); + dto.setTitle(resultSet.getString("title")); + dto.setSubtitle(resultSet.getString("subtitle")); + dto.setEmptyStateText(resultSet.getString("empty_state_text")); + return dto; + } + + private List loadCards(Long tenantId) { + return jdbcTemplate.query(""" + select + id, + card_key, + title, + subtitle, + render_type, + sql_template, + value_field, + description_field, + category_field, + color_field, + display_text_config, + unit, + value_type, + sort_direction, + display_limit, + layout_type, + full_row, + link_path, + sort_order, + enabled + from dashboard_analytics_card_config + where tenant_id = ? + order by sort_order asc, id asc + """, + cardConfigRowMapper(), + tenantId); + } + + private DashboardAnalyticsCardConfigDTO findCardConfig(Long tenantId, String cardKey, boolean includeDisabled) { + String normalizedCardKey = defaultIfBlank(cardKey, null); + if (!StringUtils.hasText(normalizedCardKey)) { + throw new BusinessException("卡片编码不能为空"); + } + List cards = jdbcTemplate.query(""" + select + id, + card_key, + title, + subtitle, + render_type, + sql_template, + value_field, + description_field, + category_field, + color_field, + display_text_config, + unit, + value_type, + sort_direction, + display_limit, + layout_type, + full_row, + link_path, + sort_order, + enabled + from dashboard_analytics_card_config + where tenant_id = ? + and card_key = ? + limit 1 + """, + cardConfigRowMapper(), + tenantId, + normalizedCardKey.trim()); + DashboardAnalyticsCardConfigDTO card = cards.isEmpty() ? null : cards.get(0); + if (card == null || (!includeDisabled && !Boolean.TRUE.equals(card.getEnabled()))) { + throw new BusinessException("未找到经营分析卡片:" + normalizedCardKey); + } + return card; + } + + private RowMapper cardConfigRowMapper() { + return (resultSet, rowNum) -> { + DashboardAnalyticsCardConfigDTO dto = new DashboardAnalyticsCardConfigDTO(); + dto.setId(resultSet.getLong("id")); + dto.setCardKey(resultSet.getString("card_key")); + dto.setTitle(resultSet.getString("title")); + dto.setSubtitle(resultSet.getString("subtitle")); + dto.setRenderType(resultSet.getString("render_type")); + dto.setSqlTemplate(resultSet.getString("sql_template")); + dto.setValueField(resultSet.getString("value_field")); + dto.setDescriptionField(resultSet.getString("description_field")); + dto.setCategoryField(resultSet.getString("category_field")); + dto.setColorField(resultSet.getString("color_field")); + dto.setDisplayTextConfig(resultSet.getString("display_text_config")); + dto.setUnit(resultSet.getString("unit")); + dto.setValueType(resultSet.getString("value_type")); + dto.setSortDirection(resultSet.getString("sort_direction")); + dto.setDisplayLimit((Integer) resultSet.getObject("display_limit")); + dto.setLayoutType(resultSet.getString("layout_type")); + dto.setFullRow(resultSet.getBoolean("full_row")); + dto.setLinkPath(resultSet.getString("link_path")); + dto.setSortOrder(resultSet.getInt("sort_order")); + dto.setEnabled(resultSet.getBoolean("enabled")); + return dto; + }; + } + + private DashboardAnalyticsConfigDTO normalizeConfig(DashboardAnalyticsConfigDTO payload, Long tenantId) { + DashboardAnalyticsConfigDTO normalized = new DashboardAnalyticsConfigDTO(); + normalized.setTenantId(tenantId); + normalized.setEnabled(payload != null && Boolean.TRUE.equals(payload.getEnabled())); + normalized.setTitle(defaultIfBlank(payload == null ? null : payload.getTitle(), "经营分析")); + normalized.setSubtitle(defaultIfBlank(payload == null ? null : payload.getSubtitle(), "支持后台配置首页经营分析卡片与 SQL 取数逻辑")); + normalized.setEmptyStateText(defaultIfBlank(payload == null ? null : payload.getEmptyStateText(), "暂无可展示的经营分析卡片")); + normalized.setCards(normalizeCards(payload == null ? null : payload.getCards())); + return normalized; + } + + private List normalizeCards(List cards) { + List normalized = new ArrayList<>(); + if (cards == null) { + return normalized; + } + Set cardKeys = new LinkedHashSet<>(); + int index = 0; + for (DashboardAnalyticsCardConfigDTO card : cards) { + if (card == null) { + continue; + } + String cardKey = defaultIfBlank(card.getCardKey(), null); + if (!StringUtils.hasText(cardKey)) { + throw new BusinessException("卡片编码不能为空"); + } + cardKey = cardKey.trim(); + if (!cardKeys.add(cardKey)) { + throw new BusinessException("卡片编码不能重复:" + cardKey); + } + String sqlTemplate = normalizeSqlTemplate(card.getSqlTemplate(), cardKey); + String renderType = normalizeRenderType(card.getRenderType()); + String valueType = normalizeValueType(card.getValueType()); + String sortDirection = normalizeSortDirection(card.getSortDirection()); + String layoutType = normalizeLayoutType(card.getLayoutType()); + if (!"metric".equals(renderType) && !"table".equals(renderType) && "text".equals(valueType)) { + throw new BusinessException("图表卡片不支持文本展示类型:" + cardKey); + } + DashboardAnalyticsCardConfigDTO item = new DashboardAnalyticsCardConfigDTO(); + item.setId(card.getId()); + item.setCardKey(cardKey); + item.setTitle(defaultIfBlank(card.getTitle(), cardKey)); + item.setSubtitle(trimToNull(card.getSubtitle())); + item.setRenderType(renderType); + item.setSqlTemplate(sqlTemplate); + item.setValueField(defaultIfBlank(card.getValueField(), "value")); + item.setDescriptionField(trimToNull(card.getDescriptionField())); + item.setCategoryField("metric".equals(renderType) ? null : defaultIfBlank(card.getCategoryField(), "label")); + item.setColorField(trimToNull(card.getColorField())); + item.setDisplayTextConfig(trimToNull(card.getDisplayTextConfig())); + item.setUnit(trimToNull(card.getUnit())); + item.setValueType(valueType); + item.setSortDirection(sortDirection); + item.setDisplayLimit(normalizeDisplayLimit(card.getDisplayLimit())); + item.setLayoutType(layoutType); + item.setFullRow(Boolean.TRUE.equals(card.getFullRow())); + item.setLinkPath(trimToNull(card.getLinkPath())); + item.setSortOrder(card.getSortOrder() == null ? index : card.getSortOrder()); + item.setEnabled(card.getEnabled() == null || card.getEnabled()); + normalized.add(item); + index++; + } + return normalized; + } + + private DashboardAnalyticsConfigDTO defaultConfig(Long tenantId) { + DashboardAnalyticsConfigDTO dto = new DashboardAnalyticsConfigDTO(); + dto.setTenantId(tenantId); + dto.setEnabled(false); + dto.setTitle("经营分析"); + dto.setSubtitle("支持后台配置首页经营分析卡片与 SQL 取数逻辑"); + dto.setEmptyStateText("暂无可展示的经营分析卡片"); + dto.setCards(List.of()); + return dto; + } + + private DashboardAnalyticsPanelDTO disabledPanel() { + DashboardAnalyticsPanelDTO panel = new DashboardAnalyticsPanelDTO(); + panel.setEnabled(false); + panel.setTitle("经营分析"); + panel.setSubtitle("支持后台配置首页经营分析卡片与 SQL 取数逻辑"); + panel.setEmptyStateText("暂无可展示的经营分析卡片"); + panel.setCards(List.of()); + return panel; + } + + private String normalizeSqlTemplate(String sqlTemplate, String cardKey) { + String normalized = defaultIfBlank(sqlTemplate, null); + if (!StringUtils.hasText(normalized)) { + throw new BusinessException("卡片 " + cardKey + " 的 SQL 不能为空"); + } + normalized = normalized.trim(); + String lowerCaseSql = normalized.toLowerCase(Locale.ROOT); + if (!(lowerCaseSql.startsWith("select") || lowerCaseSql.startsWith("with"))) { + throw new BusinessException("卡片 " + cardKey + " 仅支持 SELECT / WITH 查询"); + } + if (normalized.contains(";")) { + throw new BusinessException("卡片 " + cardKey + " 的 SQL 不允许包含分号"); + } + if (normalized.contains("/*") || normalized.contains("*/")) { + throw new BusinessException("卡片 " + cardKey + " 的 SQL 不允许包含块注释"); + } + validateDataScopeMacros(normalized, cardKey); + Matcher forbiddenMatcher = FORBIDDEN_SQL_PATTERN.matcher(normalized); + if (forbiddenMatcher.find()) { + throw new BusinessException("卡片 " + cardKey + " 的 SQL 包含不允许的关键字:" + forbiddenMatcher.group(1)); + } + validateSqlParams(normalized, cardKey); + return normalized; + } + + private void validateDataScopeMacros(String sqlTemplate, String cardKey) { + Matcher matcher = DATA_SCOPE_MACRO_PATTERN.matcher(sqlTemplate); + while (matcher.find()) { + String ownerExpression = matcher.group(1); + if (!StringUtils.hasText(ownerExpression) || !ownerExpression.matches("[A-Za-z_][A-Za-z0-9_\\.]*")) { + throw new BusinessException("卡片 " + cardKey + " 的 DATA_SCOPE 宏字段不合法:" + ownerExpression); + } + } + } + + private String normalizeRenderType(String renderType) { + String normalized = defaultIfBlank(renderType, "metric").toLowerCase(Locale.ROOT); + if (!ALLOWED_RENDER_TYPES.contains(normalized)) { + throw new BusinessException("不支持的图表类型:" + renderType); + } + return normalized; + } + + private void validateSqlParams(String sqlTemplate, String cardKey) { + String sqlWithoutDataScopeMacros = DATA_SCOPE_MACRO_PATTERN.matcher(sqlTemplate).replaceAll("true"); + Matcher matcher = NAMED_PARAM_PATTERN.matcher(sqlWithoutDataScopeMacros); + Set actualParams = new LinkedHashSet<>(); + while (matcher.find()) { + actualParams.add(matcher.group(1)); + } + for (String actualParam : actualParams) { + if (!ALLOWED_SQL_PARAMS.contains(actualParam)) { + throw new BusinessException("卡片 " + cardKey + " 使用了不支持的参数:" + actualParam); + } + } + } + + private String applyDataScopeMacros( + String sqlTemplate, + Long tenantId, + Long currentUserId, + MapSqlParameterSource params) { + Matcher matcher = DATA_SCOPE_MACRO_PATTERN.matcher(sqlTemplate); + if (!matcher.find()) { + return sqlTemplate; + } + + DataScopeRule dataScopeRule = resolveDataScopeRule(tenantId, currentUserId); + StringBuffer sqlBuffer = new StringBuffer(); + int macroIndex = 0; + do { + String ownerExpression = matcher.group(1); + String replacement = buildDataScopeClause(ownerExpression, dataScopeRule, params, macroIndex++); + matcher.appendReplacement(sqlBuffer, Matcher.quoteReplacement(replacement)); + } while (matcher.find()); + matcher.appendTail(sqlBuffer); + return sqlBuffer.toString(); + } + + private DataScopeRule resolveDataScopeRule(Long tenantId, Long currentUserId) { + List roleScopes = jdbcTemplate.query(""" + select distinct r.role_id, coalesce(r.data_scope_type, 'SELF') as data_scope_type + from sys_user_role ur + join sys_role r on r.role_id = ur.role_id + where ur.user_id = ? + and ur.tenant_id = ? + and coalesce(ur.is_deleted, 0) = 0 + and coalesce(r.is_deleted, 0) = 0 + and coalesce(r.status, 1) = 1 + """, + (resultSet, rowNum) -> new RoleScopeAssignment( + resultSet.getLong("role_id"), + defaultIfBlank(resultSet.getString("data_scope_type"), "SELF").toUpperCase(Locale.ROOT)), + currentUserId, + tenantId); + + if (roleScopes.isEmpty()) { + return new DataScopeRule(false, true, Set.of()); + } + + boolean hasAllScope = roleScopes.stream().anyMatch(item -> "ALL".equals(item.scopeType())); + if (hasAllScope) { + return new DataScopeRule(true, false, Set.of()); + } + + boolean includeSelf = false; + boolean includeDept = false; + boolean includeDeptAndChild = false; + Set customRoleIds = new LinkedHashSet<>(); + for (RoleScopeAssignment roleScope : roleScopes) { + switch (roleScope.scopeType()) { + case "SELF" -> includeSelf = true; + case "DEPT" -> includeDept = true; + case "DEPT_AND_CHILD" -> includeDeptAndChild = true; + case "CUSTOM" -> customRoleIds.add(roleScope.roleId()); + default -> includeSelf = true; + } + } + + Set scopedOrgIds = new LinkedHashSet<>(); + Long currentOrgId = queryCurrentUserOrgId(tenantId, currentUserId); + if (includeDept && currentOrgId != null) { + scopedOrgIds.add(currentOrgId); + } + if (includeDeptAndChild && currentOrgId != null) { + scopedOrgIds.addAll(queryOrgIdsWithChildren(tenantId, currentOrgId)); + } + if (!customRoleIds.isEmpty()) { + scopedOrgIds.addAll(queryCustomScopeOrgIds(customRoleIds)); + } + + if (!includeSelf && scopedOrgIds.isEmpty()) { + includeSelf = true; + } + return new DataScopeRule(false, includeSelf, scopedOrgIds); + } + + private Long queryCurrentUserOrgId(Long tenantId, Long currentUserId) { + List orgIds = jdbcTemplate.query(""" + select tu.org_id + from sys_tenant_user tu + where tu.tenant_id = ? + and tu.user_id = ? + and coalesce(tu.is_deleted, 0) = 0 + order by tu.id asc + limit 1 + """, + (resultSet, rowNum) -> { + long value = resultSet.getLong("org_id"); + return resultSet.wasNull() ? null : value; + }, + tenantId, + currentUserId); + return orgIds.isEmpty() ? null : orgIds.get(0); + } + + private Set queryOrgIdsWithChildren(Long tenantId, Long orgId) { + if (orgId == null) { + return Set.of(); + } + return new LinkedHashSet<>(jdbcTemplate.queryForList(""" + with recursive org_tree as ( + select id + from sys_org + where tenant_id = ? + and id = ? + and coalesce(is_deleted, 0) = 0 + union all + select child.id + from sys_org child + join org_tree parent_org on child.parent_id = parent_org.id + where child.tenant_id = ? + and coalesce(child.is_deleted, 0) = 0 + ) + select id + from org_tree + """, + Long.class, + tenantId, + orgId, + tenantId)); + } + + private Set queryCustomScopeOrgIds(Collection roleIds) { + if (roleIds == null || roleIds.isEmpty()) { + return Set.of(); + } + return new LinkedHashSet<>(namedParameterJdbcTemplate.queryForList(""" + select distinct org_id + from sys_role_data_scope_org + where role_id in (:roleIds) + """, + new MapSqlParameterSource().addValue("roleIds", roleIds), + Long.class)); + } + + private String buildDataScopeClause( + String ownerExpression, + DataScopeRule dataScopeRule, + MapSqlParameterSource params, + int macroIndex) { + String scopeAlias = "ds_tu_" + macroIndex; + StringBuilder clause = new StringBuilder() + .append("exists (select 1 from sys_tenant_user ").append(scopeAlias) + .append(" where ").append(scopeAlias).append(".user_id = ").append(ownerExpression) + .append(" and ").append(scopeAlias).append(".tenant_id = :tenantId") + .append(" and coalesce(").append(scopeAlias).append(".is_deleted, 0) = 0"); + + if (!dataScopeRule.allDataInTenant()) { + List predicates = new ArrayList<>(); + if (dataScopeRule.includeSelf()) { + predicates.add(scopeAlias + ".user_id = :currentUserId"); + } + if (!dataScopeRule.orgIds().isEmpty()) { + String orgParamName = "dataScopeOrgIds" + macroIndex; + params.addValue(orgParamName, dataScopeRule.orgIds()); + predicates.add(scopeAlias + ".org_id in (:" + orgParamName + ")"); + } + if (predicates.isEmpty()) { + predicates.add(scopeAlias + ".user_id = :currentUserId"); + } + clause.append(" and (").append(String.join(" or ", predicates)).append(")"); + } + + clause.append(")"); + return clause.toString(); + } + + private MapSqlParameterSource buildSqlParams(Long tenantId, Long currentUserId) { + LocalDate today = LocalDate.now(); + LocalDate monthStart = today.withDayOfMonth(1); + LocalDate nextMonthStart = monthStart.plusMonths(1); + LocalDate monthEnd = nextMonthStart.minusDays(1); + LocalDate weekStart = today.minusDays(today.getDayOfWeek().getValue() - 1L); + LocalDate weekEnd = weekStart.plusDays(6); + return new MapSqlParameterSource() + .addValue("tenantId", tenantId) + .addValue("currentUserId", currentUserId) + .addValue("today", today) + .addValue("yesterday", today.minusDays(1)) + .addValue("monthStart", monthStart) + .addValue("monthEnd", monthEnd) + .addValue("nextMonthStart", nextMonthStart) + .addValue("weekStart", weekStart) + .addValue("weekEnd", weekEnd) + .addValue("now", OffsetDateTime.now()); + } + + private Object readValue(Map row, String preferredField, String fallbackField) { + if (row == null || row.isEmpty()) { + return null; + } + Map normalizedRow = new LinkedHashMap<>(); + row.forEach((key, value) -> normalizedRow.put(key == null ? "" : key.toLowerCase(Locale.ROOT), value)); + String normalizedPreferred = preferredField == null ? null : preferredField.trim().toLowerCase(Locale.ROOT); + if (StringUtils.hasText(normalizedPreferred) && normalizedRow.containsKey(normalizedPreferred)) { + return normalizedRow.get(normalizedPreferred); + } + return normalizedRow.get(fallbackField.toLowerCase(Locale.ROOT)); + } + + private String formatValue(Object valueObject, String valueType, String unit) { + String normalizedType = normalizeValueType(valueType); + if (valueObject == null) { + return "text".equals(normalizedType) ? "-" : formatWithUnit("0", unit); + } + if ("text".equals(normalizedType)) { + return toDisplayString(valueObject); + } + BigDecimal numericValue = toBigDecimal(valueObject); + if (numericValue == null) { + return formatWithUnit(toDisplayString(valueObject), unit); + } + String formatted = switch (normalizedType) { + case "amount" -> stripTrailingZeros(numericValue.setScale(2, RoundingMode.HALF_UP)); + case "percent" -> stripTrailingZeros(numericValue.setScale(2, RoundingMode.HALF_UP)) + "%"; + default -> stripTrailingZeros(numericValue); + }; + return formatWithUnit(formatted, "percent".equals(normalizedType) ? null : unit); + } + + private VisualizationDataResult buildVisualizationData( + List> rows, + DashboardAnalyticsCardConfigDTO config, + String renderType, + boolean applyDisplayLimit) { + String rankingSecondaryField = "ranking".equals(renderType) ? resolveDisplayTextString(config, "rankingSecondaryField") : null; + String rankingSecondaryValueType = "ranking".equals(renderType) + ? normalizeDisplayTextValueType(resolveDisplayTextString(config, "rankingSecondaryValueType"), "percent") + : null; + List points = new ArrayList<>(); + for (Map row : rows == null ? List.>of() : rows) { + Object valueObject = readValue(row, config.getValueField(), "value"); + DashboardAnalyticsChartPointDTO point = new DashboardAnalyticsChartPointDTO(); + String label = toDisplayString(readValue(row, config.getCategoryField(), "label")); + point.setLabel(StringUtils.hasText(label) ? label : "未命名"); + point.setDescription(toDisplayString(readValue(row, config.getDescriptionField(), "description"))); + point.setColor(toDisplayString(readValue(row, config.getColorField(), "color"))); + if ("table".equals(renderType)) { + point.setValue(toDisplayString(valueObject)); + point.setValueText(formatValue(valueObject, config.getValueType(), config.getUnit())); + } else { + BigDecimal numericValue = toBigDecimal(valueObject); + if (numericValue == null) { + continue; + } + point.setValue(stripTrailingZeros(numericValue)); + point.setValueText(formatValue(numericValue, config.getValueType(), config.getUnit())); + } + if ("ranking".equals(renderType) && StringUtils.hasText(rankingSecondaryField)) { + Object secondaryValueObject = readValue(row, rankingSecondaryField, rankingSecondaryField); + String secondaryValue = toDisplayString(secondaryValueObject); + if (StringUtils.hasText(secondaryValue)) { + point.setSecondaryValue(secondaryValue); + point.setSecondaryValueText(formatValue(secondaryValueObject, rankingSecondaryValueType, null)); + } + } + points.add(point); + } + List normalizedPoints = applyConfiguredCategories(points, config, renderType); + if (normalizedPoints.isEmpty()) { + return new VisualizationDataResult(List.of(), 0, false); + } + List orderedPoints = sortVisualizationData(normalizedPoints, config); + List visiblePoints = applyDisplayLimit + ? applyDisplayLimit(orderedPoints, config.getDisplayLimit(), renderType) + : orderedPoints; + return new VisualizationDataResult(visiblePoints, orderedPoints.size(), orderedPoints.size() > visiblePoints.size()); + } + + private List applyConfiguredCategories( + List points, + DashboardAnalyticsCardConfigDTO config, + String renderType) { + List options = resolveCategoryOptions(config); + if (options.isEmpty()) { + options = deriveBuiltinCategoryOptions(points, renderType); + } + if (options.isEmpty()) { + return points; + } + Map pointMap = new LinkedHashMap<>(); + for (DashboardAnalyticsChartPointDTO point : points) { + pointMap.put(defaultIfBlank(point.getLabel(), "").trim(), point); + } + List merged = new ArrayList<>(); + for (CategoryOption option : options) { + DashboardAnalyticsChartPointDTO existing = pointMap.remove(option.label()); + if (existing == null) { + DashboardAnalyticsChartPointDTO created = new DashboardAnalyticsChartPointDTO(); + created.setLabel(option.label()); + created.setColor(option.color()); + created.setDescription(option.description()); + created.setValue("table".equals(renderType) ? "0" : "0"); + created.setValueText(formatValue(BigDecimal.ZERO, config.getValueType(), config.getUnit())); + merged.add(created); + continue; + } + if (!StringUtils.hasText(existing.getColor()) && StringUtils.hasText(option.color())) { + existing.setColor(option.color()); + } + if (!StringUtils.hasText(existing.getDescription()) && StringUtils.hasText(option.description())) { + existing.setDescription(option.description()); + } + merged.add(existing); + } + merged.addAll(pointMap.values()); + return merged; + } + + private List resolveCategoryOptions(DashboardAnalyticsCardConfigDTO config) { + if (config == null || !StringUtils.hasText(config.getDisplayTextConfig())) { + return List.of(); + } + try { + JsonNode root = OBJECT_MAPPER.readTree(config.getDisplayTextConfig()); + JsonNode categoryOptionsNode = root.path("categoryOptions"); + if (!categoryOptionsNode.isArray() || categoryOptionsNode.isEmpty()) { + return List.of(); + } + List options = new ArrayList<>(); + for (JsonNode item : categoryOptionsNode) { + String label = item.path("label").asText("").trim(); + if (!StringUtils.hasText(label)) { + continue; + } + String color = item.path("color").asText(null); + String description = item.path("description").asText(null); + options.add(new CategoryOption(label, StringUtils.hasText(color) ? color.trim() : null, StringUtils.hasText(description) ? description.trim() : null)); + } + return options; + } catch (Exception exception) { + log.debug("Ignore invalid displayTextConfig categoryOptions for card {}", config.getCardKey(), exception); + return List.of(); + } + } + + private String resolveDisplayTextString(DashboardAnalyticsCardConfigDTO config, String key) { + if (config == null || !StringUtils.hasText(config.getDisplayTextConfig()) || !StringUtils.hasText(key)) { + return null; + } + try { + JsonNode root = OBJECT_MAPPER.readTree(config.getDisplayTextConfig()); + String value = root.path(key).asText(""); + return StringUtils.hasText(value) ? value.trim() : null; + } catch (Exception exception) { + log.debug("Ignore invalid displayTextConfig key {} for card {}", key, config.getCardKey(), exception); + return null; + } + } + + private String normalizeDisplayTextValueType(String valueType, String fallback) { + try { + return normalizeValueType(defaultIfBlank(valueType, fallback)); + } catch (Exception ignored) { + return fallback; + } + } + + private List deriveBuiltinCategoryOptions( + List points, + String renderType) { + if (!Set.of("pie", "ring", "table", "ranking", "funnel", "bar", "line").contains(renderType) || points.isEmpty()) { + return List.of(); + } + Set labels = points.stream() + .map(DashboardAnalyticsChartPointDTO::getLabel) + .filter(StringUtils::hasText) + .map(String::trim) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + if (labels.stream().allMatch(label -> "已签约".equals(label) || "未签约".equals(label))) { + return SIGN_CATEGORY_OPTIONS; + } + return List.of(); + } + + private String normalizeValueType(String valueType) { + String normalized = defaultIfBlank(valueType, "number").toLowerCase(Locale.ROOT); + if (!ALLOWED_VALUE_TYPES.contains(normalized)) { + throw new BusinessException("不支持的数值展示类型:" + valueType); + } + return normalized; + } + + private String normalizeSortDirection(String sortDirection) { + String normalized = defaultIfBlank(sortDirection, "sql").toLowerCase(Locale.ROOT); + if (!ALLOWED_SORT_DIRECTIONS.contains(normalized)) { + throw new BusinessException("不支持的数据排序方式:" + sortDirection); + } + return normalized; + } + + private String normalizeLayoutType(String layoutType) { + String normalized = defaultIfBlank(layoutType, "vertical").toLowerCase(Locale.ROOT); + if (!ALLOWED_LAYOUT_TYPES.contains(normalized)) { + throw new BusinessException("不支持的卡片布局方式:" + layoutType); + } + return normalized; + } + + private Integer normalizeDisplayLimit(Integer displayLimit) { + if (displayLimit == null || displayLimit <= 0) { + return null; + } + return displayLimit; + } + + private int defaultDisplayLimit(String renderType) { + return switch (normalizeRenderType(renderType)) { + case "line", "bar" -> 6; + case "pie", "ring", "funnel" -> 5; + case "ranking", "table" -> 6; + default -> Integer.MAX_VALUE; + }; + } + + private List sortVisualizationData( + List points, + DashboardAnalyticsCardConfigDTO config) { + String sortDirection = normalizeSortDirection(config.getSortDirection()); + if ("sql".equals(sortDirection) || points.size() <= 1) { + return points; + } + List sorted = new ArrayList<>(points); + Comparator comparator = Comparator + .comparing(this::chartPointSortValue, Comparator.nullsLast(BigDecimal::compareTo)) + .thenComparing(point -> defaultIfBlank(point.getLabel(), ""), String::compareToIgnoreCase); + if ("desc".equals(sortDirection)) { + comparator = comparator.reversed(); + } + sorted.sort(comparator); + return sorted; + } + + private BigDecimal chartPointSortValue(DashboardAnalyticsChartPointDTO point) { + BigDecimal numericValue = toBigDecimal(point.getValue()); + if (numericValue != null) { + return numericValue; + } + return toBigDecimal(point.getValueText()); + } + + private List applyDisplayLimit( + List points, + Integer displayLimit, + String renderType) { + Integer normalizedLimit = normalizeDisplayLimit(displayLimit); + if (normalizedLimit == null) { + int recommendedLimit = defaultDisplayLimit(renderType); + if (recommendedLimit == Integer.MAX_VALUE || points.size() <= recommendedLimit) { + return points; + } + return new ArrayList<>(points.subList(0, recommendedLimit)); + } + if (points.size() <= normalizedLimit) { + return points; + } + return new ArrayList<>(points.subList(0, normalizedLimit)); + } + + private String formatWithUnit(String valueText, String unit) { + if (!StringUtils.hasText(unit)) { + return valueText; + } + return valueText + " " + unit.trim(); + } + + private BigDecimal toBigDecimal(Object valueObject) { + if (valueObject instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + if (valueObject instanceof Number number) { + return BigDecimal.valueOf(number.doubleValue()); + } + if (valueObject instanceof CharSequence sequence && StringUtils.hasText(sequence.toString())) { + try { + return new BigDecimal(sequence.toString().trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private String stripTrailingZeros(BigDecimal value) { + return value.stripTrailingZeros().toPlainString(); + } + + private String toDisplayString(Object valueObject) { + if (valueObject == null) { + return ""; + } + if (valueObject instanceof BigDecimal bigDecimal) { + return stripTrailingZeros(bigDecimal); + } + if (valueObject instanceof Number number) { + return stripTrailingZeros(BigDecimal.valueOf(number.doubleValue())); + } + return String.valueOf(valueObject); + } + + private Long resolveTenantId(Long tenantId) { + Long resolvedTenantId = tenantId; + if (resolvedTenantId == null || resolvedTenantId <= 0) { + resolvedTenantId = tenantProvider.getCurrentTenantId(); + } + if (resolvedTenantId == null || resolvedTenantId <= 0) { + throw new BusinessException("请先切换到具体租户后再操作首页经营分析配置"); + } + return resolvedTenantId; + } + + private void requirePermission(String perm, String message) { + if (!permissionService.hasPermi(perm)) { + throw new UnauthorizedException(message); + } + } + + private String defaultIfBlank(String value, String defaultValue) { + String trimmed = trimToNull(value); + return trimmed == null ? defaultValue : trimmed; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String emptyToNull(String value) { + return trimToNull(value); + } + + private record ExecutableSql(String sql, MapSqlParameterSource params) { + } + + private record RoleScopeAssignment(Long roleId, String scopeType) { + } + + private record DataScopeRule(boolean allDataInTenant, boolean includeSelf, Set orgIds) { + } + + private record CategoryOption(String label, String color, String description) { + } + + private record VisualizationDataResult( + List points, + int totalCount, + boolean hasMore) { + } +} diff --git a/backend/src/main/java/com/unis/crm/service/DashboardService.java b/backend/src/main/java/com/unis/crm/service/DashboardService.java index 3790e133..bae37025 100644 --- a/backend/src/main/java/com/unis/crm/service/DashboardService.java +++ b/backend/src/main/java/com/unis/crm/service/DashboardService.java @@ -1,5 +1,6 @@ package com.unis.crm.service; +import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO; import com.unis.crm.dto.dashboard.DashboardHomeDTO; public interface DashboardService { @@ -7,4 +8,6 @@ public interface DashboardService { DashboardHomeDTO getHome(Long userId); void completeTodo(Long userId, Long todoId); + + DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey); } diff --git a/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java index e5dfb309..9fb615c4 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java @@ -1,12 +1,16 @@ package com.unis.crm.service.impl; import com.unis.crm.common.BusinessException; +import com.unis.crm.common.UnauthorizedException; import com.unis.crm.dto.dashboard.DashboardActivityDTO; +import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO; +import com.unis.crm.dto.dashboard.DashboardAnalyticsPanelDTO; import com.unis.crm.dto.dashboard.DashboardHomeDTO; import com.unis.crm.dto.dashboard.DashboardStatDTO; import com.unis.crm.dto.dashboard.DashboardTodoDTO; import com.unis.crm.dto.dashboard.UserWelcomeDTO; import com.unis.crm.mapper.DashboardMapper; +import com.unis.crm.service.DashboardAnalyticsConfigService; import com.unis.crm.service.DashboardService; import com.unisbase.service.SysPermissionService; import com.unisbase.spi.UnisBaseTenantProvider; @@ -25,15 +29,20 @@ public class DashboardServiceImpl implements DashboardService { private static final String DASHBOARD_TODO_CARD_VIEW_PERMISSION = "dashboard_todo_card:view"; private static final String DASHBOARD_ACTIVITY_CARD_VIEW_PERMISSION = "dashboard_activity_card:view"; + private static final String DASHBOARD_ANALYTICS_CARD_VIEW_PERMISSION = "dashboard_analytics_card:view"; + private static final String DASHBOARD_STATS_CARD_VIEW_PERMISSION = "dashboard_stats_card:view"; private final DashboardMapper dashboardMapper; + private final DashboardAnalyticsConfigService dashboardAnalyticsConfigService; private final SysPermissionService sysPermissionService; private final UnisBaseTenantProvider tenantProvider; public DashboardServiceImpl(DashboardMapper dashboardMapper, + DashboardAnalyticsConfigService dashboardAnalyticsConfigService, SysPermissionService sysPermissionService, UnisBaseTenantProvider tenantProvider) { this.dashboardMapper = dashboardMapper; + this.dashboardAnalyticsConfigService = dashboardAnalyticsConfigService; this.sysPermissionService = sysPermissionService; this.tenantProvider = tenantProvider; } @@ -56,10 +65,18 @@ public class DashboardServiceImpl implements DashboardService { DashboardStatDTO monthlyChannelStat = dashboardMapper.selectMonthlyChannelStat(userId); addStatIfPresent(stats, monthlyChannelStat); Set permissionCodes = loadPermissionCodes(userId); + boolean statsCardVisible = permissionCodes.contains(DASHBOARD_STATS_CARD_VIEW_PERMISSION); boolean todoCardVisible = permissionCodes.contains(DASHBOARD_TODO_CARD_VIEW_PERMISSION); boolean activityCardVisible = permissionCodes.contains(DASHBOARD_ACTIVITY_CARD_VIEW_PERMISSION); + boolean analyticsCardVisible = permissionCodes.contains(DASHBOARD_ANALYTICS_CARD_VIEW_PERMISSION); + if (!statsCardVisible) { + stats = List.of(); + } List todos = todoCardVisible ? dashboardMapper.selectTodos(userId) : List.of(); List activities = activityCardVisible ? loadLatestActivities(userId) : List.of(); + DashboardAnalyticsPanelDTO analyticsPanel = analyticsCardVisible + ? dashboardAnalyticsConfigService.getDashboardPanel(userId) + : null; enrichActivityTimeText(activities); long onboardingDays = 0; @@ -74,11 +91,14 @@ public class DashboardServiceImpl implements DashboardService { user.getJobTitle(), user.getDeptName(), onboardingDays, + statsCardVisible, todoCardVisible, activityCardVisible, + analyticsCardVisible, stats, todos, - activities + activities, + analyticsPanel ); } @@ -96,6 +116,32 @@ public class DashboardServiceImpl implements DashboardService { } } + @Override + public DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey) { + if (userId == null) { + throw new UnauthorizedException("未获取到当前登录用户,禁止查询他人数据"); + } + Set permissionCodes = loadPermissionCodes(userId); + if (!permissionCodes.contains(DASHBOARD_ANALYTICS_CARD_VIEW_PERMISSION)) { + throw new UnauthorizedException("无权查看经营分析卡片详情"); + } + try { + return dashboardAnalyticsConfigService.getDashboardCardDetail(userId, cardKey); + } catch (Exception exception) { + DashboardAnalyticsPanelDTO panel = dashboardAnalyticsConfigService.getDashboardPanel(userId); + DashboardAnalyticsCardDTO fallbackCard = panel.getCards() == null + ? null + : panel.getCards().stream() + .filter(item -> item != null && cardKey != null && cardKey.equals(item.getCardKey())) + .findFirst() + .orElse(null); + if (fallbackCard != null) { + return fallbackCard; + } + throw exception; + } + } + private Set loadPermissionCodes(Long userId) { try { Long tenantId = tenantProvider.getCurrentTenantId(); diff --git a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java index 4c77759b..50249932 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java @@ -504,7 +504,7 @@ public class ExpansionServiceImpl implements ExpansionService { request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); request.setProvince(normalizeRequiredText(request.getProvince(), "请选择省份")); request.setCity(normalizeRequiredText(request.getCity(), "请选择市")); - request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择认证级别")); + request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择汇智内部认证级别")); request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址")); request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业")); request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年度营业额(万元)")); @@ -533,7 +533,7 @@ public class ExpansionServiceImpl implements ExpansionService { request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); request.setProvince(normalizeRequiredText(request.getProvince(), "请选择省份")); request.setCity(normalizeRequiredText(request.getCity(), "请选择市")); - request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择认证级别")); + request.setCertificationLevel(normalizeRequiredText(request.getCertificationLevel(), "请选择汇智内部认证级别")); request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址")); request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请选择聚焦行业")); request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年度营业额(万元)")); diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index 605f3afc..cac7399b 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -288,7 +288,9 @@ public class OpportunityServiceImpl implements OpportunityService { } if (isBlank(followUp.getNextAction())) { - String nextPlan = extractFollowUpField(followUp.getContent(), "后续规划"); + String nextPlan = firstNonBlank( + extractFollowUpField(followUp.getContent(), "下一步销售计划"), + extractFollowUpField(followUp.getContent(), "后续规划")); if (!isBlank(nextPlan)) { followUp.setNextAction(nextPlan); } @@ -358,9 +360,16 @@ public class OpportunityServiceImpl implements OpportunityService { request.setSource("主动开发"); } request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手")); + request.setLatestProgress(normalizeSnapshotText(request.getLatestProgress())); + request.setNextPlan(normalizeSnapshotText(request.getNextPlan())); validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId()); } + private String normalizeSnapshotText(String value) { + String normalized = normalizeOptionalText(value); + return normalized == null ? "" : normalized; + } + private void ensureOpportunityNotArchived(Long userId, Long opportunityId, String message) { if (Boolean.TRUE.equals(opportunityMapper.selectArchived(userId, opportunityId))) { throw new BusinessException(message); diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index 32c08c20..74db3712 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -75,6 +75,8 @@ public class WorkServiceImpl implements WorkService { private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL); private static final String ONLY_SEE_ROLE_CODE = "only_see"; private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报"; + private static final String LEGACY_NEXT_PLAN_LABEL = "后续规划"; + private static final String OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划"; private static final int EXPORT_LIMIT = 5000; private static final String TENCENT_COORD_TYPE_GPS = "1"; private static final String TENCENT_COORD_TYPE_GCJ02 = "2"; @@ -856,11 +858,19 @@ public class WorkServiceImpl implements WorkService { editorText = buildEditorText(item.getBizType(), objectName, extractLineFieldValues(item)); } Map fieldValues = parseEditorTextFields(editorText); + if ("opportunity".equals(item.getBizType())) { + String nextPlan = firstNonBlank( + fieldValues.get(OPPORTUNITY_NEXT_PLAN_LABEL), + fieldValues.get(LEGACY_NEXT_PLAN_LABEL)); + if (nextPlan != null) { + fieldValues.put(OPPORTUNITY_NEXT_PLAN_LABEL, nextPlan); + } + } item.setEditorText(buildEditorText(item.getBizType(), objectName, fieldValues)); if ("sales".equals(item.getBizType()) || "channel".equals(item.getBizType())) { item.setVisitStartTime(buildReportDateTimeText(item.getWorkDate())); item.setEvaluationContent(fieldValues.get("沟通内容")); - item.setNextPlan(fieldValues.get("后续规划")); + item.setNextPlan(fieldValues.get(LEGACY_NEXT_PLAN_LABEL)); item.setContent(buildExpansionLineContent(item)); return; } @@ -868,7 +878,7 @@ public class WorkServiceImpl implements WorkService { item.setEvaluationContent(null); item.setCommunicationTime(null); item.setCommunicationContent(null); - item.setNextPlan(fieldValues.get("后续规划")); + item.setNextPlan(fieldValues.get(OPPORTUNITY_NEXT_PLAN_LABEL)); item.setContent(buildOpportunityLineContent(item)); } @@ -884,7 +894,7 @@ public class WorkServiceImpl implements WorkService { parts.add("沟通内容:" + communication); } if (nextPlan != null) { - parts.add("后续规划:" + nextPlan); + parts.add(LEGACY_NEXT_PLAN_LABEL + ":" + nextPlan); } return String.join("\n", parts); } @@ -901,7 +911,7 @@ public class WorkServiceImpl implements WorkService { parts.add("项目最新进展:" + latestProgress); } if (nextPlan != null) { - parts.add("后续规划:" + nextPlan); + parts.add(OPPORTUNITY_NEXT_PLAN_LABEL + ":" + nextPlan); } return String.join("\n", parts); } @@ -913,11 +923,11 @@ public class WorkServiceImpl implements WorkService { } if ("sales".equals(item.getBizType()) || "channel".equals(item.getBizType())) { fieldValues.put("沟通内容", normalizeOptionalText(item.getEvaluationContent())); - fieldValues.put("后续规划", normalizeOptionalText(item.getNextPlan())); + fieldValues.put(LEGACY_NEXT_PLAN_LABEL, normalizeOptionalText(item.getNextPlan())); return fieldValues; } fieldValues.put("项目最新进展", normalizeOptionalText(item.getLatestProgress())); - fieldValues.put("后续规划", normalizeOptionalText(item.getNextPlan())); + fieldValues.put(OPPORTUNITY_NEXT_PLAN_LABEL, normalizeOptionalText(item.getNextPlan())); return fieldValues; } @@ -969,9 +979,9 @@ public class WorkServiceImpl implements WorkService { private List getEditorFieldLabels(String bizType) { if ("opportunity".equals(bizType)) { - return List.of("项目最新进展", "后续规划"); + return List.of("项目最新进展", OPPORTUNITY_NEXT_PLAN_LABEL); } - return List.of("沟通内容", "后续规划"); + return List.of("沟通内容", LEGACY_NEXT_PLAN_LABEL); } private String getBizTypeLabel(String bizType) { @@ -1059,13 +1069,17 @@ public class WorkServiceImpl implements WorkService { userId, reportDate, WORK_REPORT_FOLLOW_UP_TYPE); - workMapper.insertLegacyOpportunityFollowUp( + workMapper.insertLegacyOpportunityFollowUp( item.getBizId(), userId, followUpTime, WORK_REPORT_FOLLOW_UP_TYPE, item.getContent(), - resolveOpportunityNextAction(item, request)); + resolveOpportunityNextAction(item)); + workMapper.updateOpportunitySnapshot( + item.getBizId(), + normalizeSnapshotText(item.getLatestProgress()), + normalizeSnapshotText(item.getNextPlan())); continue; } workMapper.deleteDailyReportExpansionFollowUps( @@ -1088,12 +1102,41 @@ public class WorkServiceImpl implements WorkService { } } - private String resolveOpportunityNextAction(WorkReportLineItemRequest item, CreateWorkDailyReportRequest request) { + private String resolveOpportunityNextAction(WorkReportLineItemRequest item) { String nextAction = item == null ? null : normalizeOptionalText(item.getNextPlan()); if (nextAction != null) { return nextAction; } - return request == null ? null : normalizeOptionalText(request.getTomorrowPlan()); + if (item == null) { + return null; + } + return firstNonBlank( + extractSingleLineField(item.getContent(), OPPORTUNITY_NEXT_PLAN_LABEL), + extractSingleLineField(item.getContent(), LEGACY_NEXT_PLAN_LABEL)); + } + + private String extractSingleLineField(String content, String label) { + String normalizedContent = normalizeOptionalText(content); + String normalizedLabel = normalizeOptionalText(label); + if (normalizedContent == null || normalizedLabel == null) { + return null; + } + String prefix = normalizedLabel + ":"; + int startIndex = normalizedContent.indexOf(prefix); + if (startIndex < 0) { + return null; + } + int valueStart = startIndex + prefix.length(); + int lineEnd = normalizedContent.indexOf('\n', valueStart); + String value = lineEnd >= 0 + ? normalizedContent.substring(valueStart, lineEnd) + : normalizedContent.substring(valueStart); + return normalizeOptionalText(value); + } + + private String normalizeSnapshotText(String value) { + String normalized = normalizeOptionalText(value); + return normalized == null ? "" : normalized; } private OffsetDateTime resolveOffsetDateTime(String value) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c8822a6c..074f18ff 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -4,6 +4,8 @@ server: spring: application: name: unis-crm-backend + jackson: + time-zone: Asia/Shanghai servlet: multipart: max-file-size: 20MB @@ -13,6 +15,8 @@ spring: username: postgres password: 199628 driver-class-name: org.postgresql.Driver + hikari: + connection-init-sql: SET TIME ZONE 'Asia/Shanghai' data: redis: host: 127.0.0.1 diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index c483fb5d..026f9654 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -112,7 +112,7 @@ o.pre_sales_id as preSalesId, coalesce(o.pre_sales_name, '') as preSalesName, coalesce(o.competitor_name, '') as competitorName, - coalesce(( + coalesce(o.latest_progress, ( select case when f.content like '项目最新进展:%' then @@ -125,10 +125,12 @@ order by f.followup_time desc, f.id desc limit 1 ), '') as latestProgress, - coalesce(( + coalesce(o.next_plan, ( select case when coalesce(nullif(btrim(f.next_action), ''), '') <> '' then f.next_action + when f.content like '%下一步销售计划:%' then + split_part(split_part(f.content, '下一步销售计划:', 2), E'\n', 1) when f.content like '%后续规划:%' then split_part(split_part(f.content, '后续规划:', 2), E'\n', 1) else '' @@ -137,6 +139,7 @@ where f.opportunity_id = o.id and ( coalesce(nullif(btrim(f.next_action), ''), '') <> '' + or f.content like '%下一步销售计划:%' or f.content like '%后续规划:%' ) order by f.followup_time desc, f.id desc @@ -260,7 +263,7 @@ o.pre_sales_id as preSalesId, coalesce(o.pre_sales_name, '') as preSalesName, coalesce(o.competitor_name, '') as competitorName, - coalesce(( + coalesce(o.latest_progress, ( select case when f.content like '项目最新进展:%' then @@ -273,10 +276,12 @@ order by f.followup_time desc, f.id desc limit 1 ), '') as latestProgress, - coalesce(( + coalesce(o.next_plan, ( select case when coalesce(nullif(btrim(f.next_action), ''), '') <> '' then f.next_action + when f.content like '%下一步销售计划:%' then + split_part(split_part(f.content, '下一步销售计划:', 2), E'\n', 1) when f.content like '%后续规划:%' then split_part(split_part(f.content, '后续规划:', 2), E'\n', 1) else '' @@ -285,6 +290,7 @@ where f.opportunity_id = o.id and ( coalesce(nullif(btrim(f.next_action), ''), '') <> '' + or f.content like '%下一步销售计划:%' or f.content like '%后续规划:%' ) order by f.followup_time desc, f.id desc @@ -405,6 +411,8 @@ sales_expansion_id, channel_expansion_id, competitor_name, + latest_progress, + next_plan, pushed_to_oms, oms_push_time, description, @@ -428,6 +436,8 @@ #{request.salesExpansionId}, #{request.channelExpansionId}, #{request.competitorName}, + #{request.latestProgress}, + #{request.nextPlan}, false, null, #{request.description}, @@ -550,6 +560,8 @@ sales_expansion_id = #{request.salesExpansionId}, channel_expansion_id = #{request.channelExpansionId}, competitor_name = #{request.competitorName}, + latest_progress = #{request.latestProgress}, + next_plan = #{request.nextPlan}, description = #{request.description}, status = case when #{request.stage} = 'won' then 'won' diff --git a/backend/src/main/resources/mapper/work/WorkMapper.xml b/backend/src/main/resources/mapper/work/WorkMapper.xml index a567b32f..6c6b1f80 100644 --- a/backend/src/main/resources/mapper/work/WorkMapper.xml +++ b/backend/src/main/resources/mapper/work/WorkMapper.xml @@ -628,6 +628,14 @@ ) + + update crm_opportunity + set latest_progress = #{latestProgress}, + next_plan = #{nextPlan}, + updated_at = now() + where id = #{opportunityId} + + delete from work_todo where user_id = #{userId} diff --git a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java index 33a44dd1..e9b2c0b6 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/OpportunityServiceImplTest.java @@ -3,6 +3,7 @@ package com.unis.crm.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -112,6 +113,32 @@ class OpportunityServiceImplTest { verify(opportunityMapper).insertCustomer(any(), eq(1L), eq("客户B"), any()); } + @Test + void createOpportunity_shouldPersistEditableSnapshotFields() { + when(opportunityMapper.selectOwnedCustomerIdByName(1L, "客户A")).thenReturn(200L); + when(opportunityMapper.insertOpportunity(eq(1L), eq(200L), any(CreateOpportunityRequest.class))).thenAnswer(invocation -> { + CreateOpportunityRequest request = invocation.getArgument(2); + request.setId(10L); + return 1; + }); + when(opportunityMapper.selectOpportunityOmsPushData(1L, 10L)).thenReturn(opportunityPushData("客户A")); + when(opportunityMapper.selectCurrentUserAccount(1L)).thenReturn(currentUserAccount()); + when(omsClient.ensureUserExists("zhangsan", "张三")).thenReturn(omsUser()); + when(omsClient.createProject(any(OpportunityOmsPushDataDTO.class), eq("99"))).thenReturn("OPP-CODE-002"); + when(opportunityMapper.updateOpportunityCode(1L, 10L, "OPP-CODE-002")).thenReturn(1); + + CreateOpportunityRequest request = updateRequest("客户A"); + request.setLatestProgress(" 已完成需求沟通 "); + request.setNextPlan(" 下周提交报价 "); + + Long result = opportunityService.createOpportunity(1L, request); + + assertEquals(10L, result); + verify(opportunityMapper).insertOpportunity(eq(1L), eq(200L), argThat(payload -> + "已完成需求沟通".equals(payload.getLatestProgress()) + && "下周提交报价".equals(payload.getNextPlan()))); + } + private OpportunityCustomerSnapshotDTO customerSnapshot(Long customerId, String customerName) { OpportunityCustomerSnapshotDTO dto = new OpportunityCustomerSnapshotDTO(); dto.setCustomerId(customerId); diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java index abc3f634..d71b345d 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java @@ -10,10 +10,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.unis.crm.common.BusinessException; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; +import com.unis.crm.dto.work.WorkDailyReportDTO; import com.unis.crm.dto.work.WorkCheckInExportDTO; import com.unis.crm.dto.work.WorkDailyReportExportDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; +import com.unis.crm.dto.work.WorkReportLineItemRequest; +import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest; +import java.time.LocalDate; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; import com.unis.crm.service.ReportReminderService; @@ -25,6 +29,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; + @ExtendWith(MockitoExtension.class) class WorkServiceImplTest { @@ -163,8 +171,8 @@ class WorkServiceImplTest { void exportDailyReports_shouldCleanMetadataAndFilterByBizType() { WorkDailyReportExportDTO opportunityReport = new WorkDailyReportExportDTO(); opportunityReport.setWorkContent(""" - 1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;后续规划:继续推进 - [[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"opportunity","bizId":1,"bizName":"项目A","content":"项目最新进展:推进中\\n后续规划:继续推进","latestProgress":"推进中","nextPlan":"继续推进","editorText":"@商机 项目A\\n# 项目最新进展:推进中\\n# 后续规划:继续推进"}][[/WORK_REPORT_LINES]] + 1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;下一步销售计划:继续推进 + [[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"opportunity","bizId":1,"bizName":"项目A","content":"项目最新进展:推进中\\n下一步销售计划:继续推进","latestProgress":"推进中","nextPlan":"继续推进","editorText":"@商机 项目A\\n# 项目最新进展:推进中\\n# 下一步销售计划:继续推进"}][[/WORK_REPORT_LINES]] """); opportunityReport.setTomorrowPlan(""" 1. 跟进客户 @@ -185,13 +193,58 @@ class WorkServiceImplTest { List result = workService.exportDailyReports(17L, null, null, null, null, "opportunity", null); assertEquals(1, result.size()); - assertEquals("1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;后续规划:继续推进", result.get(0).getWorkContent()); + assertEquals("1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;下一步销售计划:继续推进", result.get(0).getWorkContent()); assertEquals(1, result.get(0).getLineItems().size()); assertEquals("项目A", result.get(0).getLineItems().get(0).getBizName()); assertEquals(1, result.get(0).getPlanItems().size()); assertEquals("跟进客户", result.get(0).getPlanItems().get(0).getContent()); } + @Test + void saveDailyReport_shouldNotUseTomorrowPlanAsOpportunityNextAction() { + CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest(); + WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest(); + lineItem.setWorkDate("2026-04-28"); + lineItem.setBizType("opportunity"); + lineItem.setBizId(101L); + lineItem.setBizName("项目A"); + lineItem.setEditorText(""" + @商机 项目A + # 项目最新进展:推进中 + # 下一步销售计划: + """); + lineItem.setContent("项目最新进展:推进中"); + request.setLineItems(List.of(lineItem)); + + WorkTomorrowPlanItemRequest planItem = new WorkTomorrowPlanItemRequest(); + planItem.setContent("这是整篇日报的明日计划"); + request.setPlanItems(List.of(planItem)); + request.setWorkContent("占位"); + request.setTomorrowPlan("这是整篇日报的明日计划"); + request.setSourceType("manual"); + + WorkDailyReportDTO currentReport = new WorkDailyReportDTO(); + currentReport.setDate("2026-04-28"); + currentReport.setSubmitTime("2026-04-28 10:00"); + + when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any())) + .thenReturn(1); + when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class))) + .thenReturn(null, 501L); + when(workMapper.selectTodayReport(eq(17L), any(LocalDate.class))) + .thenReturn(currentReport); + + workService.saveDailyReport(17L, request); + + verify(workMapper).insertLegacyOpportunityFollowUp( + eq(101L), + eq(17L), + any(), + eq("工作日报"), + eq("项目最新进展:推进中"), + eq(null)); + } + private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) { WorkHistoryItemDTO item = new WorkHistoryItemDTO(); item.setId(id); diff --git a/docs/后台配置SQL驱动的经营分析方案.md b/docs/后台配置SQL驱动的经营分析方案.md new file mode 100644 index 00000000..4eb0f20b --- /dev/null +++ b/docs/后台配置SQL驱动的经营分析方案.md @@ -0,0 +1,756 @@ +# 后台配置 SQL 驱动的经营分析方案 + +## 1. 目标 + +本方案满足你明确提出的目标: + +- 统计定义在后台管理配置 +- H5 端直接按配置展示 +- 取数 SQL 也可以在后台管理配置 +- 后续口径变化时,尽量不改前端代码 +- 同时兼顾 PC 端与 H5 / 企业微信端 + +这套方案的核心思想是: + +- `前端不写死报表` +- `后台管理端维护报表定义` +- `后端读取配置 SQL 执行` +- `H5 / PC 统一走配置渲染` + +## 2. 结论先说 + +这个方案是可行的,但建议不要做“完全无限制的任意 SQL 执行”,而要做: + +- `后台可配置 SQL` +- `后端受控执行 SQL` +- `带参数模板` +- `带权限注入` +- `带版本管理` +- `带审核发布` + +也就是说,不是直接把数据库裸露给后台管理员,而是做一个“`可配置 SQL 报表平台`”。 + +## 3. 适合你的整体架构 + +建议拆成 4 个部分: + +### 3.1 报表定义后台 + +给管理员使用,用于配置: + +- 报表名称 +- 适用端 +- 筛选条件 +- 图表类型 +- 指标字段 +- 维度字段 +- SQL 模板 +- 参数说明 +- 发布版本 + +### 3.2 报表执行引擎 + +后端新增一个报表引擎服务,职责是: + +- 读取报表配置 +- 校验参数 +- 替换 SQL 模板变量 +- 自动追加权限条件 +- 执行 SQL +- 返回统一结果结构 + +### 3.3 H5 / PC 动态渲染层 + +前端不再写死每个图表,而是: + +- 先请求报表页面配置 +- 再请求每个组件的数据 +- 根据配置渲染 KPI、柱状图、折线图、饼图、表格 + +### 3.4 配置治理层 + +用于保证 SQL 配置安全可控: + +- 草稿/已发布版本 +- 审核流 +- 执行日志 +- 回滚版本 +- 性能限制 + +## 4. 最推荐的实现模式 + +你要的是“SQL 可配置”,这里推荐两个级别。 + +### 4.1 级别一:SQL 模板可配置 + +这是最推荐的。 + +后台配置的不是完全自由 SQL,而是带占位符的模板 SQL,例如: + +```sql +select + owner_user_id as ownerUserId, + sum(amount) as actualAmount +from crm_opportunity +where status = 'won' + and won_at between ${startDate} and ${endDate} + ${dataScopeClause} + ${orgFilterClause} +group by owner_user_id +order by actualAmount desc +``` + +特点: + +- 可配灵活 +- 后端可控 +- 容易注入权限 +- 易于参数校验 + +### 4.2 级别二:完全自由 SQL + +不建议默认开放。 + +如果一定要支持,也建议: + +- 仅超级管理员可用 +- 仅允许 `select` +- 禁止 `update/delete/insert/drop` +- 禁止多语句 +- 禁止访问敏感表 +- 强制走只读数据源 +- 强制超时和行数限制 + +## 5. 业务上如何满足“销售完成业绩”和“区域业绩” + +这两个场景很适合做成后台配置化报表。 + +### 5.1 销售完成业绩 + +建议报表结构: + +- KPI: + - 销售目标 + - 实际完成 + - 完成率 + - 同比/环比 +- 图表: + - 销售排行 + - 趋势图 + - 部门分布 +- 明细: + - 销售员、目标值、完成值、完成率、区域、趋势 + +### 5.2 区域业绩 + +建议报表结构: + +- KPI: + - 区域目标 + - 区域实际 + - 区域完成率 +- 图表: + - 区域排名 + - 区域趋势 + - 区域构成 +- 明细: + - 大区、省区、城市、销售数、目标、实际、完成率 + +这两类都可以通过后台 SQL 配置实现。 + +## 6. 推荐的数据模型 + +建议新增以下表。 + +### 6.1 `analytics_report` + +存报表主定义。 + +建议字段: + +- `id` +- `report_code` +- `report_name` +- `report_category` +- `description` +- `status` +- `terminal_scope` +- `default_time_granularity` +- `visible` +- `created_by` +- `updated_by` +- `created_at` +- `updated_at` + +说明: + +- `terminal_scope` 用于区分 `pc / h5 / both` + +### 6.2 `analytics_report_version` + +存报表版本。 + +建议字段: + +- `id` +- `report_id` +- `version_no` +- `version_status` +- `change_log` +- `published_by` +- `published_at` +- `config_json` +- `created_at` + +说明: + +- 一个报表允许多个版本 +- H5 默认读已发布版本 + +### 6.3 `analytics_widget` + +存一个报表下的组件定义。 + +建议字段: + +- `id` +- `report_version_id` +- `widget_code` +- `widget_name` +- `widget_type` +- `title` +- `subtitle` +- `layout_json` +- `props_json` +- `data_source_id` +- `sort_order` +- `visible` + +### 6.4 `analytics_data_source` + +存数据源定义,也就是 SQL 配置核心。 + +建议字段: + +- `id` +- `source_code` +- `source_name` +- `source_type` +- `dataset_code` +- `query_sql` +- `count_sql` +- `parameter_schema_json` +- `result_schema_json` +- `timeout_ms` +- `max_rows` +- `cache_ttl_seconds` +- `enabled` +- `created_at` +- `updated_at` + +### 6.5 `analytics_filter_def` + +存筛选器定义。 + +建议字段: + +- `id` +- `report_version_id` +- `filter_code` +- `filter_name` +- `filter_type` +- `data_type` +- `default_value_json` +- `options_source_type` +- `options_source_config` +- `sort_order` +- `visible` + +### 6.6 `analytics_sql_publish_log` + +存 SQL 发布日志。 + +建议字段: + +- `id` +- `data_source_id` +- `version_no` +- `old_sql` +- `new_sql` +- `change_summary` +- `operator_id` +- `created_at` + +### 6.7 `analytics_query_log` + +存执行日志。 + +建议字段: + +- `id` +- `report_code` +- `widget_code` +- `data_source_id` +- `request_user_id` +- `request_params_json` +- `final_sql_preview` +- `duration_ms` +- `row_count` +- `success` +- `error_message` +- `created_at` + +## 7. 配置中心怎么设计 + +后台建议做成 5 个页面。 + +### 7.1 报表管理 + +配置: + +- 报表基本信息 +- 展示端 +- 菜单归属 +- 是否发布 + +### 7.2 页面布局管理 + +配置: + +- KPI 卡片 +- 图表组件 +- 表格组件 +- H5 是否显示 +- PC 是否显示 +- 顺序与显隐 + +### 7.3 SQL 数据源管理 + +配置: + +- SQL 模板 +- 参数定义 +- 字段映射 +- 结果预览 +- 缓存策略 +- 执行限制 + +### 7.4 筛选器管理 + +配置: + +- 时间筛选 +- 区域筛选 +- 销售筛选 +- 部门筛选 +- 自定义枚举 + +### 7.5 发布与回滚 + +配置: + +- 草稿 +- 预览 +- 发布 +- 回滚历史版本 + +## 8. SQL 配置方式建议 + +这是关键设计点。 + +### 8.1 SQL 模板结构 + +建议使用模板 SQL,不要直接存“拼好的最终 SQL”。 + +示例: + +```sql +select + region_code as regionCode, + region_name as regionName, + sum(actual_amount) as actualAmount, + sum(target_amount) as targetAmount, + case when sum(target_amount) = 0 then 0 + else round(sum(actual_amount) / sum(target_amount) * 100, 2) + end as completionRate +from analytics_sales_performance_fact +where stat_date between ${startDate} and ${endDate} + ${orgFilterClause} + ${regionFilterClause} + ${salesFilterClause} + ${dataScopeClause} +group by region_code, region_name +order by actualAmount desc +limit ${limit} +``` + +### 8.2 参数定义 + +每条 SQL 必须配参数定义。 + +示例: + +```json +[ + { "name": "startDate", "type": "date", "required": true }, + { "name": "endDate", "type": "date", "required": true }, + { "name": "orgId", "type": "long", "required": false }, + { "name": "regionCode", "type": "string", "required": false }, + { "name": "salesUserId", "type": "long", "required": false }, + { "name": "limit", "type": "int", "required": false, "defaultValue": 50 } +] +``` + +### 8.3 建议支持的占位符 + +后端统一支持如下占位符: + +- `${startDate}` +- `${endDate}` +- `${limit}` +- `${offset}` +- `${dataScopeClause}` +- `${orgFilterClause}` +- `${regionFilterClause}` +- `${salesFilterClause}` + +### 8.4 动态条件注入方式 + +例如 `regionCode` 为空时,不拼条件; +有值时自动生成: + +```sql +and region_code = :regionCode +``` + +不建议把这种逻辑写死在前端。 + +## 9. 前端展示怎么实现 + +虽然 SQL 在后台配置,但前端还是要有统一协议。 + +### 9.1 H5 与 PC 共用配置协议 + +前端请求: + +- 页面定义 +- 筛选器定义 +- Widget 定义 +- Widget 数据 + +### 9.2 Widget 类型建议 + +支持以下组件: + +- `kpi` +- `line` +- `bar` +- `stack-bar` +- `pie` +- `table` +- `rank-list` + +### 9.3 H5 展示规则 + +建议在配置里加上: + +- `showOnPc` +- `showOnH5` +- `h5SortOrder` +- `pcSortOrder` +- `h5ChartType` +- `pcChartType` + +这样同一个报表可以: + +- PC 显示表格 + 图表 +- H5 只显示 KPI + Top10 + +### 9.4 H5 页面渲染流程 + +1. 获取报表定义 +2. 获取筛选项定义 +3. 渲染页面骨架 +4. 请求组件数据 +5. 渲染图表 + +## 10. 后端执行引擎设计 + +建议新增一个 `AnalyticsQueryService`。 + +职责: + +- 加载已发布报表版本 +- 解析筛选条件 +- 校验参数类型 +- 生成最终 SQL +- 注入权限条件 +- 执行查询 +- 做缓存 +- 记录日志 + +### 10.1 执行流程 + +1. 读取报表配置 +2. 读取组件配置 +3. 读取数据源 SQL 模板 +4. 根据请求参数构造过滤条件 +5. 自动注入数据权限 +6. 生成最终 SQL +7. 执行查询 +8. 转成统一结果结构 + +### 10.2 统一返回结构 + +建议: + +```json +{ + "reportCode": "sales-performance", + "title": "销售完成业绩", + "filters": [], + "widgets": [ + { + "widgetCode": "sales-rank", + "widgetType": "bar", + "title": "销售业绩排名", + "columns": ["ownerName", "actualAmount"], + "rows": [] + } + ] +} +``` + +## 11. 权限与安全控制 + +这是这个方案能不能上线的关键。 + +### 11.1 禁止直接执行危险 SQL + +必须限制: + +- 只允许 `select` +- 禁止 `insert` +- 禁止 `update` +- 禁止 `delete` +- 禁止 `drop` +- 禁止 `alter` +- 禁止多语句执行 + +### 11.2 必须强制数据权限注入 + +即使后台配置 SQL,也不能信任配置人手动写权限条件。 + +必须由后端统一追加: + +- 当前租户过滤 +- 当前用户数据范围 +- 组织权限范围 + +### 11.3 建议使用只读数据源 + +报表执行引擎单独使用只读数据库账号。 + +### 11.4 超时与数据量限制 + +每条数据源定义都要支持: + +- 最大执行时长 +- 最大返回行数 +- 最大导出行数 + +### 11.5 SQL 审核 + +建议发布前做 SQL 检查: + +- 关键词校验 +- 表白名单校验 +- 字段白名单校验 +- explain 成本校验 + +## 12. 针对“销售完成业绩”和“区域业绩”的配置方式 + +### 12.1 销售完成业绩推荐做法 + +建议先沉淀一个业绩事实层表或视图: + +- `analytics_sales_performance_fact` + +字段建议: + +- `stat_date` +- `sales_user_id` +- `sales_user_name` +- `org_id` +- `org_name` +- `region_code` +- `region_name` +- `target_amount` +- `actual_amount` +- `won_amount` +- `contract_amount` +- `payment_amount` +- `adjust_amount` + +好处: + +- 前端怎么展示都行 +- 后台 SQL 简单很多 +- 口径变更时只改事实层生成逻辑或切换版本 + +### 12.2 区域业绩推荐做法 + +建议同样通过事实层统一: + +- 客户区域 +- 项目区域 +- 销售归属区域 + +如果口径会改,可以做多个字段: + +- `customer_region_code` +- `project_region_code` +- `owner_region_code` + +后台 SQL 通过配置选择用哪个字段聚合。 + +## 13. 推荐的“SQL 可配置”分层 + +为了避免后期越来越乱,建议把 SQL 分成两层。 + +### 13.1 第一层:事实层 SQL + +由研发维护。 + +职责: + +- 把复杂业务逻辑沉淀成事实表或视图 +- 处理复杂口径、去重、归属、调整项 + +### 13.2 第二层:展示层 SQL + +由后台管理配置。 + +职责: + +- 从事实层表/视图做汇总 +- 做 KPI、排行、趋势、区域分组 + +这是最适合你的方案。 + +原因: + +- 真正复杂的口径不适合完全交给后台人员写 +- 但展示层统计完全可以后台配置 +- H5 端可以完全动态渲染 + +## 14. 如果你坚持“所有 SQL 都后台可配” + +也可以做,但我建议加 3 个保护措施。 + +### 14.1 只允许访问白名单对象 + +例如只允许查: + +- `analytics_*` +- `crm_*` +- `work_*` +- 部分 `sys_*` 维表 + +### 14.2 强制 SQL 发布审核 + +流程建议: + +- 编辑草稿 +- 测试预览 +- 提交审核 +- 审核通过后发布 + +### 14.3 强制版本切换 + +H5 / PC 永远只读“已发布版本”,不读草稿。 + +## 15. API 设计建议 + +### 15.1 后台管理端 + +- `GET /api/admin/analytics/reports` +- `POST /api/admin/analytics/reports` +- `PUT /api/admin/analytics/reports/{id}` +- `GET /api/admin/analytics/reports/{id}/versions` +- `POST /api/admin/analytics/data-sources` +- `PUT /api/admin/analytics/data-sources/{id}` +- `POST /api/admin/analytics/data-sources/{id}/preview` +- `POST /api/admin/analytics/reports/{id}/publish` + +### 15.2 前台 H5 / PC + +- `GET /api/analytics/reports/{reportCode}` +- `POST /api/analytics/reports/{reportCode}/query` + +## 16. 实施步骤建议 + +### 第一阶段 + +目标: + +- 先把基础平台搭起来 + +内容: + +- 报表定义表 +- 数据源定义表 +- Widget 定义表 +- H5 动态渲染页 +- PC 动态渲染页 +- SQL 安全校验 + +### 第二阶段 + +目标: + +- 上线销售完成业绩、区域业绩 + +内容: + +- 销售业绩事实表 +- 区域业绩事实表 +- 报表管理页 +- SQL 配置预览 +- 发布与回滚 + +### 第三阶段 + +目标: + +- 真正进入“后台可运营” + +内容: + +- 版本对比 +- 审核流 +- 执行监控 +- 性能报警 +- 缓存策略 + +## 17. 最终建议 + +如果你想实现: + +- 后台管理定义 +- H5 自动展示 +- SQL 可配置 +- 后期口径频繁修改 + +那么我最推荐的是: + +1. 做一个 `报表定义 + SQL 数据源 + Widget 配置` 的后台管理平台 +2. H5 与 PC 统一走动态渲染 +3. SQL 采用“模板化 + 参数化 + 权限注入 + 发布审核” +4. 真正复杂的业绩口径优先沉淀为事实层视图或事实表 +5. 展示层 SQL 再交给后台配置 + +一句话总结: + +`这个方案完全可以做,而且很适合你当前“口径复杂、变化频繁、H5要快速展示”的场景;但最佳实践不是裸放任意 SQL,而是做受控的后台 SQL 配置平台。` + diff --git a/docs/数据结构.md b/docs/数据结构.md index 6cafec3e..5d402000 100644 --- a/docs/数据结构.md +++ b/docs/数据结构.md @@ -222,6 +222,8 @@ erDiagram | `product_type` | varchar(100) | 产品类型 | | `source` | varchar(50) | 商机来源 | | `competitor_name` | varchar(200) | 竞品名称 | +| `latest_progress` | text | 项目最新进展 | +| `next_plan` | text | 下一步销售计划 | | `archived` | boolean | 是否归档 | | `pushed_to_oms` | boolean | 是否已推送 OMS | | `oms_push_time` | timestamptz | 推送时间 | @@ -341,7 +343,7 @@ erDiagram | `channel_name` | varchar(200) | 渠道名称 | | `office_address` | varchar(255) | 办公地址 | | `channel_industry` | varchar(100) | 聚焦行业 | -| `certification_level` | varchar(100) | 认证级别 | +| `certification_level` | varchar(100) | 汇智内部认证级别 | | `annual_revenue` | numeric(18,2) | 年营收 | | `staff_size` | integer | 人员规模 | | `contact_established_date` | date | 建立联系日期 | @@ -655,4 +657,3 @@ erDiagram - `docs/system-construction.md` - `docs/technical-system-planning.md` - `docs/deployment-guide.md` - diff --git a/docs/首页相关能力SQL发布说明.md b/docs/首页相关能力SQL发布说明.md new file mode 100644 index 00000000..1ff69d9a --- /dev/null +++ b/docs/首页相关能力SQL发布说明.md @@ -0,0 +1,207 @@ +# 首页相关能力 SQL 发布说明 + +本文档用于指导已有生产环境执行本次首页相关能力升级 SQL。 + +对应正式脚本: + +- `sql/upgrade_dashboard_analytics_prod_pg17.sql` + +## 1. 适用范围 + +适用于以下场景: + +- 已存在业务数据的老环境 +- 需要补齐首页经营分析配置能力 +- 需要补齐首页卡片权限 +- 需要补齐商机快照字段 + +不适用于以下场景: + +- 全新初始化数据库 + +全新环境请直接执行: + +```bash +psql -d your_database -f sql/init_full_pg17.sql +``` + +## 2. 本次变更内容 + +主升级脚本 `sql/upgrade_dashboard_analytics_prod_pg17.sql` 会补齐: + +- 新建 `dashboard_analytics_panel_config` +- 新建 `dashboard_analytics_card_config` +- 补齐首页权限分组与卡片权限: + - `menu:frontend-home` + - `dashboard_todo_card:view` + - `dashboard_activity_card:view` + - `dashboard_stats_card:view` + - `dashboard_analytics_card:view` + - `menu:dashboard-analytics-settings` + - `dashboard_analytics_config:view` + - `dashboard_analytics_config:update` + - `dashboard_analytics_config:preview` +- 给 `crm_opportunity` 增加字段: + - `latest_progress` + - `next_plan` +- 修正 `crm_channel_expansion.certification_level` 字段注释为“汇智内部认证级别” +- 修复平台菜单权限串租户问题,并补齐 `system` 父目录权限 + +这份脚本不会做的事情: + +- 不插入默认经营分析业务卡片 +- 不创建目标值业务表 +- 不导入业务数据 + +## 3. 上线执行顺序 + +### 3.1 执行前准备 + +建议先完成以下检查: + +- 确认当前数据库为 PostgreSQL 17 兼容环境 +- 确认业务高峰期外执行 +- 备份本次涉及表的结构和关键权限数据: + - `crm_opportunity` + - `crm_channel_expansion` + - `sys_permission` + - `sys_role_permission` + - `dashboard_analytics_panel_config` + - `dashboard_analytics_card_config` + +建议至少保留一份备份: + +```bash +pg_dump -d your_database -t crm_opportunity -t crm_channel_expansion -t sys_permission -t sys_role_permission > pre_dashboard_upgrade.sql +``` + +如果环境里还没有经营分析表,`pg_dump` 忽略这两张新表即可。 + +### 3.2 正常执行顺序 + +生产环境标准执行顺序如下: + +1. 执行正式升级脚本 + +```bash +psql -d your_database -f sql/upgrade_dashboard_analytics_prod_pg17.sql +``` + +2. 执行完成后做结构校验 + +```sql +select column_name +from information_schema.columns +where table_name = 'crm_opportunity' + and column_name in ('latest_progress', 'next_plan'); +``` + +```sql +select table_name +from information_schema.tables +where table_name in ('dashboard_analytics_panel_config', 'dashboard_analytics_card_config'); +``` + +3. 做权限校验 + +```sql +select code, name, is_deleted +from sys_permission +where code in ( + 'menu:frontend-home', + 'dashboard_todo_card:view', + 'dashboard_activity_card:view', + 'dashboard_stats_card:view', + 'dashboard_analytics_card:view', + 'menu:dashboard-analytics-settings', + 'dashboard_analytics_config:view', + 'dashboard_analytics_config:update', + 'dashboard_analytics_config:preview' +) +order by code, perm_id; +``` + +4. 做页面冒烟验证 + +- 首页正常打开 +- 待办卡片正常显示 +- 最新动态卡片正常显示 +- 指标卡正常显示 +- 有权限账号能进入“首页经营分析配置”页面 +- 商机新增/编辑页面可正常保存“项目最新进展”“下一步销售计划” + +## 4. 验收重点 + +上线后建议重点验收以下内容: + +- `crm_opportunity` 能正常读写 `latest_progress`、`next_plan` +- 老商机详情能正常打开,不因新字段为空报错 +- 首页待办、动态、指标、经营分析卡片权限符合预期 +- 管理员角色拥有首页经营分析配置菜单与按钮权限 +- 普通租户角色不会误拿平台菜单权限 +- 经营分析配置页能打开,但若未配置卡片,页面为空属于预期现象 + +## 5. 风险说明 + +### 5.1 权限可见性变化风险 + +本次脚本会创建、修复并去重首页相关权限,同时会给管理员类角色补权限。 + +风险点: + +- 某些历史脏数据环境中,原来依赖重复权限记录的角色可见性可能发生变化 +- 若角色本身不属于管理员类,脚本不会自动补齐首页经营分析配置权限 + +建议: + +- 上线后用管理员账号和普通业务账号各验证一次首页与系统管理菜单 + +### 5.2 经营分析页“空白”风险 + +本次只补配置表和权限,不会写默认卡片配置。 + +风险点: + +- 脚本执行成功后,经营分析区域可能为空 + +这不是故障,表示表结构已就绪,但后台尚未配置业务卡片。 + +### 5.3 商机字段新增后的数据回填预期 + +`latest_progress` 和 `next_plan` 为新增字段,历史数据不会被批量回填。 + +风险点: + +- 旧商机初次查看时,这两个字段可能为空 + +当前代码已经保留从历史跟进记录兜底读取的兼容逻辑,因此一般不会影响旧数据查看。 + +### 5.4 平台菜单权限纠偏风险 + +本次正式升级脚本已包含平台菜单权限纠偏逻辑。 + +风险点: + +- 若某些环境历史上错误发放了平台菜单权限,执行后这些错误权限会被收回 + +建议: + +- 执行前备份 `sys_role_permission` +- 执行后重点验证租户管理员、平台管理员两类账号的菜单可见性 + +## 6. 回滚建议 + +本次脚本以“新增字段、建表、权限修复”为主,不建议直接无审查回滚。 + +建议回滚策略: + +1. 若只是经营分析权限异常,优先人工修复 `sys_permission` / `sys_role_permission` +2. 若只是经营分析配置有误,清理 `dashboard_analytics_panel_config` / `dashboard_analytics_card_config` 即可 +3. 若必须整体回退,请基于执行前备份恢复,不建议直接手写反向 SQL 在线回滚 + +## 7. 推荐发布口径 + +可向实施或运维同事同步以下结论: + +- 本次生产升级主入口只有一份:`sql/upgrade_dashboard_analytics_prod_pg17.sql` +- 旧的重复升级脚本已删除,不需要再单独执行首页卡片权限历史脚本 diff --git a/frontend/node_modules/.package-lock.json b/frontend/node_modules/.package-lock.json index c8973f54..94615da1 100644 --- a/frontend/node_modules/.package-lock.json +++ b/frontend/node_modules/.package-lock.json @@ -908,6 +908,42 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1263,6 +1299,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -1593,6 +1641,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1701,6 +1812,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -2356,6 +2473,127 @@ "node": ">= 8" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2398,6 +2636,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2585,6 +2829,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.0.tgz", + "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -2651,6 +2905,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz", @@ -3266,6 +3526,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", @@ -3283,6 +3553,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4377,6 +4656,36 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4472,6 +4781,57 @@ "node": ">=10" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4948,6 +5308,12 @@ "node": ">=6" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5137,6 +5503,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5170,6 +5545,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/frontend/node_modules/.vite/deps/_metadata.json b/frontend/node_modules/.vite/deps/_metadata.json index 09e0dbea..06d80720 100644 --- a/frontend/node_modules/.vite/deps/_metadata.json +++ b/frontend/node_modules/.vite/deps/_metadata.json @@ -1,91 +1,100 @@ { - "hash": "4ed97220", + "hash": "11a137f3", "configHash": "4d48f89c", - "lockfileHash": "25980767", - "browserHash": "0ac37406", + "lockfileHash": "3435c45d", + "browserHash": "01835e8f", "optimized": { "react": { "src": "../../react/index.js", "file": "react.js", - "fileHash": "5499e597", + "fileHash": "2fe36264", "needsInterop": true }, "react-dom": { "src": "../../react-dom/index.js", "file": "react-dom.js", - "fileHash": "ee512445", + "fileHash": "3e554bb3", "needsInterop": true }, "react/jsx-dev-runtime": { "src": "../../react/jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js", - "fileHash": "b416d357", + "fileHash": "640ca040", "needsInterop": true }, "react/jsx-runtime": { "src": "../../react/jsx-runtime.js", "file": "react_jsx-runtime.js", - "fileHash": "d2d6e3f0", + "fileHash": "e2fb61c6", "needsInterop": true }, "clsx": { "src": "../../clsx/dist/clsx.mjs", "file": "clsx.js", - "fileHash": "48844b26", + "fileHash": "36d60f13", "needsInterop": false }, "date-fns": { "src": "../../date-fns/index.js", "file": "date-fns.js", - "fileHash": "e0776950", + "fileHash": "204ad576", "needsInterop": false }, "lucide-react": { "src": "../../lucide-react/dist/esm/lucide-react.js", "file": "lucide-react.js", - "fileHash": "2b6331af", + "fileHash": "13822394", "needsInterop": false }, "motion/react": { "src": "../../motion/dist/es/react.mjs", "file": "motion_react.js", - "fileHash": "6ad27c2c", + "fileHash": "baa20c1b", "needsInterop": false }, "react-dom/client": { "src": "../../react-dom/client.js", "file": "react-dom_client.js", - "fileHash": "105dfe43", + "fileHash": "49e567cb", "needsInterop": true }, "react-router-dom": { "src": "../../react-router-dom/dist/index.mjs", "file": "react-router-dom.js", - "fileHash": "8926882a", + "fileHash": "92bb81d7", "needsInterop": false }, "tailwind-merge": { "src": "../../tailwind-merge/dist/bundle-mjs.mjs", "file": "tailwind-merge.js", - "fileHash": "2c815557", + "fileHash": "196a6373", "needsInterop": false }, "date-fns/locale": { "src": "../../date-fns/locale.js", "file": "date-fns_locale.js", - "fileHash": "00bef214", + "fileHash": "c028f096", "needsInterop": false }, "exceljs": { "src": "../../exceljs/dist/exceljs.min.js", "file": "exceljs.js", - "fileHash": "7f44c50c", + "fileHash": "4f4c428f", "needsInterop": true + }, + "recharts": { + "src": "../../recharts/es6/index.js", + "file": "recharts.js", + "fileHash": "05557fbc", + "needsInterop": false } }, "chunks": { - "chunk-7VK2ROTQ": { - "file": "chunk-7VK2ROTQ.js" + "chunk-ZFXKT4LN": { + "file": "chunk-ZFXKT4LN.js" + }, + "chunk-U7P2NEEE": { + "file": "chunk-U7P2NEEE.js" }, "chunk-5MXL5BYH": { "file": "chunk-5MXL5BYH.js" diff --git a/frontend/node_modules/.vite/deps/clsx.js b/frontend/node_modules/.vite/deps/clsx.js index 46d9addf..0578dd7d 100644 --- a/frontend/node_modules/.vite/deps/clsx.js +++ b/frontend/node_modules/.vite/deps/clsx.js @@ -1,22 +1,9 @@ +import { + clsx, + clsx_default +} from "./chunk-U7P2NEEE.js"; import "./chunk-2TUXWMP5.js"; - -// node_modules/clsx/dist/clsx.mjs -function r(e) { - var t, f, n = ""; - if ("string" == typeof e || "number" == typeof e) n += e; - else if ("object" == typeof e) if (Array.isArray(e)) { - var o = e.length; - for (t = 0; t < o; t++) e[t] && (f = r(e[t])) && (n && (n += " "), n += f); - } else for (f in e) e[f] && (n && (n += " "), n += f); - return n; -} -function clsx() { - for (var e, t, f = 0, n = "", o = arguments.length; f < o; f++) (e = arguments[f]) && (t = r(e)) && (n && (n += " "), n += t); - return n; -} -var clsx_default = clsx; export { clsx, clsx_default as default }; -//# sourceMappingURL=clsx.js.map diff --git a/frontend/node_modules/.vite/deps/clsx.js.map b/frontend/node_modules/.vite/deps/clsx.js.map index f1e7f4db..98652118 100644 --- a/frontend/node_modules/.vite/deps/clsx.js.map +++ b/frontend/node_modules/.vite/deps/clsx.js.map @@ -1,7 +1,7 @@ { "version": 3, - "sources": ["../../clsx/dist/clsx.mjs"], - "sourcesContent": ["function r(e){var t,f,n=\"\";if(\"string\"==typeof e||\"number\"==typeof e)n+=e;else if(\"object\"==typeof e)if(Array.isArray(e)){var o=e.length;for(t=0;t= 8" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2368,6 +2607,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2555,6 +2800,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.0.tgz", + "integrity": "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -2621,6 +2876,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz", @@ -3236,6 +3497,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", @@ -3253,6 +3524,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4337,6 +4617,36 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4432,6 +4742,57 @@ "node": ">=10" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4908,6 +5269,12 @@ "node": ">=6" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5097,6 +5464,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5130,6 +5506,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index cb8473d9..bee35f13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", "vite": "^6.2.0" }, diff --git a/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx b/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx new file mode 100644 index 00000000..2d76460f --- /dev/null +++ b/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx @@ -0,0 +1,564 @@ +import { useId, useMemo } from "react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Funnel, + FunnelChart, + LabelList, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { DashboardAnalyticsCard } from "@/lib/auth"; +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; + +type ChartPoint = NonNullable[number]; +type DisplayTextConfig = Partial<{ + emptyText: string; + unnamedLabel: string; + peakPrefix: string; + ratioPrefix: string; + sharePrefix: string; + centerLabel: string; + tableLabelHeader: string; + tableValueHeader: string; + categoryOptions: Array<{ + label?: string; + color?: string; + description?: string; + }>; +}>; +type NormalizedPoint = { + label: string; + value: number; + rawValue: string; + valueText: string; + secondaryValueText?: string; + description?: string; + color: string; + initials: string; +}; + +const CHART_COLORS = ["#2563eb", "#7c3aed", "#db2777", "#f59e0b", "#10b981", "#0ea5e9", "#f97316", "#8b5cf6"]; +const RANKING_BADGE_CLASSES = [ + "bg-blue-100 text-blue-600", + "bg-purple-100 text-purple-600", + "bg-rose-100 text-rose-600", + "bg-emerald-100 text-emerald-600", + "bg-amber-100 text-amber-600", +]; + +function toNumber(value?: string) { + const parsed = Number(value ?? ""); + return Number.isFinite(parsed) ? parsed : 0; +} + +function parseDisplayTextConfig(raw?: string): DisplayTextConfig { + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed as DisplayTextConfig : {}; + } catch { + return {}; + } +} + +function formatPercent(ratio: number) { + const percent = ratio * 100; + return `${percent.toFixed(percent >= 10 ? 1 : 2).replace(/\.0+$/, "")}%`; +} + +function formatRatioLabel(ratio: number, prefix: string) { + return `${prefix} ${formatPercent(ratio)}`; +} + +function getSignedNumber(text?: string) { + if (!text) { + return Number.NaN; + } + const match = text.match(/[+-]?\d+(?:\.\d+)?/); + return match ? Number.parseFloat(match[0]) : Number.NaN; +} + +function getPercentTone(text?: string) { + if (!text?.includes("%")) { + return "default" as const; + } + const value = getSignedNumber(text); + if (!Number.isFinite(value) || value === 0) { + return "neutral" as const; + } + return value > 0 ? "positive" as const : "negative" as const; +} + +function getPercentToneFromRatio(ratio: number) { + if (!Number.isFinite(ratio) || ratio === 0) { + return "neutral" as const; + } + return ratio > 0 ? "positive" as const : "negative" as const; +} + +function getPercentTextClass(text?: string, fallback = "text-slate-900") { + const tone = getPercentTone(text); + if (tone === "positive") { + return "text-emerald-500"; + } + if (tone === "negative") { + return "text-rose-500"; + } + if (tone === "neutral") { + return "text-slate-400"; + } + return fallback; +} + +function getPercentTextClassFromRatio(ratio: number, fallback = "text-slate-400") { + const tone = getPercentToneFromRatio(ratio); + if (tone === "positive") { + return "text-emerald-500"; + } + if (tone === "negative") { + return "text-rose-500"; + } + if (tone === "neutral") { + return "text-slate-400"; + } + return fallback; +} + +function getPercentFillColor(text?: string, fallback = "#0f172a") { + const tone = getPercentTone(text); + if (tone === "positive") { + return "#10b981"; + } + if (tone === "negative") { + return "#f43f5e"; + } + if (tone === "neutral") { + return "#94a3b8"; + } + return fallback; +} + +function formatRankingSecondaryLabel( + secondaryValueText: string | undefined, + ratio: number, + prefix: string | undefined, +) { + const normalizedPrefix = prefix?.trim(); + if (secondaryValueText) { + return normalizedPrefix ? `${normalizedPrefix} ${secondaryValueText}` : secondaryValueText; + } + return formatRatioLabel(ratio, normalizedPrefix || "占首位"); +} + +function getInitials(label: string) { + const trimmed = label.trim(); + if (!trimmed) { + return "--"; + } + if (/[\u4e00-\u9fa5]/.test(trimmed)) { + return trimmed.slice(0, 1); + } + const parts = trimmed.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return trimmed.slice(0, 2).toUpperCase(); +} + +function formatAxisValue(value: number) { + const absolute = Math.abs(value); + if (absolute >= 1000000) { + return `${(value / 1000000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (absolute >= 10000) { + return `${(value / 10000).toFixed(1).replace(/\.0$/, "")}w`; + } + if (absolute >= 1000) { + return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return `${value}`; +} + +function getChartHeightClass(renderType: DashboardAnalyticsCard["renderType"], mobile: boolean, expanded?: boolean) { + if (renderType === "funnel") { + return expanded ? "h-[340px]" : mobile ? "h-[260px]" : "h-[300px]"; + } + if (renderType === "pie" || renderType === "ring") { + return expanded ? "h-[340px]" : mobile ? "h-[300px]" : "h-[320px]"; + } + return expanded ? "h-[320px]" : mobile ? "h-[240px]" : "h-64"; +} + +function normalizePoints(points: ChartPoint[], texts: DisplayTextConfig, allowTextOnly?: boolean) { + const categoryOptionMap = new Map( + (texts.categoryOptions ?? []) + .filter((item) => Boolean(item?.label?.trim())) + .map((item) => [item.label!.trim(), item]) + ); + + return points + .map((point, index) => { + const label = point.label?.trim() || texts.unnamedLabel || "未命名"; + const option = categoryOptionMap.get(label); + return { + label, + value: toNumber(point.value), + rawValue: point.value || "0", + valueText: point.valueText || point.value || "0", + secondaryValueText: point.secondaryValueText || point.secondaryValue || undefined, + description: point.description || option?.description || undefined, + color: point.color || option?.color || CHART_COLORS[index % CHART_COLORS.length], + initials: getInitials(label), + } satisfies NormalizedPoint; + }) + .filter((point) => allowTextOnly || point.value >= 0); +} + +function EmptyState({ text }: { text?: string }) { + return ( +
+ {text || "暂无图表数据"} +
+ ); +} + +function AnalyticsTooltip({ + active, + payload, + prefix, + mode, + total, +}: { + active?: boolean; + payload?: Array<{ payload?: NormalizedPoint }>; + prefix: string; + mode: "peak" | "share"; + total: number; +}) { + const point = payload?.[0]?.payload; + if (!active || !point) { + return null; + } + + const ratio = total > 0 ? Math.max(point.value, 0) / total : 0; + const ratioLabel = formatRatioLabel(ratio, prefix); + + return ( +
+
{point.label}
+
{point.valueText}
+
+ {mode === "peak" ? ratioLabel : ratioLabel} +
+
+ ); +} + +function ChartLegend({ points }: { points: NormalizedPoint[] }) { + return ( +
+ {points.map((point) => ( +
+ + {point.label} +
+ ))} +
+ ); +} + +function LineChartCard({ + points, + texts, + mobile, + expanded, +}: { + points: NormalizedPoint[]; + texts: DisplayTextConfig; + mobile: boolean; + expanded?: boolean; +}) { + const gradientId = useId(); + const max = Math.max(...points.map((point) => point.value), 0); + + return ( +
+ + + + + + + + + + + + } /> + + + +
+ ); +} + +function BarChartCard({ + points, + texts, + mobile, + expanded, +}: { + points: NormalizedPoint[]; + texts: DisplayTextConfig; + mobile: boolean; + expanded?: boolean; +}) { + const max = Math.max(...points.map((point) => point.value), 0); + + return ( +
+ + + + + + } cursor={{ fill: "transparent" }} /> + + {points.map((point) => ( + + ))} + + + +
+ ); +} + +function PieChartCard({ + points, + texts, + ring, + mobile, + expanded, +}: { + points: NormalizedPoint[]; + texts: DisplayTextConfig; + ring?: boolean; + mobile: boolean; + expanded?: boolean; +}) { + const total = points.reduce((sum, point) => sum + point.value, 0); + const primaryRatio = total > 0 ? points[0]?.value / total : 0; + const centerTitle = ring + ? texts.centerLabel || (points.length === 2 ? points[0]?.label || "达成率" : "总计") + : ""; + const centerValue = points.length === 2 ? formatPercent(primaryRatio) : formatAxisValue(total); + + return ( +
+ + + `${value}`} + labelLine={!ring} + > + {points.map((point) => ( + + ))} + + } /> + {ring ? ( + <> + + {centerTitle} + + + {centerValue} + + + ) : null} + + + +
+ ); +} + +function FunnelChartCard({ + points, + mobile, + expanded, +}: { + points: NormalizedPoint[]; + mobile: boolean; + expanded?: boolean; +}) { + const funnelData = points.map((point) => ({ + ...point, + fill: point.color, + name: point.label, + })); + + return ( +
+ + + point.value), 0)} />} /> + + + + + +
+ ); +} + +function RankingChartCard({ + points, + texts, +}: { + points: NormalizedPoint[]; + texts: DisplayTextConfig; +}) { + return ( +
+ {points.map((point, index) => ( +
+
+ {index + 1} +
+ {point.initials} +
+
+

{point.label}

+

{point.description || "成交效率优秀"}

+
+
+
+

{point.valueText}

+

+ {formatRankingSecondaryLabel( + point.secondaryValueText, + points[0]?.value ? point.value / points[0].value : 0, + texts.ratioPrefix, + )} +

+
+
+ ))} +
+ ); +} + +function TableCard({ + points, + texts, +}: { + points: NormalizedPoint[]; + texts: DisplayTextConfig; +}) { + return ( +
+
+ {texts.tableLabelHeader || "项目"} + 说明 + {texts.tableValueHeader || "数值"} +
+
+ {points.map((point, index) => ( +
+
+
{point.label}
+
{point.description || "-"}
+
+
+
{point.description || "-"}
+
+
{point.valueText}
+
+ ))} +
+
+ ); +} + +export default function DashboardAnalyticsChart({ + card, + expanded, +}: { + card: DashboardAnalyticsCard; + expanded?: boolean; +}) { + const isMobile = useIsMobileViewport(); + const renderType = card.renderType || "metric"; + const texts = parseDisplayTextConfig(card.displayTextConfig); + const chartPoints = useMemo( + () => normalizePoints(card.chartData ?? [], texts, renderType === "table"), + [card.chartData, renderType, texts], + ); + + if (renderType === "table") { + return chartPoints.length ? : ; + } + + if (!chartPoints.length) { + return ; + } + + if (renderType === "line") { + return ; + } + if (renderType === "bar") { + return ; + } + if (renderType === "pie") { + return ; + } + if (renderType === "ring") { + return ; + } + if (renderType === "ranking") { + return ; + } + if (renderType === "funnel") { + return ; + } + return ; +} diff --git a/frontend/src/features/crmQuickCreate/shared.tsx b/frontend/src/features/crmQuickCreate/shared.tsx index d2420dce..4054ae38 100644 --- a/frontend/src/features/crmQuickCreate/shared.tsx +++ b/frontend/src/features/crmQuickCreate/shared.tsx @@ -143,7 +143,7 @@ export function validateChannelForm(form: CreateChannelExpansionPayload, channel errors.channelIndustry = "请选择聚焦行业"; } if (!form.certificationLevel?.trim()) { - errors.certificationLevel = "请选择认证级别"; + errors.certificationLevel = "请选择汇智内部认证级别"; } if (!form.annualRevenue || form.annualRevenue <= 0) { errors.annualRevenue = "请填写年度营业额"; @@ -474,11 +474,11 @@ export function QuickChannelForm({ {fieldErrors.channelIndustry ?

{fieldErrors.channelIndustry}

: null}