diff --git a/.DS_Store b/.DS_Store index 404549a1..a02c7be3 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/sql/dashboard_analytics_config_upgrade.sql b/backend/sql/dashboard_analytics_config_upgrade.sql new file mode 100644 index 00000000..ac06c6ab --- /dev/null +++ b/backend/sql/dashboard_analytics_config_upgrade.sql @@ -0,0 +1,123 @@ +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() +); + +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, + group_name varchar(100), + 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() +); + +alter table dashboard_analytics_panel_config + add column if not exists enabled boolean not null default false; + +alter table dashboard_analytics_panel_config + add column if not exists title varchar(100) not null default '经营分析'; + +alter table dashboard_analytics_panel_config + add column if not exists subtitle varchar(255); + +alter table dashboard_analytics_panel_config + add column if not exists empty_state_text varchar(255); + +alter table dashboard_analytics_card_config + add column if not exists group_name varchar(100); + +alter table dashboard_analytics_card_config + add column if not exists title varchar(100) not null default '未命名卡片'; + +alter table dashboard_analytics_card_config + add column if not exists subtitle varchar(255); + +alter table dashboard_analytics_card_config + add column if not exists render_type varchar(20) not null default 'metric'; + +alter table dashboard_analytics_card_config + add column if not exists description_field varchar(100); + +alter table dashboard_analytics_card_config + add column if not exists category_field varchar(100); + +alter table dashboard_analytics_card_config + add column if not exists color_field varchar(100); + +alter table dashboard_analytics_card_config + add column if not exists display_text_config text; + +alter table dashboard_analytics_card_config + add column if not exists unit varchar(20); + +alter table dashboard_analytics_card_config + add column if not exists value_type varchar(20) not null default 'number'; + +alter table dashboard_analytics_card_config + add column if not exists sort_direction varchar(20) not null default 'sql'; + +alter table dashboard_analytics_card_config + add column if not exists display_limit integer; + +alter table dashboard_analytics_card_config + add column if not exists layout_type varchar(20) not null default 'vertical'; + +alter table dashboard_analytics_card_config + add column if not exists full_row boolean not null default false; + +alter table dashboard_analytics_card_config + add column if not exists link_path varchar(255); + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'uk_dashboard_analytics_panel_tenant' + ) then + alter table dashboard_analytics_panel_config + add constraint uk_dashboard_analytics_panel_tenant unique (tenant_id); + end if; +end +$$; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'uk_dashboard_analytics_card_tenant_key' + ) then + alter table dashboard_analytics_card_config + add constraint uk_dashboard_analytics_card_tenant_key unique (tenant_id, card_key); + end if; +end +$$; + +create index if not exists idx_dashboard_analytics_card_tenant_sort + on dashboard_analytics_card_config (tenant_id, sort_order asc, id asc); diff --git a/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java index fb4c13d5..9d734d3c 100644 --- a/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/DashboardAnalyticsSchemaInitializer.java @@ -39,6 +39,7 @@ public class DashboardAnalyticsSchemaInitializer implements ApplicationRunner { id bigint generated by default as identity primary key, tenant_id bigint not null, card_key varchar(100) not null, + group_name varchar(100), title varchar(100) not null, subtitle varchar(255), render_type varchar(20) not null default 'metric', @@ -66,10 +67,42 @@ public class DashboardAnalyticsSchemaInitializer implements ApplicationRunner { 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_panel_config + add column if not exists enabled boolean not null default false + """); + statement.execute(""" + alter table dashboard_analytics_panel_config + add column if not exists title varchar(100) not null default '经营分析' + """); + statement.execute(""" + alter table dashboard_analytics_panel_config + add column if not exists subtitle varchar(255) + """); + statement.execute(""" + alter table dashboard_analytics_panel_config + add column if not exists empty_state_text varchar(255) + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists group_name varchar(100) + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists title varchar(100) not null default '未命名卡片' + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists subtitle varchar(255) + """); 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 description_field varchar(100) + """); statement.execute(""" alter table dashboard_analytics_card_config add column if not exists category_field varchar(100) @@ -82,6 +115,14 @@ public class DashboardAnalyticsSchemaInitializer implements ApplicationRunner { 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 unit varchar(20) + """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists value_type varchar(20) not null default 'number' + """); statement.execute(""" alter table dashboard_analytics_card_config add column if not exists sort_direction varchar(20) not null default 'sql' @@ -98,6 +139,10 @@ public class DashboardAnalyticsSchemaInitializer implements ApplicationRunner { alter table dashboard_analytics_card_config add column if not exists full_row boolean not null default false """); + statement.execute(""" + alter table dashboard_analytics_card_config + add column if not exists link_path varchar(255) + """); } 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 de7d05ec..64341c7e 100644 --- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -35,10 +35,12 @@ public class OpportunitySchemaInitializer implements ApplicationRunner { 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("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)"); 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 '归档时间'"); + statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'"); } ensureArchivedAtStorage(connection); ensureConfidenceGradeStorage(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 index b23df0eb..8df5632a 100644 --- a/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java +++ b/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java @@ -41,7 +41,8 @@ public class DashboardAnalyticsAdminController { @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)); + @RequestParam("cardKey") String cardKey, + @RequestParam(value = "dimension", required = false) String dimension) { + return ApiResponse.success(dashboardAnalyticsConfigService.previewCard(tenantId, cardKey, dimension)); } } 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 a83a257a..a5a5b6ba 100644 --- a/backend/src/main/java/com/unis/crm/controller/DashboardController.java +++ b/backend/src/main/java/com/unis/crm/controller/DashboardController.java @@ -10,6 +10,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -43,7 +44,8 @@ public class DashboardController { @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)); + @PathVariable("cardKey") String cardKey, + @RequestParam(value = "dimension", required = false) String dimension) { + return ApiResponse.success(dashboardService.getAnalyticsCardDetail(userId, cardKey, dimension)); } } 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 index 9333c809..ddeb815b 100644 --- a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsCardDTO.java @@ -4,6 +4,7 @@ public class DashboardAnalyticsCardDTO { private Long id; private String cardKey; + private String groupName; private String title; private String subtitle; private String renderType; @@ -38,6 +39,14 @@ public class DashboardAnalyticsCardDTO { this.cardKey = cardKey; } + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + public String getTitle() { return title; } 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 index a36c8b00..6abc2f20 100644 --- a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsChartPointDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardAnalyticsChartPointDTO.java @@ -5,6 +5,8 @@ public class DashboardAnalyticsChartPointDTO { private String label; private String value; private String valueText; + private String seriesName; + private String frameName; private String secondaryValue; private String secondaryValueText; private String description; @@ -34,6 +36,22 @@ public class DashboardAnalyticsChartPointDTO { this.valueText = valueText; } + public String getSeriesName() { + return seriesName; + } + + public void setSeriesName(String seriesName) { + this.seriesName = seriesName; + } + + public String getFrameName() { + return frameName; + } + + public void setFrameName(String frameName) { + this.frameName = frameName; + } + public String getSecondaryValue() { return secondaryValue; } 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 index a29499f4..d557a28a 100644 --- a/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsCardConfigDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/DashboardAnalyticsCardConfigDTO.java @@ -12,6 +12,9 @@ public class DashboardAnalyticsCardConfigDTO { @Size(max = 100, message = "卡片编码长度不能超过100") private String cardKey; + @Size(max = 100, message = "分组名称长度不能超过100") + private String groupName; + @NotBlank(message = "卡片标题不能为空") @Size(max = 100, message = "卡片标题长度不能超过100") private String title; @@ -83,6 +86,14 @@ public class DashboardAnalyticsCardConfigDTO { this.cardKey = cardKey; } + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + public String getTitle() { return title; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelRelatedProjectSummaryDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelRelatedProjectSummaryDTO.java index abb75f36..14d3e14a 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelRelatedProjectSummaryDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelRelatedProjectSummaryDTO.java @@ -8,6 +8,8 @@ public class ChannelRelatedProjectSummaryDTO { private Long opportunityId; private String opportunityCode; private String opportunityName; + private String stageCode; + private String stage; private BigDecimal amount; public Long getChannelExpansionId() { @@ -42,6 +44,22 @@ public class ChannelRelatedProjectSummaryDTO { this.opportunityName = opportunityName; } + public String getStageCode() { + return stageCode; + } + + public void setStageCode(String stageCode) { + this.stageCode = stageCode; + } + + public String getStage() { + return stage; + } + + public void setStage(String stage) { + this.stage = stage; + } + public BigDecimal getAmount() { return amount; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/RelatedProjectSummaryDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/RelatedProjectSummaryDTO.java index deca2d1d..2c2fdc31 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/RelatedProjectSummaryDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/RelatedProjectSummaryDTO.java @@ -8,6 +8,8 @@ public class RelatedProjectSummaryDTO { private Long opportunityId; private String opportunityCode; private String opportunityName; + private String stageCode; + private String stage; private BigDecimal amount; public Long getSalesExpansionId() { @@ -42,6 +44,22 @@ public class RelatedProjectSummaryDTO { this.opportunityName = opportunityName; } + public String getStageCode() { + return stageCode; + } + + public void setStageCode(String stageCode) { + this.stageCode = stageCode; + } + + public String getStage() { + return stage; + } + + public void setStage(String stage) { + this.stage = stage; + } + public BigDecimal getAmount() { return amount; } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java index 9cd63a32..c29b16bc 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java @@ -12,6 +12,7 @@ public class WorkReportLineItemDTO { private String evaluationContent; private String nextPlan; private String latestProgress; + private String stage; private String communicationTime; private String communicationContent; @@ -95,6 +96,14 @@ public class WorkReportLineItemDTO { this.latestProgress = latestProgress; } + public String getStage() { + return stage; + } + + public void setStage(String stage) { + this.stage = stage; + } + public String getCommunicationTime() { return communicationTime; } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java index 4fceec71..40f126d3 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java @@ -29,6 +29,7 @@ public class WorkReportLineItemRequest { private String evaluationContent; private String nextPlan; private String latestProgress; + private String stage; private String communicationTime; private String communicationContent; @@ -112,6 +113,14 @@ public class WorkReportLineItemRequest { this.latestProgress = latestProgress; } + public String getStage() { + return stage; + } + + public void setStage(String stage) { + this.stage = stage; + } + public String getCommunicationTime() { return communicationTime; } 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 c6b4ae81..66ce90cb 100644 --- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java @@ -142,7 +142,8 @@ public interface WorkMapper { int updateOpportunitySnapshot( @Param("opportunityId") Long opportunityId, @Param("latestProgress") String latestProgress, - @Param("nextPlan") String nextPlan); + @Param("nextPlan") String nextPlan, + @Param("stage") String stage); int deleteTodosByBiz(@Param("userId") Long userId, @Param("bizType") String bizType, @Param("bizId") Long bizId); diff --git a/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java b/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java index 5d021b14..6ff73b04 100644 --- a/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java +++ b/backend/src/main/java/com/unis/crm/service/DashboardAnalyticsConfigService.java @@ -52,12 +52,13 @@ public class DashboardAnalyticsConfigService { 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_RENDER_TYPES = Set.of("metric", "line", "bar", "pie", "ring", "funnel"); 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", + "dimension", "today", "yesterday", "monthStart", @@ -65,6 +66,14 @@ public class DashboardAnalyticsConfigService { "nextMonthStart", "weekStart", "weekEnd", + "quarterStart", + "quarterEnd", + "nextQuarterStart", + "yearStart", + "yearEnd", + "nextYearStart", + "periodStart", + "periodEnd", "now"); private static final Pattern NAMED_PARAM_PATTERN = Pattern.compile("(? cards = new ArrayList<>(); for (DashboardAnalyticsCardConfigDTO cardConfig : config.getCards()) { - if (!Boolean.TRUE.equals(cardConfig.getEnabled())) { + if (!Boolean.TRUE.equals(cardConfig.getEnabled()) || !isSupportedStoredCard(cardConfig)) { continue; } try { - cards.add(executeCard(tenantId, currentUserId, cardConfig, true)); + cards.add(executeCard(tenantId, currentUserId, cardConfig, true, null)); } catch (Exception exception) { log.warn("Failed to execute dashboard analytics card {} for tenant {}", cardConfig.getCardKey(), tenantId, exception); if (includeErrors) { @@ -288,8 +300,10 @@ public class DashboardAnalyticsConfigService { Long tenantId, Long currentUserId, DashboardAnalyticsCardConfigDTO config, - boolean applyDisplayLimit) { - ExecutableSql executableSql = prepareExecutableSql(config.getSqlTemplate(), tenantId, currentUserId); + boolean applyDisplayLimit, + String requestedDimension) { + String activeDimension = resolveActiveDimension(config, requestedDimension); + ExecutableSql executableSql = prepareExecutableSql(config.getSqlTemplate(), tenantId, currentUserId, activeDimension); List> rows = namedParameterJdbcTemplate.queryForList(executableSql.sql(), executableSql.params()); Map row = rows.isEmpty() ? Map.of() : rows.get(0); String renderType = normalizeRenderType(config.getRenderType()); @@ -297,6 +311,7 @@ public class DashboardAnalyticsConfigService { DashboardAnalyticsCardDTO dto = new DashboardAnalyticsCardDTO(); dto.setId(config.getId()); dto.setCardKey(config.getCardKey()); + dto.setGroupName(config.getGroupName()); dto.setTitle(config.getTitle()); dto.setSubtitle(config.getSubtitle()); dto.setRenderType(renderType); @@ -328,8 +343,8 @@ public class DashboardAnalyticsConfigService { return dto; } - private ExecutableSql prepareExecutableSql(String sqlTemplate, Long tenantId, Long currentUserId) { - MapSqlParameterSource params = buildSqlParams(tenantId, currentUserId); + private ExecutableSql prepareExecutableSql(String sqlTemplate, Long tenantId, Long currentUserId, String dimension) { + MapSqlParameterSource params = buildSqlParams(tenantId, currentUserId, dimension); String executableSql = applyDataScopeMacros(sqlTemplate, tenantId, currentUserId, params); return new ExecutableSql(executableSql, params); } @@ -361,10 +376,11 @@ public class DashboardAnalyticsConfigService { } private List loadCards(Long tenantId) { - return jdbcTemplate.query(""" + List cards = jdbcTemplate.query(""" select id, card_key, + group_name, title, subtitle, render_type, @@ -389,6 +405,9 @@ public class DashboardAnalyticsConfigService { """, cardConfigRowMapper(), tenantId); + return cards.stream() + .filter(this::isSupportedStoredCard) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); } private DashboardAnalyticsCardConfigDTO findCardConfig(Long tenantId, String cardKey, boolean includeDisabled) { @@ -400,6 +419,7 @@ public class DashboardAnalyticsConfigService { select id, card_key, + group_name, title, subtitle, render_type, @@ -427,7 +447,7 @@ public class DashboardAnalyticsConfigService { tenantId, normalizedCardKey.trim()); DashboardAnalyticsCardConfigDTO card = cards.isEmpty() ? null : cards.get(0); - if (card == null || (!includeDisabled && !Boolean.TRUE.equals(card.getEnabled()))) { + if (card == null || !isSupportedStoredCard(card) || (!includeDisabled && !Boolean.TRUE.equals(card.getEnabled()))) { throw new BusinessException("未找到经营分析卡片:" + normalizedCardKey); } return card; @@ -438,6 +458,7 @@ public class DashboardAnalyticsConfigService { DashboardAnalyticsCardConfigDTO dto = new DashboardAnalyticsCardConfigDTO(); dto.setId(resultSet.getLong("id")); dto.setCardKey(resultSet.getString("card_key")); + dto.setGroupName(resultSet.getString("group_name")); dto.setTitle(resultSet.getString("title")); dto.setSubtitle(resultSet.getString("subtitle")); dto.setRenderType(resultSet.getString("render_type")); @@ -464,8 +485,8 @@ public class DashboardAnalyticsConfigService { 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.setTitle(defaultIfBlank(payload == null ? null : payload.getTitle(), "")); + normalized.setSubtitle(defaultIfBlank(payload == null ? null : payload.getSubtitle(), "")); normalized.setEmptyStateText(defaultIfBlank(payload == null ? null : payload.getEmptyStateText(), "暂无可展示的经营分析卡片")); normalized.setCards(normalizeCards(payload == null ? null : payload.getCards())); return normalized; @@ -495,12 +516,13 @@ public class DashboardAnalyticsConfigService { 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)) { + if (!"metric".equals(renderType) && "text".equals(valueType)) { throw new BusinessException("图表卡片不支持文本展示类型:" + cardKey); } DashboardAnalyticsCardConfigDTO item = new DashboardAnalyticsCardConfigDTO(); item.setId(card.getId()); item.setCardKey(cardKey); + item.setGroupName(trimToNull(card.getGroupName())); item.setTitle(defaultIfBlank(card.getTitle(), cardKey)); item.setSubtitle(trimToNull(card.getSubtitle())); item.setRenderType(renderType); @@ -589,6 +611,15 @@ public class DashboardAnalyticsConfigService { return normalized; } + private boolean isSupportedStoredRenderType(String renderType) { + String normalized = defaultIfBlank(renderType, "").toLowerCase(Locale.ROOT); + return ALLOWED_RENDER_TYPES.contains(normalized); + } + + private boolean isSupportedStoredCard(DashboardAnalyticsCardConfigDTO cardConfig) { + return cardConfig != null && isSupportedStoredRenderType(cardConfig.getRenderType()); + } + private void validateSqlParams(String sqlTemplate, String cardKey) { String sqlWithoutDataScopeMacros = DATA_SCOPE_MACRO_PATTERN.matcher(sqlTemplate).replaceAll("true"); Matcher matcher = NAMED_PARAM_PATTERN.matcher(sqlWithoutDataScopeMacros); @@ -774,16 +805,24 @@ public class DashboardAnalyticsConfigService { return clause.toString(); } - private MapSqlParameterSource buildSqlParams(Long tenantId, Long currentUserId) { + private MapSqlParameterSource buildSqlParams(Long tenantId, Long currentUserId, String dimension) { 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); + LocalDate quarterStart = today.with(today.getMonth().firstMonthOfQuarter()).withDayOfMonth(1); + LocalDate nextQuarterStart = quarterStart.plusMonths(3); + LocalDate quarterEnd = nextQuarterStart.minusDays(1); + LocalDate yearStart = today.with(TemporalAdjusters.firstDayOfYear()); + LocalDate nextYearStart = yearStart.plusYears(1); + LocalDate yearEnd = nextYearStart.minusDays(1); + LocalDate[] periodRange = resolvePeriodRange(today, monthStart, nextMonthStart, quarterStart, nextQuarterStart, yearStart, nextYearStart, dimension); return new MapSqlParameterSource() .addValue("tenantId", tenantId) .addValue("currentUserId", currentUserId) + .addValue("dimension", defaultIfBlank(dimension, "")) .addValue("today", today) .addValue("yesterday", today.minusDays(1)) .addValue("monthStart", monthStart) @@ -791,9 +830,104 @@ public class DashboardAnalyticsConfigService { .addValue("nextMonthStart", nextMonthStart) .addValue("weekStart", weekStart) .addValue("weekEnd", weekEnd) + .addValue("quarterStart", quarterStart) + .addValue("quarterEnd", quarterEnd) + .addValue("nextQuarterStart", nextQuarterStart) + .addValue("yearStart", yearStart) + .addValue("yearEnd", yearEnd) + .addValue("nextYearStart", nextYearStart) + .addValue("periodStart", periodRange[0]) + .addValue("periodEnd", periodRange[1]) .addValue("now", OffsetDateTime.now()); } + private LocalDate[] resolvePeriodRange( + LocalDate today, + LocalDate monthStart, + LocalDate nextMonthStart, + LocalDate quarterStart, + LocalDate nextQuarterStart, + LocalDate yearStart, + LocalDate nextYearStart, + String dimension) { + String normalizedDimension = defaultIfBlank(dimension, "").toLowerCase(Locale.ROOT); + return switch (normalizedDimension) { + case "quarter" -> new LocalDate[]{quarterStart, nextQuarterStart}; + case "year" -> new LocalDate[]{yearStart, nextYearStart}; + case "all" -> new LocalDate[]{LocalDate.of(2000, 1, 1), today.plusDays(1)}; + case "month", "" -> new LocalDate[]{monthStart, nextMonthStart}; + default -> new LocalDate[]{monthStart, nextMonthStart}; + }; + } + + private String resolveActiveDimension(DashboardAnalyticsCardConfigDTO config, String requestedDimension) { + List dimensionOptions = resolveDimensionOptions(config); + if (dimensionOptions.isEmpty()) { + return null; + } + String normalizedRequested = trimToNull(requestedDimension); + if (normalizedRequested != null) { + String matched = dimensionOptions.stream() + .map(DimensionOption::value) + .filter(StringUtils::hasText) + .filter(value -> value.equalsIgnoreCase(normalizedRequested)) + .findFirst() + .orElse(null); + if (matched != null) { + return matched; + } + } + String configuredDefault = resolveDisplayTextString(config, "defaultDimension"); + if (StringUtils.hasText(configuredDefault)) { + String matched = dimensionOptions.stream() + .map(DimensionOption::value) + .filter(StringUtils::hasText) + .filter(value -> value.equalsIgnoreCase(configuredDefault)) + .findFirst() + .orElse(null); + if (matched != null) { + return matched; + } + } + return dimensionOptions.get(0).value(); + } + + private List resolveDimensionOptions(DashboardAnalyticsCardConfigDTO config) { + if (config == null || !StringUtils.hasText(config.getDisplayTextConfig())) { + return List.of(); + } + try { + JsonNode root = OBJECT_MAPPER.readTree(config.getDisplayTextConfig()); + if (!root.path("dimensionSwitchEnabled").asBoolean(false)) { + return List.of(); + } + JsonNode optionsNode = root.path("dimensionOptions"); + if (!optionsNode.isArray() || optionsNode.isEmpty()) { + return List.of(); + } + List options = new ArrayList<>(); + for (JsonNode item : optionsNode) { + if (item.isTextual()) { + String value = trimToNull(item.asText()); + if (value != null) { + options.add(new DimensionOption(value, value)); + } + continue; + } + String value = trimToNull(item.path("value").asText(null)); + if (value == null) { + continue; + } + String label = trimToNull(item.path("label").asText(null)); + options.add(new DimensionOption(value, label == null ? value : label)); + } + return options; + } catch (Exception exception) { + log.debug("Ignore invalid displayTextConfig dimensionOptions for card {}", config.getCardKey(), exception); + return List.of(); + } + } + private Object readValue(Map row, String preferredField, String fallbackField) { if (row == null || row.isEmpty()) { return null; @@ -832,37 +966,32 @@ public class DashboardAnalyticsConfigService { 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") + String seriesField = Set.of("line", "bar", "pie", "ring", "funnel").contains(renderType) + ? resolveDisplayTextString(config, "seriesField") : null; + String frameField = "bar".equals(renderType) ? resolveDisplayTextString(config, "frameField") : 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 : "未命名"); + String seriesName = toDisplayString(readValue(row, seriesField, "series")); + if (StringUtils.hasText(seriesName)) { + point.setSeriesName(seriesName); + } + String frameName = toDisplayString(readValue(row, frameField, "frame")); + if (StringUtils.hasText(frameName)) { + point.setFrameName(frameName); + } 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)); - } + BigDecimal numericValue = toBigDecimal(valueObject); + if (numericValue == null) { + continue; } + point.setValue(stripTrailingZeros(numericValue)); + point.setValueText(formatValue(numericValue, config.getValueType(), config.getUnit())); points.add(point); } List normalizedPoints = applyConfiguredCategories(points, config, renderType); @@ -880,6 +1009,9 @@ public class DashboardAnalyticsConfigService { List points, DashboardAnalyticsCardConfigDTO config, String renderType) { + if (points.stream().anyMatch(this::usesAdvancedVisualizationProtocol)) { + return points; + } List options = resolveCategoryOptions(config); if (options.isEmpty()) { options = deriveBuiltinCategoryOptions(points, renderType); @@ -899,7 +1031,7 @@ public class DashboardAnalyticsConfigService { created.setLabel(option.label()); created.setColor(option.color()); created.setDescription(option.description()); - created.setValue("table".equals(renderType) ? "0" : "0"); + created.setValue("0"); created.setValueText(formatValue(BigDecimal.ZERO, config.getValueType(), config.getUnit())); merged.add(created); continue; @@ -957,18 +1089,10 @@ public class DashboardAnalyticsConfigService { } } - 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()) { + if (!Set.of("pie", "ring", "funnel", "bar", "line").contains(renderType) || points.isEmpty()) { return List.of(); } Set labels = points.stream() @@ -1017,7 +1141,6 @@ public class DashboardAnalyticsConfigService { return switch (normalizeRenderType(renderType)) { case "line", "bar" -> 6; case "pie", "ring", "funnel" -> 5; - case "ranking", "table" -> 6; default -> Integer.MAX_VALUE; }; } @@ -1025,6 +1148,9 @@ public class DashboardAnalyticsConfigService { private List sortVisualizationData( List points, DashboardAnalyticsCardConfigDTO config) { + if (points.stream().anyMatch(this::usesAdvancedVisualizationProtocol)) { + return points; + } String sortDirection = normalizeSortDirection(config.getSortDirection()); if ("sql".equals(sortDirection) || points.size() <= 1) { return points; @@ -1052,6 +1178,14 @@ public class DashboardAnalyticsConfigService { List points, Integer displayLimit, String renderType) { + if (points.stream().anyMatch(point -> StringUtils.hasText(point.getFrameName())) + && "bar".equals(normalizeRenderType(renderType))) { + return points; + } + if (points.stream().anyMatch(point -> StringUtils.hasText(point.getSeriesName())) + && Set.of("line", "bar", "pie", "ring", "funnel").contains(normalizeRenderType(renderType))) { + return applyDisplayLimitByLabel(points, displayLimit, renderType); + } Integer normalizedLimit = normalizeDisplayLimit(displayLimit); if (normalizedLimit == null) { int recommendedLimit = defaultDisplayLimit(renderType); @@ -1066,6 +1200,36 @@ public class DashboardAnalyticsConfigService { return new ArrayList<>(points.subList(0, normalizedLimit)); } + private List applyDisplayLimitByLabel( + List points, + Integer displayLimit, + String renderType) { + Integer normalizedLimit = normalizeDisplayLimit(displayLimit); + int effectiveLimit = normalizedLimit == null ? defaultDisplayLimit(renderType) : normalizedLimit; + if (effectiveLimit == Integer.MAX_VALUE) { + return points; + } + + Set visibleLabels = new LinkedHashSet<>(); + for (DashboardAnalyticsChartPointDTO point : points) { + visibleLabels.add(defaultIfBlank(point.getLabel(), "未命名")); + if (visibleLabels.size() >= effectiveLimit) { + break; + } + } + if (visibleLabels.isEmpty()) { + return points; + } + return points.stream() + .filter(point -> visibleLabels.contains(defaultIfBlank(point.getLabel(), "未命名"))) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + } + + private boolean usesAdvancedVisualizationProtocol(DashboardAnalyticsChartPointDTO point) { + return point != null + && (StringUtils.hasText(point.getSeriesName()) || StringUtils.hasText(point.getFrameName())); + } + private String formatWithUnit(String valueText, String unit) { if (!StringUtils.hasText(unit)) { return valueText; @@ -1153,6 +1317,9 @@ public class DashboardAnalyticsConfigService { private record CategoryOption(String label, String color, String description) { } + private record DimensionOption(String value, String label) { + } + private record VisualizationDataResult( List points, int totalCount, 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 bae37025..1a7f1e5d 100644 --- a/backend/src/main/java/com/unis/crm/service/DashboardService.java +++ b/backend/src/main/java/com/unis/crm/service/DashboardService.java @@ -9,5 +9,5 @@ public interface DashboardService { void completeTodo(Long userId, Long todoId); - DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey); + DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey, String dimension); } 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 9fb615c4..f2e0751a 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 @@ -117,7 +117,7 @@ public class DashboardServiceImpl implements DashboardService { } @Override - public DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey) { + public DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey, String dimension) { if (userId == null) { throw new UnauthorizedException("未获取到当前登录用户,禁止查询他人数据"); } @@ -126,7 +126,7 @@ public class DashboardServiceImpl implements DashboardService { throw new UnauthorizedException("无权查看经营分析卡片详情"); } try { - return dashboardAnalyticsConfigService.getDashboardCardDetail(userId, cardKey); + return dashboardAnalyticsConfigService.getDashboardCardDetail(userId, cardKey, dimension); } catch (Exception exception) { DashboardAnalyticsPanelDTO panel = dashboardAnalyticsConfigService.getDashboardPanel(userId); DashboardAnalyticsCardDTO fallbackCard = panel.getCards() == null diff --git a/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java index 2e6efaeb..7293f125 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WecomSsoServiceImpl.java @@ -42,6 +42,8 @@ public class WecomSsoServiceImpl implements WecomSsoService { private static final String JSAPI_TICKET_CACHE_KEY = "crm:wecom:jsapi-ticket"; private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:"; private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:"; + private static final long ACCESS_TOKEN_DEFAULT_MINUTES = 7L * 24L * 60L; + private static final long REFRESH_TOKEN_DEFAULT_DAYS = 7L; private final WecomProperties wecomProperties; private final StringRedisTemplate stringRedisTemplate; @@ -347,11 +349,11 @@ public class WecomSsoServiceImpl implements WecomSsoService { } private long resolveAccessDefaultMinutes() { - return 30L; + return ACCESS_TOKEN_DEFAULT_MINUTES; } private long resolveRefreshDefaultDays() { - return 7L; + return REFRESH_TOKEN_DEFAULT_DAYS; } private String newSessionId() { 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 74db3712..2f5d0a2a 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 @@ -17,6 +17,7 @@ import com.unis.crm.dto.work.WorkTomorrowPlanItemDTO; import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest; import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.dto.work.WorkSuggestedActionDTO; +import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; import com.unis.crm.service.ReportReminderService; @@ -77,6 +78,8 @@ public class WorkServiceImpl implements WorkService { 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 String OPPORTUNITY_STAGE_LABEL = "项目阶段"; + private static final String OPPORTUNITY_STAGE_TYPE_CODE = "sj_xmjd"; 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"; @@ -88,6 +91,7 @@ public class WorkServiceImpl implements WorkService { private static final ZoneId BUSINESS_ZONE_ID = ZoneId.of("Asia/Shanghai"); private final WorkMapper workMapper; + private final OpportunityMapper opportunityMapper; private final ProfileMapper profileMapper; private final HttpClient httpClient; private final ObjectMapper objectMapper; @@ -98,12 +102,14 @@ public class WorkServiceImpl implements WorkService { public WorkServiceImpl( WorkMapper workMapper, + OpportunityMapper opportunityMapper, ProfileMapper profileMapper, ReportReminderService reportReminderService, ObjectMapper objectMapper, @Value("${unisbase.app.upload-path}") String uploadPath, @Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) { this.workMapper = workMapper; + this.opportunityMapper = opportunityMapper; this.profileMapper = profileMapper; this.reportReminderService = reportReminderService; this.objectMapper = objectMapper; @@ -800,6 +806,7 @@ public class WorkServiceImpl implements WorkService { item.setEvaluationContent(normalizeOptionalText(item.getEvaluationContent())); item.setNextPlan(normalizeOptionalText(item.getNextPlan())); item.setLatestProgress(normalizeOptionalText(item.getLatestProgress())); + item.setStage(normalizeOptionalText(item.getStage())); item.setCommunicationTime(normalizeOptionalText(item.getCommunicationTime())); item.setCommunicationContent(normalizeOptionalText(item.getCommunicationContent())); hydrateLineItemFromEditorText(item); @@ -878,7 +885,11 @@ public class WorkServiceImpl implements WorkService { item.setEvaluationContent(null); item.setCommunicationTime(null); item.setCommunicationContent(null); + item.setStage(normalizeOpportunityStageValue(firstNonBlank( + fieldValues.get(OPPORTUNITY_STAGE_LABEL), + item.getStage()))); item.setNextPlan(fieldValues.get(OPPORTUNITY_NEXT_PLAN_LABEL)); + item.setEditorText(buildEditorText(item.getBizType(), objectName, fieldValues)); item.setContent(buildOpportunityLineContent(item)); } @@ -901,8 +912,9 @@ public class WorkServiceImpl implements WorkService { private String buildOpportunityLineContent(WorkReportLineItemRequest item) { String latestProgress = normalizeOptionalText(item.getLatestProgress()); + String stage = formatOpportunityStageLabel(item.getStage()); String nextPlan = normalizeOptionalText(item.getNextPlan()); - if (latestProgress == null && nextPlan == null) { + if (latestProgress == null && stage == null && nextPlan == null) { return normalizeOptionalText(item.getContent()); } @@ -910,6 +922,9 @@ public class WorkServiceImpl implements WorkService { if (latestProgress != null) { parts.add("项目最新进展:" + latestProgress); } + if (stage != null) { + parts.add(OPPORTUNITY_STAGE_LABEL + ":" + stage); + } if (nextPlan != null) { parts.add(OPPORTUNITY_NEXT_PLAN_LABEL + ":" + nextPlan); } @@ -927,6 +942,7 @@ public class WorkServiceImpl implements WorkService { return fieldValues; } fieldValues.put("项目最新进展", normalizeOptionalText(item.getLatestProgress())); + fieldValues.put(OPPORTUNITY_STAGE_LABEL, formatOpportunityStageLabel(item.getStage())); fieldValues.put(OPPORTUNITY_NEXT_PLAN_LABEL, normalizeOptionalText(item.getNextPlan())); return fieldValues; } @@ -1079,7 +1095,8 @@ public class WorkServiceImpl implements WorkService { workMapper.updateOpportunitySnapshot( item.getBizId(), normalizeSnapshotText(item.getLatestProgress()), - normalizeSnapshotText(item.getNextPlan())); + normalizeSnapshotText(item.getNextPlan()), + item.getStage()); continue; } workMapper.deleteDailyReportExpansionFollowUps( @@ -1115,6 +1132,34 @@ public class WorkServiceImpl implements WorkService { extractSingleLineField(item.getContent(), LEGACY_NEXT_PLAN_LABEL)); } + private String normalizeOpportunityStageValue(String rawStage) { + String normalized = normalizeOptionalText(rawStage); + if (normalized == null) { + return null; + } + String dictLabel = opportunityMapper.selectDictLabel(OPPORTUNITY_STAGE_TYPE_CODE, normalized); + if (dictLabel != null && !dictLabel.isBlank()) { + return normalized; + } + String dictValue = opportunityMapper.selectDictValueByLabel(OPPORTUNITY_STAGE_TYPE_CODE, normalized); + if (dictValue != null && !dictValue.isBlank()) { + return dictValue; + } + throw new BusinessException("项目阶段无效: " + normalized); + } + + private String formatOpportunityStageLabel(String rawStage) { + String normalized = normalizeOptionalText(rawStage); + if (normalized == null) { + return null; + } + String dictLabel = opportunityMapper.selectDictLabel(OPPORTUNITY_STAGE_TYPE_CODE, normalized); + if (dictLabel != null && !dictLabel.isBlank()) { + return dictLabel; + } + return normalized; + } + private String extractSingleLineField(String content, String label) { String normalizedContent = normalizeOptionalText(content); String normalizedLabel = normalizeOptionalText(label); diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 399d921a..04a895ab 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -57,7 +57,7 @@ unisbase: ttl-seconds: 120 max-attempts: 5 token: - access-default-minutes: 30 + access-default-minutes: 10080 refresh-default-days: 7 wecom: enabled: ${WECOM_ENABLED:false} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 074f18ff..5c5b6f70 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -66,7 +66,7 @@ unisbase: ttl-seconds: 120 max-attempts: 5 token: - access-default-minutes: 30 + access-default-minutes: 10080 refresh-default-days: 7 wecom: enabled: ${WECOM_ENABLED:true} diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index 459346c4..308c7599 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -533,8 +533,34 @@ o.id as opportunityId, o.opportunity_code as opportunityCode, o.opportunity_name as opportunityName, + o.stage as stageCode, + coalesce(stage_dict.item_label, case coalesce(o.stage, 'initial_contact') + when 'initial_contact' then '初步沟通' + when 'solution_discussion' then '方案交流' + when 'bidding' then '招投标' + when 'business_negotiation' then '商务谈判' + when 'won' then '已成交' + when 'lost' then '已放弃' + else o.stage + end) as stage, o.amount from crm_opportunity o + left join sys_dict_item stage_dict + on stage_dict.type_code = 'sj_xmjd' + and ( + stage_dict.item_value = o.stage + or stage_dict.item_label = case coalesce(o.stage, 'initial_contact') + when 'initial_contact' then '初步沟通' + when 'solution_discussion' then '方案交流' + when 'bidding' then '招投标' + when 'business_negotiation' then '商务谈判' + when 'won' then '已成交' + when 'lost' then '已放弃' + else o.stage + end + ) + and stage_dict.status = 1 + and coalesce(stage_dict.is_deleted, 0) = 0 where o.sales_expansion_id in #{id} @@ -587,8 +613,34 @@ o.id as opportunityId, o.opportunity_code as opportunityCode, o.opportunity_name as opportunityName, + o.stage as stageCode, + coalesce(stage_dict.item_label, case coalesce(o.stage, 'initial_contact') + when 'initial_contact' then '初步沟通' + when 'solution_discussion' then '方案交流' + when 'bidding' then '招投标' + when 'business_negotiation' then '商务谈判' + when 'won' then '已成交' + when 'lost' then '已放弃' + else o.stage + end) as stage, o.amount from crm_opportunity o + left join sys_dict_item stage_dict + on stage_dict.type_code = 'sj_xmjd' + and ( + stage_dict.item_value = o.stage + or stage_dict.item_label = case coalesce(o.stage, 'initial_contact') + when 'initial_contact' then '初步沟通' + when 'solution_discussion' then '方案交流' + when 'bidding' then '招投标' + when 'business_negotiation' then '商务谈判' + when 'won' then '已成交' + when 'lost' then '已放弃' + else o.stage + end + ) + and stage_dict.status = 1 + and coalesce(stage_dict.is_deleted, 0) = 0 where o.channel_expansion_id in #{id} diff --git a/backend/src/main/resources/mapper/work/WorkMapper.xml b/backend/src/main/resources/mapper/work/WorkMapper.xml index 6c6b1f80..8606be67 100644 --- a/backend/src/main/resources/mapper/work/WorkMapper.xml +++ b/backend/src/main/resources/mapper/work/WorkMapper.xml @@ -632,6 +632,7 @@ update crm_opportunity set latest_progress = #{latestProgress}, next_plan = #{nextPlan}, + stage = coalesce(#{stage}, stage), updated_at = now() where id = #{opportunityId} 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 d71b345d..bc4153d9 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 @@ -17,6 +17,7 @@ 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 com.unis.crm.mapper.OpportunityMapper; import java.time.LocalDate; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; @@ -39,6 +40,9 @@ class WorkServiceImplTest { @Mock private WorkMapper workMapper; + @Mock + private OpportunityMapper opportunityMapper; + @Mock private ProfileMapper profileMapper; @@ -49,7 +53,7 @@ class WorkServiceImplTest { @BeforeEach void setUp() { - workService = new WorkServiceImpl(workMapper, profileMapper, reportReminderService, new ObjectMapper(), "build/test-uploads", ""); + workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, new ObjectMapper(), "build/test-uploads", ""); } @Test @@ -226,7 +230,6 @@ class WorkServiceImplTest { 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))) @@ -245,6 +248,52 @@ class WorkServiceImplTest { eq(null)); } + @Test + void saveDailyReport_shouldSyncOpportunityStageFromReportTemplate() { + 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.setStage("方案交流"); + 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(opportunityMapper.selectDictLabel("sj_xmjd", "方案交流")).thenReturn(null); + when(opportunityMapper.selectDictValueByLabel("sj_xmjd", "方案交流")).thenReturn("solution_discussion"); + when(opportunityMapper.selectDictLabel("sj_xmjd", "solution_discussion")).thenReturn("方案交流"); + 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).updateOpportunitySnapshot( + eq(101L), + eq("推进中"), + eq("继续推进"), + eq("solution_discussion")); + } + private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) { WorkHistoryItemDTO item = new WorkHistoryItemDTO(); item.setId(id); diff --git a/frontend/node_modules/.package-lock.json b/frontend/node_modules/.package-lock.json index 94615da1..f6adb312 100644 --- a/frontend/node_modules/.package-lock.json +++ b/frontend/node_modules/.package-lock.json @@ -2750,6 +2750,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6354,6 +6370,21 @@ "engines": { "node": "*" } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/frontend/node_modules/.vite/deps/_metadata.json b/frontend/node_modules/.vite/deps/_metadata.json index 06d80720..ed323ac5 100644 --- a/frontend/node_modules/.vite/deps/_metadata.json +++ b/frontend/node_modules/.vite/deps/_metadata.json @@ -1,107 +1,113 @@ { - "hash": "11a137f3", + "hash": "540407b6", "configHash": "4d48f89c", - "lockfileHash": "3435c45d", - "browserHash": "01835e8f", + "lockfileHash": "dddd8ddf", + "browserHash": "11edbc54", "optimized": { "react": { "src": "../../react/index.js", "file": "react.js", - "fileHash": "2fe36264", + "fileHash": "f1e46176", "needsInterop": true }, "react-dom": { "src": "../../react-dom/index.js", "file": "react-dom.js", - "fileHash": "3e554bb3", + "fileHash": "efb2a8e2", "needsInterop": true }, "react/jsx-dev-runtime": { "src": "../../react/jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js", - "fileHash": "640ca040", + "fileHash": "d5b77aa1", "needsInterop": true }, "react/jsx-runtime": { "src": "../../react/jsx-runtime.js", "file": "react_jsx-runtime.js", - "fileHash": "e2fb61c6", + "fileHash": "ad778c8f", "needsInterop": true }, "clsx": { "src": "../../clsx/dist/clsx.mjs", "file": "clsx.js", - "fileHash": "36d60f13", + "fileHash": "cde11afc", "needsInterop": false }, "date-fns": { "src": "../../date-fns/index.js", "file": "date-fns.js", - "fileHash": "204ad576", + "fileHash": "b6351193", "needsInterop": false }, "lucide-react": { "src": "../../lucide-react/dist/esm/lucide-react.js", "file": "lucide-react.js", - "fileHash": "13822394", + "fileHash": "8c7d4e04", "needsInterop": false }, "motion/react": { "src": "../../motion/dist/es/react.mjs", "file": "motion_react.js", - "fileHash": "baa20c1b", + "fileHash": "92d599f0", "needsInterop": false }, "react-dom/client": { "src": "../../react-dom/client.js", "file": "react-dom_client.js", - "fileHash": "49e567cb", + "fileHash": "02796eff", "needsInterop": true }, "react-router-dom": { "src": "../../react-router-dom/dist/index.mjs", "file": "react-router-dom.js", - "fileHash": "92bb81d7", + "fileHash": "1f5aeac8", "needsInterop": false }, "tailwind-merge": { "src": "../../tailwind-merge/dist/bundle-mjs.mjs", "file": "tailwind-merge.js", - "fileHash": "196a6373", + "fileHash": "5ab4e882", "needsInterop": false }, "date-fns/locale": { "src": "../../date-fns/locale.js", "file": "date-fns_locale.js", - "fileHash": "c028f096", + "fileHash": "46729450", "needsInterop": false }, "exceljs": { "src": "../../exceljs/dist/exceljs.min.js", "file": "exceljs.js", - "fileHash": "4f4c428f", + "fileHash": "1b4cd078", "needsInterop": true }, "recharts": { "src": "../../recharts/es6/index.js", "file": "recharts.js", - "fileHash": "05557fbc", + "fileHash": "f145b69c", + "needsInterop": false + }, + "echarts": { + "src": "../../echarts/index.js", + "file": "echarts.js", + "fileHash": "5cad0870", "needsInterop": false } }, "chunks": { - "chunk-ZFXKT4LN": { - "file": "chunk-ZFXKT4LN.js" - }, "chunk-U7P2NEEE": { "file": "chunk-U7P2NEEE.js" }, - "chunk-5MXL5BYH": { - "file": "chunk-5MXL5BYH.js" - }, "chunk-BCIG5HOZ": { "file": "chunk-BCIG5HOZ.js" }, + "chunk-ZFXKT4LN": { + "file": "chunk-ZFXKT4LN.js" + }, + "chunk-5MXL5BYH": { + "file": "chunk-5MXL5BYH.js" + }, "chunk-EUHAVL2Q": { "file": "chunk-EUHAVL2Q.js" }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e453884c..7bb6b633 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dotenv": "^17.2.3", + "echarts": "^6.0.0", "exceljs": "^4.4.0", "express": "^4.21.2", "lucide-react": "^0.546.0", @@ -2721,6 +2722,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6290,6 +6307,21 @@ "engines": { "node": "*" } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/frontend/package.json b/frontend/package.json index bee35f13..6f03fa53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dotenv": "^17.2.3", + "echarts": "^6.0.0", "exceljs": "^4.4.0", "express": "^4.21.2", "lucide-react": "^0.546.0", diff --git a/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx b/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx index 2d76460f..4e3eb851 100644 --- a/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx +++ b/frontend/src/components/dashboard/DashboardAnalyticsChart.tsx @@ -1,230 +1,84 @@ -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 DashboardAnalyticsEChart from "@/components/dashboard/DashboardAnalyticsEChart"; import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; -type ChartPoint = NonNullable[number]; +type SupportedChartRenderType = "line" | "bar" | "pie" | "ring" | "funnel"; 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; - }>; + showDataLabels: boolean; + labelPosition: string; + pcLayout: { + preset?: "auto" | "left" | "right" | "top" | "bottom" | "center"; + chartWidthPercent?: number; + chartHeight?: number; + }; + mobileLayout: { + preset?: "auto" | "left" | "right" | "top" | "bottom" | "center"; + chartWidthPercent?: number; + chartHeight?: number; + }; }>; -type NormalizedPoint = { - label: string; - value: number; - rawValue: string; - valueText: string; - secondaryValueText?: string; - description?: string; - color: string; - initials: string; -}; +type LayoutPreset = "auto" | "left" | "right" | "top" | "bottom" | "center"; -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", -]; +const SUPPORTED_CHART_RENDER_TYPES = new Set(["line", "bar", "pie", "ring", "funnel"]); -function toNumber(value?: string) { - const parsed = Number(value ?? ""); - return Number.isFinite(parsed) ? parsed : 0; +function resolveLegacyShowDataLabelsDefault(renderType?: SupportedChartRenderType) { + return renderType === "pie" || renderType === "ring" || renderType === "funnel"; } -function parseDisplayTextConfig(raw?: string): DisplayTextConfig { +function parseBooleanLike(value: unknown) { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + } + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "y", "on", "是"].includes(normalized)) { + return true; + } + if (["false", "0", "no", "n", "off", "否"].includes(normalized)) { + return false; + } + return undefined; +} + +function parseDisplayTextConfig(raw?: string, renderType?: SupportedChartRenderType): DisplayTextConfig { if (!raw) { return {}; } try { const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed as DisplayTextConfig : {}; + if (!parsed || typeof parsed !== "object") { + return {}; + } + const nextConfig = parsed as Record; + return { + emptyText: typeof nextConfig.emptyText === "string" ? nextConfig.emptyText : undefined, + showDataLabels: parseBooleanLike(nextConfig.showDataLabels) + ?? resolveLegacyShowDataLabelsDefault(renderType) + ?? undefined, + labelPosition: typeof nextConfig.labelPosition === "string" ? nextConfig.labelPosition : undefined, + pcLayout: nextConfig.pcLayout && typeof nextConfig.pcLayout === "object" + ? nextConfig.pcLayout as DisplayTextConfig["pcLayout"] + : undefined, + mobileLayout: nextConfig.mobileLayout && typeof nextConfig.mobileLayout === "object" + ? nextConfig.mobileLayout as DisplayTextConfig["mobileLayout"] + : undefined, + }; } 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 (
@@ -233,290 +87,78 @@ function EmptyState({ text }: { text?: string }) { ); } -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; +function normalizeLayoutPreset(value?: string): LayoutPreset { + switch (value) { + case "left": + case "right": + case "top": + case "bottom": + case "center": + return value; + default: + return "auto"; + } +} + +function normalizeWidthPercent(value?: number) { + if (!Number.isFinite(value)) { + return undefined; + } + return Math.max(40, Math.min(100, value)); +} + +function resolveDefaultChartWidthPercent( + renderType: DashboardAnalyticsCard["renderType"], + config: DisplayTextConfig, + mobile: boolean, +) { + if (mobile || renderType === "line" || renderType === "bar") { + return 100; + } + const outsidePieLabel = (renderType === "pie" || renderType === "ring") + && config.showDataLabels !== false + && !["inside", "center"].includes(config.labelPosition?.trim() || "outside"); + return outsidePieLabel ? 100 : 78; +} + +function resolveLayoutStyle( + card: DashboardAnalyticsCard, + config: DisplayTextConfig, + mobile: boolean, +) { + const renderType = card.renderType; + const layout = mobile ? config.mobileLayout : config.pcLayout; + const preset = normalizeLayoutPreset(layout?.preset); + const widthPercent = normalizeWidthPercent(layout?.chartWidthPercent) + ?? resolveDefaultChartWidthPercent(renderType, config, mobile); + const chartHeight = Number.isFinite(layout?.chartHeight) ? Math.max(180, Math.min(520, Number(layout?.chartHeight))) : undefined; + + let justifyContent = "center"; + let alignItems = "flex-start"; + if (preset === "left") { + justifyContent = "flex-start"; + } else if (preset === "right") { + justifyContent = "flex-end"; + } else if (preset === "top") { + alignItems = "flex-start"; + } else if (preset === "bottom") { + alignItems = "flex-end"; } - 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}
-
- ))} -
-
- ); + return { + chartHeight, + containerStyle: { + width: "100%", + minHeight: chartHeight, + display: "flex", + justifyContent, + alignItems, + } as const, + innerStyle: { + width: `${widthPercent}%`, + maxWidth: "100%", + } as const, + }; } export default function DashboardAnalyticsChart({ @@ -527,38 +169,23 @@ export default function DashboardAnalyticsChart({ 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], - ); + const renderType = card.renderType as SupportedChartRenderType | undefined; + const texts = parseDisplayTextConfig(card.displayTextConfig, renderType); + const layout = resolveLayoutStyle(card, texts, isMobile); - if (renderType === "table") { - return chartPoints.length ? : ; - } - - if (!chartPoints.length) { + if (!renderType || !SUPPORTED_CHART_RENDER_TYPES.has(renderType)) { return ; } - if (renderType === "line") { - return ; + if (!(card.chartData?.length)) { + return ; } - if (renderType === "bar") { - return ; - } - if (renderType === "pie") { - return ; - } - if (renderType === "ring") { - return ; - } - if (renderType === "ranking") { - return ; - } - if (renderType === "funnel") { - return ; - } - return ; + + return ( +
+
+ +
+
+ ); } diff --git a/frontend/src/components/dashboard/DashboardAnalyticsEChart.tsx b/frontend/src/components/dashboard/DashboardAnalyticsEChart.tsx new file mode 100644 index 00000000..c93c4999 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardAnalyticsEChart.tsx @@ -0,0 +1,1400 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import * as echarts from "echarts"; +import type { EChartsOption } from "echarts"; +import type { DashboardAnalyticsCard } from "@/lib/auth"; +import { useIsMobileViewport } from "@/hooks/useIsMobileViewport"; + +type ChartPoint = NonNullable[number]; +type RenderType = NonNullable; +type ValueType = DashboardAnalyticsCard["valueType"]; +type LegendPosition = "top" | "bottom" | "left" | "right"; +type LegendOrient = "horizontal" | "vertical"; +type FunnelSort = "ascending" | "descending"; +type DisplayTextConfig = Partial<{ + emptyText: string; + centerLabel: string; + peakPrefix: string; + sharePrefix: string; + chartPreset: string; + seriesField: string; + legendSeriesOrder: string[]; + frameField: string; + stepPosition: "start" | "middle" | "end"; + lineStyleType: "solid" | "dashed" | "dotted"; + showLegend: boolean; + showDataLabels: boolean; + dataLabelMode: "value" | "total"; + optionOverrides: string; + legendPosition: LegendPosition; + legendOrient: LegendOrient; + labelPosition: string; + gridTop: number; + gridRight: number; + gridBottom: number; + gridLeft: number; + categoryBoundaryGap: boolean; + axisLabelRotate: number; + valueAxisMin: number; + valueAxisMax: number; + lineWidth: number; + symbolSize: number; + barMaxWidth: number; + barBorderRadius: number; + pieRadiusOuter: number; + pieRadiusInner: number; + pieCenterX: number; + pieCenterY: number; + pieLabelEdgeDistance: number; + pieLabelBleedMargin: number; + pieLabelLineLength: number; + pieLabelLineLength2: number; + funnelSort: FunnelSort; + funnelGap: number; +}>; +type NormalizedPoint = { + label: string; + value: number; + valueText: string; + color: string; + seriesName: string; + frameName: string; +}; +type LegendSelectionMap = Record; + +const CHART_COLORS = ["#2563eb", "#7c3aed", "#db2777", "#f59e0b", "#10b981", "#0ea5e9", "#f97316", "#8b5cf6"]; +const LEGEND_POSITIONS = new Set(["top", "bottom", "left", "right"]); +const LEGEND_ORIENTS = new Set(["horizontal", "vertical"]); +const FUNNEL_SORT_OPTIONS = new Set(["ascending", "descending"]); +const LEGACY_CHART_PRESET_ALIASES: Partial>> = { + line: { + "折线图": "line-basic", + "平滑折线图": "line-smooth", + "面积图": "line-area", + "堆积折线图": "line-stacked", + }, + bar: { + "柱状图": "bar-basic", + "条形图": "bar-horizontal", + "堆积柱状图": "bar-stacked", + "堆积条形图": "bar-horizontal-stacked", + "横向堆积柱状图": "bar-horizontal-stacked", + }, + pie: { + "饼图": "pie-basic", + "环形图": "pie-doughnut", + "玫瑰图": "pie-rose", + }, + ring: { + "圆环图": "ring-basic", + "环形图": "ring-basic", + "玫瑰图": "ring-rose", + }, + funnel: { + "漏斗图": "funnel-basic", + }, +}; + +function resolveLegacyShowDataLabelsDefault(renderType?: RenderType) { + return renderType === "pie" || renderType === "ring" || renderType === "funnel"; +} + +function normalizeChartPresetValue(value: unknown, renderType?: RenderType) { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + if (!normalized) { + return undefined; + } + const legacyAlias = renderType ? LEGACY_CHART_PRESET_ALIASES[renderType]?.[normalized] : undefined; + return legacyAlias || normalized; +} + +function parseBooleanLike(value: unknown) { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + } + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "y", "on", "是"].includes(normalized)) { + return true; + } + if (["false", "0", "no", "n", "off", "否"].includes(normalized)) { + return false; + } + return undefined; +} + +function normalizeDataLabelModeValue(value: unknown) { + if (value === "total" || value === "只显示总数") { + return "total" as const; + } + return undefined; +} + +function toNumber(value?: string) { + const parsed = Number(value ?? ""); + return Number.isFinite(parsed) ? parsed : 0; +} + +function toFiniteNumber(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseLegendSeriesOrder(value: unknown) { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); + } + if (typeof value === "string") { + return value + .split(/[\n,,、]+/) + .map((item) => item.trim()) + .filter(Boolean); + } + return []; +} + +function parseDisplayTextConfig(raw?: string, renderType?: RenderType): DisplayTextConfig { + if (!raw) { + return { + showDataLabels: resolveLegacyShowDataLabelsDefault(renderType) || undefined, + }; + } + try { + const parsed = JSON.parse(raw) as Record; + const legendPosition = typeof parsed.legendPosition === "string" && LEGEND_POSITIONS.has(parsed.legendPosition as LegendPosition) + ? parsed.legendPosition as LegendPosition + : undefined; + const legendOrient = typeof parsed.legendOrient === "string" && LEGEND_ORIENTS.has(parsed.legendOrient as LegendOrient) + ? parsed.legendOrient as LegendOrient + : undefined; + const funnelSort = typeof parsed.funnelSort === "string" && FUNNEL_SORT_OPTIONS.has(parsed.funnelSort as FunnelSort) + ? parsed.funnelSort as FunnelSort + : undefined; + + return { + emptyText: typeof parsed.emptyText === "string" ? parsed.emptyText : undefined, + centerLabel: typeof parsed.centerLabel === "string" ? parsed.centerLabel : undefined, + peakPrefix: typeof parsed.peakPrefix === "string" ? parsed.peakPrefix : undefined, + sharePrefix: typeof parsed.sharePrefix === "string" ? parsed.sharePrefix : undefined, + chartPreset: normalizeChartPresetValue(parsed.chartPreset, renderType), + seriesField: typeof parsed.seriesField === "string" ? parsed.seriesField : undefined, + legendSeriesOrder: parseLegendSeriesOrder(parsed.legendSeriesOrder), + frameField: typeof parsed.frameField === "string" ? parsed.frameField : undefined, + stepPosition: typeof parsed.stepPosition === "string" ? parsed.stepPosition as DisplayTextConfig["stepPosition"] : undefined, + lineStyleType: typeof parsed.lineStyleType === "string" ? parsed.lineStyleType as DisplayTextConfig["lineStyleType"] : undefined, + showLegend: parseBooleanLike(parsed.showLegend), + showDataLabels: parseBooleanLike(parsed.showDataLabels) + ?? resolveLegacyShowDataLabelsDefault(renderType) + ?? undefined, + dataLabelMode: normalizeDataLabelModeValue(parsed.dataLabelMode), + optionOverrides: typeof parsed.optionOverrides === "string" ? parsed.optionOverrides : undefined, + legendPosition, + legendOrient, + labelPosition: typeof parsed.labelPosition === "string" ? parsed.labelPosition : undefined, + gridTop: toFiniteNumber(parsed.gridTop), + gridRight: toFiniteNumber(parsed.gridRight), + gridBottom: toFiniteNumber(parsed.gridBottom), + gridLeft: toFiniteNumber(parsed.gridLeft), + categoryBoundaryGap: parseBooleanLike(parsed.categoryBoundaryGap), + axisLabelRotate: toFiniteNumber(parsed.axisLabelRotate), + valueAxisMin: toFiniteNumber(parsed.valueAxisMin), + valueAxisMax: toFiniteNumber(parsed.valueAxisMax), + lineWidth: toFiniteNumber(parsed.lineWidth), + symbolSize: toFiniteNumber(parsed.symbolSize), + barMaxWidth: toFiniteNumber(parsed.barMaxWidth), + barBorderRadius: toFiniteNumber(parsed.barBorderRadius), + pieRadiusOuter: toFiniteNumber(parsed.pieRadiusOuter), + pieRadiusInner: toFiniteNumber(parsed.pieRadiusInner), + pieCenterX: toFiniteNumber(parsed.pieCenterX), + pieCenterY: toFiniteNumber(parsed.pieCenterY), + pieLabelEdgeDistance: toFiniteNumber(parsed.pieLabelEdgeDistance), + pieLabelBleedMargin: toFiniteNumber(parsed.pieLabelBleedMargin), + pieLabelLineLength: toFiniteNumber(parsed.pieLabelLineLength), + pieLabelLineLength2: toFiniteNumber(parsed.pieLabelLineLength2), + funnelSort, + funnelGap: toFiniteNumber(parsed.funnelGap), + }; + } catch { + return { + showDataLabels: resolveLegacyShowDataLabelsDefault(renderType) || undefined, + }; + } +} + +function parseOptionOverrides(raw?: string) { + if (!raw?.trim()) { + return null; + } + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function mergeOptions(base: any, extra: any): any { + if (!extra || typeof extra !== "object" || Array.isArray(extra)) { + return extra ?? base; + } + const result = Array.isArray(base) ? [...base] : { ...(base || {}) }; + Object.entries(extra).forEach(([key, value]) => { + if (Array.isArray(value)) { + result[key] = value; + return; + } + if (value && typeof value === "object") { + result[key] = mergeOptions(result[key], value); + return; + } + result[key] = value; + }); + return result; +} + +function buildTooltip(trigger: "axis" | "item", mobile: boolean) { + return { + trigger, + confine: false, + appendToBody: true, + position(pos: number[], _params: unknown, _dom: unknown, _rect: unknown, size: { contentSize: number[]; viewSize: number[] }) { + const [contentWidth, contentHeight] = size.contentSize; + const [viewWidth, viewHeight] = size.viewSize; + const gap = mobile ? 10 : 14; + const preferredX = mobile ? pos[0] - contentWidth - 12 : pos[0] + 14; + const preferredY = pos[1] - contentHeight / 2; + const clampedX = Math.max(gap, Math.min(viewWidth - contentWidth - gap, preferredX)); + const clampedY = Math.max(gap, Math.min(viewHeight - contentHeight - gap, preferredY)); + return [clampedX, clampedY]; + }, + } as const; +} + +function getHeight(renderType: RenderType, mobile: boolean, expanded?: boolean) { + if (renderType === "funnel") { + return expanded ? 360 : mobile ? 280 : 320; + } + if (renderType === "pie" || renderType === "ring") { + return expanded ? 360 : mobile ? 320 : 340; + } + return expanded ? 340 : mobile ? 260 : 280; +} + +function countVisibleCategoryLabels(points: NormalizedPoint[]) { + const labels = new Set(); + points.forEach((point) => { + labels.add(point.label); + }); + return labels.size; +} + +function resolveChartHeight( + renderType: RenderType, + texts: DisplayTextConfig, + points: NormalizedPoint[], + mobile: boolean, + expanded?: boolean, + heightOverride?: number, +) { + const baseHeight = heightOverride && heightOverride > 0 ? heightOverride : getHeight(renderType, mobile, expanded); + const preset = texts.chartPreset || (renderType === "ring" ? "ring-basic" : renderType === "pie" ? "pie-basic" : ""); + const horizontalBar = renderType === "bar" && (preset === "bar-horizontal" || preset === "bar-horizontal-stacked"); + if (!horizontalBar) { + return baseHeight; + } + const labelCount = countVisibleCategoryLabels(points); + if (labelCount <= 0) { + return baseHeight; + } + const rowHeight = mobile ? 28 : 32; + const padding = mobile ? 92 : 108; + return Math.max(baseHeight, labelCount * rowHeight + padding); +} + +function normalizePoints(points: ChartPoint[]) { + return points.map((point, index) => ({ + label: point.label?.trim() || "未命名", + value: toNumber(point.value), + valueText: point.valueText || point.value || "0", + color: point.color || CHART_COLORS[index % CHART_COLORS.length], + seriesName: point.seriesName?.trim() || "默认系列", + frameName: point.frameName?.trim() || "", + })) satisfies NormalizedPoint[]; +} + +function getFrameNames(points: NormalizedPoint[]) { + const frameNames: string[] = []; + const seen = new Set(); + points.forEach((point) => { + if (!point.frameName || seen.has(point.frameName)) { + return; + } + seen.add(point.frameName); + frameNames.push(point.frameName); + }); + return frameNames; +} + +function collectSeries(points: NormalizedPoint[]) { + const labels: string[] = []; + const labelSeen = new Set(); + const seriesNames: string[] = []; + const seriesSeen = new Set(); + const valueMap = new Map(); + const seriesColorMap = new Map(); + const labelColorMap = new Map(); + + points.forEach((point) => { + if (!labelSeen.has(point.label)) { + labelSeen.add(point.label); + labels.push(point.label); + } + if (!seriesSeen.has(point.seriesName)) { + seriesSeen.add(point.seriesName); + seriesNames.push(point.seriesName); + seriesColorMap.set(point.seriesName, point.color); + } + valueMap.set(`${point.seriesName}::${point.label}`, point.value); + if (!labelColorMap.has(point.label)) { + labelColorMap.set(point.label, point.color); + } + }); + + return { labels, seriesNames, valueMap, seriesColorMap, labelColorMap }; +} + +function resolveOrderedSeriesNames(seriesNames: string[], texts: DisplayTextConfig) { + const desiredOrder = parseLegendSeriesOrder(texts.legendSeriesOrder); + if (desiredOrder.length === 0) { + return seriesNames; + } + const sourceMap = new Map(seriesNames.map((item) => [item.trim().toLowerCase(), item])); + const used = new Set(); + const ordered: string[] = []; + + desiredOrder.forEach((item) => { + const matched = sourceMap.get(item.trim().toLowerCase()); + if (matched && !used.has(matched)) { + used.add(matched); + ordered.push(matched); + } + }); + + seriesNames.forEach((item) => { + if (!used.has(item)) { + ordered.push(item); + } + }); + + return ordered; +} + +function buildStackedLabelMeta( + labels: string[], + seriesNames: string[], + valueMap: Map, + selectedLegendMap?: LegendSelectionMap, +) { + const totalsByLabel = new Map(); + const lastSeriesIndexByLabel = new Map(); + + labels.forEach((label) => { + let total = 0; + let lastIndex = -1; + let lastVisibleIndex = -1; + seriesNames.forEach((seriesName, index) => { + const visible = selectedLegendMap?.[seriesName] !== false; + if (visible) { + lastVisibleIndex = index; + } + const value = valueMap.get(`${seriesName}::${label}`) ?? 0; + if (!visible) { + return; + } + total += value; + if (value !== 0) { + lastIndex = index; + } + }); + totalsByLabel.set(label, total); + lastSeriesIndexByLabel.set(label, lastIndex >= 0 ? lastIndex : lastVisibleIndex); + }); + + return { totalsByLabel, lastSeriesIndexByLabel }; +} + +function sortLabelsByVisibleValue( + labels: string[], + seriesNames: string[], + valueMap: Map, + selectedLegendMap?: LegendSelectionMap, +) { + return [...labels] + .map((label, index) => ({ + label, + index, + total: seriesNames.reduce((sum, seriesName) => { + if (selectedLegendMap?.[seriesName] === false) { + return sum; + } + return sum + (valueMap.get(`${seriesName}::${label}`) ?? 0); + }, 0), + })) + .sort((left, right) => { + if (right.total !== left.total) { + return right.total - left.total; + } + return left.index - right.index; + }) + .map((item) => item.label); +} + +function formatNumberValue(value: number) { + if (!Number.isFinite(value)) { + return "0"; + } + const absValue = Math.abs(value); + const maximumFractionDigits = absValue !== 0 && absValue < 1 ? 4 : (Number.isInteger(value) ? 0 : 2); + return new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: 0, + maximumFractionDigits, + }).format(value); +} + +function appendUnit(formattedValue: string, valueType?: ValueType, unit?: string) { + const trimmedUnit = unit?.trim(); + if (trimmedUnit) { + if (trimmedUnit === "%" || /%$/.test(trimmedUnit)) { + return `${formattedValue}${trimmedUnit}`; + } + return `${formattedValue} ${trimmedUnit}`; + } + if (valueType === "percent") { + return `${formattedValue}%`; + } + return formattedValue; +} + +function formatChartValue(value: number, valueType?: ValueType, unit?: string) { + return appendUnit(formatNumberValue(value), valueType, unit); +} + +function createAxisTooltipFormatter(valueType?: ValueType, unit?: string) { + return (params: any) => { + const items = Array.isArray(params) ? params : [params]; + if (items.length === 0) { + return ""; + } + const title = items[0]?.axisValueLabel || items[0]?.axisValue || ""; + const lines = items + .filter((item) => item) + .map((item) => { + const rawValue = Array.isArray(item.value) + ? Number(item.value.at(-1) ?? 0) + : Number(item.data?.value ?? item.value ?? 0); + return `${item.marker || ""}${item.seriesName || ""}:${formatChartValue(rawValue, valueType, unit)}`; + }); + return [title, ...lines].filter(Boolean).join("
"); + }; +} + +function createItemTooltipFormatter(valueType?: ValueType, unit?: string) { + return (params: any) => { + const name = params.name || params.seriesName || ""; + const formattedValue = formatChartValue(Number(params.value ?? 0), valueType, unit); + return [ + params.seriesName && params.seriesName !== "数据分布" ? params.seriesName : "", + `${params.marker || ""}${name}:${formattedValue}`, + ].filter(Boolean).join("
"); + }; +} + +function createBarLabelFormatter( + labels: string[], + seriesIndex: number, + stacked: boolean, + texts: DisplayTextConfig, + totalsByLabel: Map, + lastSeriesIndexByLabel: Map, + valueType?: ValueType, + unit?: string, +) { + void valueType; + void unit; + return (params: any) => { + const currentValue = Number(params.value ?? 0); + const label = labels[params.dataIndex] || ""; + if (stacked && texts.dataLabelMode === "total") { + if (lastSeriesIndexByLabel.get(label) !== seriesIndex) { + return ""; + } + const total = totalsByLabel.get(label) ?? 0; + return total === 0 ? "" : formatNumberValue(total); + } + return currentValue === 0 ? "" : formatNumberValue(currentValue); + }; +} + +function createLineLabelFormatter( + labels: string[], + seriesIndex: number, + stacked: boolean, + texts: DisplayTextConfig, + totalsByLabel: Map, + lastSeriesIndexByLabel: Map, + valueType?: ValueType, + unit?: string, +) { + void valueType; + void unit; + return (params: any) => { + const rawValue = Array.isArray(params.value) ? Number(params.value[1] ?? 0) : Number(params.value ?? 0); + const label = labels[params.dataIndex] || ""; + if (stacked && texts.dataLabelMode === "total") { + if (lastSeriesIndexByLabel.get(label) !== seriesIndex) { + return ""; + } + const total = totalsByLabel.get(label) ?? 0; + return total === 0 ? "" : formatNumberValue(total); + } + return rawValue === 0 ? "" : formatNumberValue(rawValue); + }; +} + +function resolveLegendConfig(texts: DisplayTextConfig, selectedLegendMap?: LegendSelectionMap) { + if (texts.showLegend === false) { + return { show: false } as const; + } + const position = texts.legendPosition || "top"; + const orient = texts.legendOrient || ((position === "left" || position === "right") ? "vertical" : "horizontal"); + const legend: Record = { + show: true, + orient, + textStyle: { color: "#64748b" }, + }; + + if (position === "top") { + legend.top = 0; + legend.left = orient === "horizontal" ? "center" : 0; + } else if (position === "bottom") { + legend.bottom = 0; + legend.left = orient === "horizontal" ? "center" : 0; + } else if (position === "left") { + legend.left = 0; + legend.top = "middle"; + } else if (position === "right") { + legend.right = 0; + legend.top = "middle"; + } + if (selectedLegendMap && Object.keys(selectedLegendMap).length > 0) { + legend.selected = selectedLegendMap; + } + return legend; +} + +function resolveCartesianGrid( + base: { left: number; right: number; top: number; bottom: number }, + texts: DisplayTextConfig, +) { + const grid = { ...base }; + if (texts.showLegend !== false) { + const position = texts.legendPosition || "top"; + if (position === "top") { + grid.top = Math.max(grid.top, 44); + } else if (position === "bottom") { + grid.bottom = Math.max(grid.bottom, 44); + } else if (position === "left") { + grid.left = Math.max(grid.left, 104); + } else if (position === "right") { + grid.right = Math.max(grid.right, 104); + } + } + if (texts.gridTop !== undefined) { + grid.top = texts.gridTop; + } + if (texts.gridRight !== undefined) { + grid.right = texts.gridRight; + } + if (texts.gridBottom !== undefined) { + grid.bottom = texts.gridBottom; + } + if (texts.gridLeft !== undefined) { + grid.left = texts.gridLeft; + } + return grid; +} + +function applyCategoryAxisConfig(axis: Record, texts: DisplayTextConfig, boundaryGapDefault: boolean) { + const nextAxis = { + ...axis, + boundaryGap: texts.categoryBoundaryGap ?? boundaryGapDefault, + axisLabel: { + ...(axis.axisLabel as Record | undefined), + rotate: texts.axisLabelRotate ?? 0, + }, + }; + return nextAxis; +} + +function applyValueAxisConfig(axis: Record, texts: DisplayTextConfig) { + const nextAxis = { ...axis } as Record; + if (texts.valueAxisMin !== undefined) { + nextAxis.min = texts.valueAxisMin; + } + if (texts.valueAxisMax !== undefined) { + nextAxis.max = texts.valueAxisMax; + } + return nextAxis; +} + +function resolveLabelPosition(texts: DisplayTextConfig, fallback: string): any { + return texts.labelPosition?.trim() || fallback; +} + +function toPercentRadius(value: number | undefined, fallback: string) { + return value === undefined ? fallback : `${value}%`; +} + +function resolvePieLabelConfig(texts: DisplayTextConfig, mobile: boolean) { + const labelPosition = resolveLabelPosition(texts, "outside"); + const outsideLabel = !["inside", "center"].includes(labelPosition); + return { + labelPosition, + outsideLabel, + labelWidth: outsideLabel ? (mobile ? 84 : 132) : undefined, + labelOverflow: outsideLabel ? (mobile ? "breakAll" : "break") : undefined, + labelLineHeight: mobile ? 11 : 13, + labelFontSize: mobile ? 10 : 11, + edgeDistance: outsideLabel ? (texts.pieLabelEdgeDistance ?? (mobile ? 6 : 12)) : undefined, + bleedMargin: outsideLabel ? (texts.pieLabelBleedMargin ?? (mobile ? 2 : 8)) : undefined, + lineLength: texts.pieLabelLineLength ?? (mobile ? 8 : 14), + lineLength2: texts.pieLabelLineLength2 ?? (mobile ? 6 : 14), + outerRadiusFallback: outsideLabel ? (mobile ? "62%" : "58%") : "68%", + } as const; +} + +function resolvePieCenter(texts: DisplayTextConfig) { + if (texts.pieCenterX !== undefined || texts.pieCenterY !== undefined) { + return [`${texts.pieCenterX ?? 50}%`, `${texts.pieCenterY ?? 52}%`]; + } + if (texts.showLegend === false) { + return ["50%", "52%"]; + } + switch (texts.legendPosition || "right") { + case "left": + return ["68%", "52%"]; + case "top": + return ["50%", "58%"]; + case "bottom": + return ["50%", "44%"]; + case "right": + default: + return ["32%", "52%"]; + } +} + +function resolveRingTitleTop(texts: DisplayTextConfig) { + const centerY = texts.pieCenterY; + if (centerY !== undefined) { + return `${Math.max(16, centerY - 10)}%`; + } + return "42%"; +} + +function buildLineOption( + card: DashboardAnalyticsCard, + points: NormalizedPoint[], + texts: DisplayTextConfig, + mobile: boolean, + selectedLegendMap?: LegendSelectionMap, +): EChartsOption { + const preset = texts.chartPreset || "line-basic"; + const { labels, seriesNames: rawSeriesNames, valueMap, seriesColorMap } = collectSeries(points); + const seriesNames = resolveOrderedSeriesNames(rawSeriesNames, texts); + const { totalsByLabel, lastSeriesIndexByLabel } = buildStackedLabelMeta(labels, seriesNames, valueMap, selectedLegendMap); + const lineStyleType = texts.lineStyleType || "solid"; + const stacked = preset === "line-stacked"; + const smooth = preset === "line-smooth" || preset === "line-area"; + const totalOnlyLabels = stacked && texts.dataLabelMode === "total"; + const step = preset.startsWith("line-step") + ? (preset.replace("line-step-", "") as "start" | "middle" | "end") || texts.stepPosition || "middle" + : false; + const showArea = preset === "line-area" || stacked; + const lineWidth = texts.lineWidth || 3; + const symbolSize = texts.symbolSize || 7; + + return { + color: seriesNames.map((name, index) => seriesColorMap.get(name) || CHART_COLORS[index % CHART_COLORS.length]), + tooltip: { + ...buildTooltip("axis", mobile), + formatter: createAxisTooltipFormatter(card.valueType as ValueType, card.unit), + }, + legend: resolveLegendConfig(texts, selectedLegendMap), + grid: resolveCartesianGrid({ left: mobile ? 36 : 44, right: 18, top: 20, bottom: 30 }, texts), + xAxis: applyCategoryAxisConfig({ + type: "category", + data: labels, + axisTick: { show: false }, + axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts, false), + yAxis: applyValueAxisConfig({ + type: "value", + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts), + series: seriesNames.map((name, index) => ({ + name, + type: "line", + smooth, + step, + stack: stacked ? "total" : undefined, + areaStyle: showArea ? {} : undefined, + symbolSize, + label: { + show: Boolean(texts.showDataLabels), + position: totalOnlyLabels ? "top" : resolveLabelPosition(texts, "top"), + color: seriesColorMap.get(name) || CHART_COLORS[index % CHART_COLORS.length], + formatter: createLineLabelFormatter(labels, index, stacked, texts, totalsByLabel, lastSeriesIndexByLabel, card.valueType as ValueType, card.unit), + }, + lineStyle: { + width: lineWidth, + type: lineStyleType, + }, + data: labels.map((label) => valueMap.get(`${name}::${label}`) ?? null), + })), + animationDuration: 500, + }; +} + +function buildBarRaceOption( + points: NormalizedPoint[], + texts: DisplayTextConfig, + frameLabel?: string, + limit = 8, +): EChartsOption { + const { labels, seriesNames, valueMap, labelColorMap } = collectSeries(points); + const seriesName = seriesNames[0] || "默认系列"; + const sorted = labels + .map((label) => ({ + label, + value: valueMap.get(`${seriesName}::${label}`) ?? 0, + color: labelColorMap.get(label) || "#2563eb", + })) + .sort((left, right) => right.value - left.value) + .slice(0, Math.max(limit, 3)); + + return { + color: sorted.map((item) => item.color), + title: frameLabel ? { + text: frameLabel, + left: "right", + top: 0, + textStyle: { color: "#94a3b8", fontSize: 12, fontWeight: 600 }, + } : undefined, + tooltip: { ...buildTooltip("axis", false), axisPointer: { type: "shadow" }, formatter: createAxisTooltipFormatter("number") }, + grid: resolveCartesianGrid({ left: 84, right: 32, top: frameLabel ? 32 : 16, bottom: 16 }, texts), + xAxis: applyValueAxisConfig({ + type: "value", + max: "dataMax", + axisLabel: { color: "#94a3b8", fontSize: 11 }, + splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } }, + }, texts), + yAxis: applyCategoryAxisConfig({ + type: "category", + inverse: true, + data: sorted.map((item) => item.label), + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { color: "#64748b", fontSize: 12 }, + animationDuration: 300, + animationDurationUpdate: 300, + }, texts, true), + series: [ + { + type: "bar", + realtimeSort: true, + data: sorted.map((item) => item.value), + label: { + show: texts.showDataLabels !== false, + position: resolveLabelPosition(texts, "right"), + valueAnimation: true, + color: "#334155", + formatter: ({ value }: any) => formatNumberValue(Number(value ?? 0)), + }, + itemStyle: { + borderRadius: [0, texts.barBorderRadius || 8, texts.barBorderRadius || 8, 0], + }, + animationDuration: 0, + animationDurationUpdate: 1000, + }, + ], + animationDuration: 0, + animationDurationUpdate: 1000, + animationEasing: "linear", + animationEasingUpdate: "linear", + }; +} + +function buildBarOption( + points: NormalizedPoint[], + card: DashboardAnalyticsCard, + texts: DisplayTextConfig, + mobile: boolean, + frameLabel?: string, + selectedLegendMap?: LegendSelectionMap, +): EChartsOption { + const preset = texts.chartPreset || "bar-basic"; + const { labels, seriesNames: rawSeriesNames, valueMap, seriesColorMap, labelColorMap } = collectSeries(points); + const seriesNames = resolveOrderedSeriesNames(rawSeriesNames, texts); + const stacked = preset === "bar-stacked" || preset === "bar-horizontal-stacked"; + const horizontal = preset === "bar-horizontal" || preset === "bar-horizontal-stacked"; + const radius = texts.barBorderRadius || 8; + const totalOnlyLabels = stacked && texts.dataLabelMode === "total"; + const { totalsByLabel, lastSeriesIndexByLabel } = buildStackedLabelMeta(labels, seriesNames, valueMap, selectedLegendMap); + const rankedLabels = horizontal ? sortLabelsByVisibleValue(labels, seriesNames, valueMap, selectedLegendMap) : labels; + + if (preset === "bar-race" || preset === "bar-race-timeline") { + return buildBarRaceOption(points, texts, frameLabel, card.displayLimit || labels.length || 8); + } + + if (preset === "bar-waterfall") { + const singleSeries = labels.map((label) => valueMap.get(`${seriesNames[0]}::${label}`) ?? 0); + let cumulative = 0; + const helpData = singleSeries.map((value) => { + const start = cumulative; + cumulative += value; + return value >= 0 ? start : cumulative; + }); + return { + color: ["rgba(0,0,0,0)", "#2563eb", "#f97316"], + tooltip: { ...buildTooltip("axis", mobile), axisPointer: { type: "shadow" }, formatter: createAxisTooltipFormatter(card.valueType as ValueType, card.unit) }, + legend: { show: false }, + grid: resolveCartesianGrid({ left: mobile ? 36 : 44, right: 18, top: 20, bottom: 30 }, texts), + xAxis: applyCategoryAxisConfig({ + type: "category", + data: labels, + axisTick: { show: false }, + axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts, true), + yAxis: applyValueAxisConfig({ + type: "value", + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts), + series: [ + { + type: "bar", + stack: "total", + silent: true, + itemStyle: { color: "rgba(0,0,0,0)" }, + emphasis: { itemStyle: { color: "rgba(0,0,0,0)" } }, + data: helpData, + }, + { + name: card.title || "增加", + type: "bar", + stack: "total", + label: { + show: Boolean(texts.showDataLabels), + position: resolveLabelPosition(texts, "top"), + formatter: ({ value }: any) => { + const numericValue = Number(value ?? 0); + return numericValue === 0 ? "" : formatNumberValue(numericValue); + }, + }, + data: singleSeries.map((value) => value >= 0 ? value : "-"), + itemStyle: { color: "#2563eb", borderRadius: [radius, radius, 0, 0] }, + }, + { + name: "减少", + type: "bar", + stack: "total", + label: { + show: Boolean(texts.showDataLabels), + position: resolveLabelPosition(texts, "bottom"), + formatter: ({ value }: any) => { + const numericValue = Number(value ?? 0); + return numericValue === 0 ? "" : formatNumberValue(numericValue); + }, + }, + data: singleSeries.map((value) => value < 0 ? value : "-"), + itemStyle: { color: "#f97316", borderRadius: [0, 0, radius, radius] }, + }, + ], + }; + } + + return { + color: seriesNames.map((name, index) => seriesColorMap.get(name) || CHART_COLORS[index % CHART_COLORS.length]), + tooltip: { ...buildTooltip("axis", mobile), axisPointer: { type: "shadow" }, formatter: createAxisTooltipFormatter(card.valueType as ValueType, card.unit) }, + legend: resolveLegendConfig(texts, selectedLegendMap), + grid: resolveCartesianGrid({ left: mobile ? 44 : 52, right: 18, top: 20, bottom: 30 }, texts), + xAxis: horizontal + ? applyValueAxisConfig({ + type: "value", + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts) + : applyCategoryAxisConfig({ + type: "category", + data: rankedLabels, + axisTick: { show: false }, + axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts, true), + yAxis: horizontal + ? applyCategoryAxisConfig({ + type: "category", + inverse: true, + data: rankedLabels, + axisTick: { show: false }, + axisLine: { lineStyle: { color: "#cbd5e1" } }, + axisLabel: { color: "#64748b", fontSize: 12, interval: 0 }, + }, texts, true) + : applyValueAxisConfig({ + type: "value", + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: "#e2e8f0", type: "dashed" } }, + axisLabel: { color: "#94a3b8", fontSize: 11 }, + }, texts), + series: seriesNames.map((name, index) => ({ + name, + type: "bar", + stack: stacked ? "total" : undefined, + barMaxWidth: texts.barMaxWidth || 34, + label: { + show: Boolean(texts.showDataLabels), + position: totalOnlyLabels ? (horizontal ? "right" : "top") : resolveLabelPosition(texts, horizontal ? "right" : "top"), + color: totalOnlyLabels ? "#334155" : undefined, + formatter: createBarLabelFormatter(rankedLabels, index, stacked, texts, totalsByLabel, lastSeriesIndexByLabel, card.valueType as ValueType, card.unit), + }, + itemStyle: { + borderRadius: horizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0], + color: seriesNames.length === 1 + ? undefined + : (seriesColorMap.get(name) || CHART_COLORS[index % CHART_COLORS.length]), + }, + data: rankedLabels.map((label) => { + const value = valueMap.get(`${name}::${label}`) ?? 0; + if (seriesNames.length === 1) { + return { + value, + itemStyle: { color: labelColorMap.get(label) || CHART_COLORS[index % CHART_COLORS.length] }, + }; + } + return value; + }), + })), + animationDuration: 500, + }; +} + +function buildPieOption( + card: DashboardAnalyticsCard, + points: NormalizedPoint[], + renderType: RenderType, + texts: DisplayTextConfig, + mobile: boolean, + selectedLegendMap?: LegendSelectionMap, +): EChartsOption { + const preset = texts.chartPreset || (renderType === "ring" ? "ring-basic" : "pie-basic"); + const { seriesNames } = collectSeries(points); + const isNested = preset === "pie-nested" || preset === "ring-nested"; + const isRing = renderType === "ring" || preset === "pie-doughnut" || preset === "ring-basic" || preset === "ring-rose" || preset === "ring-nested"; + const roseType: "area" | undefined = preset === "pie-rose" || preset === "ring-rose" ? "area" : undefined; + const total = points.reduce((sum, point) => sum + Math.max(point.value, 0), 0); + const center = resolvePieCenter(texts); + const pieLabelConfig = resolvePieLabelConfig(texts, mobile); + const { labelPosition, outsideLabel } = pieLabelConfig; + const outerRadius = texts.pieRadiusOuter; + const innerRadius = texts.pieRadiusInner; + const pieLabel: any = { + show: Boolean(texts.showDataLabels), + position: labelPosition, + formatter: (params: any) => { + const percent = Number(params.percent ?? 0); + return `${params.name || ""}: ${formatNumberValue(percent)}%`; + }, + width: pieLabelConfig.labelWidth, + overflow: pieLabelConfig.labelOverflow, + lineHeight: pieLabelConfig.labelLineHeight, + fontSize: pieLabelConfig.labelFontSize, + alignTo: outsideLabel ? "edge" : undefined, + edgeDistance: pieLabelConfig.edgeDistance, + bleedMargin: pieLabelConfig.bleedMargin, + }; + const pieLabelLine: any = { + show: Boolean(texts.showDataLabels) && outsideLabel, + length: pieLabelConfig.lineLength, + length2: pieLabelConfig.lineLength2, + maxSurfaceAngle: 80, + }; + + if (isNested && seriesNames.length > 1) { + const nestedStart = innerRadius ?? (isRing ? 24 : 0); + const nestedOuter = outerRadius ?? (isRing ? 78 : 80); + const availableRadius = Math.max(nestedOuter - nestedStart, seriesNames.length * 10); + const step = availableRadius / seriesNames.length; + const nestedSeries = seriesNames.map((seriesName, index) => { + const inner = nestedStart + index * step; + const outer = nestedStart + (index + 1) * step - 4; + const data = points + .filter((point) => point.seriesName === seriesName) + .map((point) => ({ + value: Math.max(point.value, 0), + name: point.label, + itemStyle: { color: point.color }, + })); + const resolvedLabelPosition = resolveLabelPosition(texts, index === seriesNames.length - 1 ? "outside" : "inside"); + return { + name: seriesName, + type: "pie", + radius: isRing || inner > 0 ? [`${Math.max(inner, 0)}%`, `${Math.max(outer, inner + 6)}%`] : `${Math.max(outer, 12)}%`, + center, + roseType, + avoidLabelOverlap: true, + label: index === seriesNames.length - 1 + ? { ...pieLabel, position: resolvedLabelPosition, show: Boolean(texts.showDataLabels) } + : { show: false, position: resolvedLabelPosition, formatter: pieLabel.formatter }, + labelLine: { + ...pieLabelLine, + show: Boolean(texts.showDataLabels) && !["inside", "center"].includes(resolvedLabelPosition), + }, + labelLayout: { moveOverlap: "shiftY", hideOverlap: false }, + data, + } as any; + }); + return { + tooltip: { ...buildTooltip("item", mobile), formatter: createItemTooltipFormatter(card.valueType as ValueType, card.unit) }, + legend: resolveLegendConfig(texts, selectedLegendMap), + title: isRing ? { + text: texts.centerLabel || "总计", + subtext: String(total), + left: center[0], + top: resolveRingTitleTop(texts), + textAlign: "center", + textStyle: { fontSize: 12, fontWeight: 600, color: "#94a3b8" }, + subtextStyle: { fontSize: 24, fontWeight: 700, color: "#0f172a" }, + } : undefined, + series: nestedSeries, + animationDuration: 500, + }; + } + + const resolvedOuterRadius = toPercentRadius(outerRadius, pieLabelConfig.outerRadiusFallback); + const resolvedInnerRadius = toPercentRadius(innerRadius, isRing ? "42%" : "0%"); + return { + color: points.map((point) => point.color), + tooltip: { ...buildTooltip("item", mobile), formatter: createItemTooltipFormatter(card.valueType as ValueType, card.unit) }, + legend: resolveLegendConfig(texts, selectedLegendMap), + title: isRing ? { + text: texts.centerLabel || "总计", + subtext: String(total), + left: center[0], + top: resolveRingTitleTop(texts), + textAlign: "center", + textStyle: { fontSize: 12, fontWeight: 600, color: "#94a3b8" }, + subtextStyle: { fontSize: 24, fontWeight: 700, color: "#0f172a" }, + } : undefined, + series: [ + { + name: "数据分布", + type: "pie", + roseType, + radius: isRing ? [resolvedInnerRadius, resolvedOuterRadius] : resolvedOuterRadius, + center, + avoidLabelOverlap: true, + label: pieLabel, + labelLine: pieLabelLine, + labelLayout: { moveOverlap: "shiftY", hideOverlap: false }, + data: points.map((point) => ({ + value: Math.max(point.value, 0), + name: point.label, + itemStyle: { color: point.color }, + })), + }, + ], + animationDuration: 500, + }; +} + +function buildFunnelOption(points: NormalizedPoint[], texts: DisplayTextConfig, selectedLegendMap?: LegendSelectionMap): EChartsOption { + const preset = texts.chartPreset || "funnel-basic"; + const { seriesNames } = collectSeries(points); + const funnelSort = texts.funnelSort || "descending"; + const funnelGap = texts.funnelGap ?? 2; + + if ((preset === "funnel-contrast" || preset === "funnel-multiple") && seriesNames.length > 1) { + const maxValue = Math.max(...points.map((point) => point.value), 0); + const segmentWidth = seriesNames.length <= 2 ? 36 : Math.max(24, 80 / seriesNames.length); + const segmentGap = seriesNames.length <= 2 ? 10 : 4; + const baseLeft = 6; + + return { + tooltip: { trigger: "item", formatter: createItemTooltipFormatter("number") }, + legend: resolveLegendConfig(texts, selectedLegendMap), + series: seriesNames.map((seriesName, index) => { + const left = `${baseLeft + index * (segmentWidth + segmentGap)}%`; + const data = points + .filter((point) => point.seriesName === seriesName) + .map((point) => ({ + value: Math.max(point.value, 0), + name: point.label, + itemStyle: { color: point.color }, + })); + const labelPosition = resolveLabelPosition(texts, texts.showDataLabels ? "inside" : (index === seriesNames.length - 1 ? "right" : "left")); + return { + name: seriesName, + type: "funnel", + left, + top: texts.showLegend === false ? 12 : 38, + bottom: 12, + width: `${segmentWidth}%`, + min: 0, + max: maxValue, + minSize: "20%", + maxSize: "100%", + sort: funnelSort, + gap: funnelGap, + label: { + show: true, + position: labelPosition, + formatter: ({ name, percent }: any) => `${name || ""}\n${formatNumberValue(Number(percent ?? 0))}%`, + }, + labelLine: { + show: !["inside", "center"].includes(labelPosition), + length: 14, + lineStyle: { width: 1, type: "solid" }, + }, + itemStyle: { + borderColor: "#fff", + borderWidth: 1, + }, + emphasis: { + label: { fontSize: 15 }, + }, + data, + } as any; + }), + animationDuration: 500, + }; + } + + const labelPosition = resolveLabelPosition(texts, texts.showDataLabels ? "inside" : "right"); + return { + color: points.map((point) => point.color), + tooltip: { trigger: "item", formatter: createItemTooltipFormatter("number") }, + legend: resolveLegendConfig(texts, selectedLegendMap), + series: [ + { + name: "漏斗分析", + type: "funnel", + left: "10%", + top: texts.showLegend === false ? 12 : 38, + bottom: 12, + width: "80%", + min: 0, + max: Math.max(...points.map((point) => point.value), 0), + minSize: "20%", + maxSize: "100%", + sort: funnelSort, + gap: funnelGap, + label: { + show: true, + position: labelPosition, + formatter: ({ name, percent }: any) => `${name || ""}\n${formatNumberValue(Number(percent ?? 0))}%`, + }, + labelLine: { + show: !["inside", "center"].includes(labelPosition), + length: 16, + lineStyle: { width: 1, type: "solid" }, + }, + itemStyle: { + borderColor: "#fff", + borderWidth: 1, + }, + emphasis: { + label: { fontSize: 15 }, + }, + data: points.map((point) => ({ + value: Math.max(point.value, 0), + name: point.label, + itemStyle: { color: point.color }, + })), + }, + ], + animationDuration: 500, + }; +} + +function buildOption( + card: DashboardAnalyticsCard, + points: NormalizedPoint[], + texts: DisplayTextConfig, + mobile: boolean, + frameLabel?: string, + selectedLegendMap?: LegendSelectionMap, +): EChartsOption { + const renderType = card.renderType as RenderType; + let option: EChartsOption; + if (renderType === "line") { + option = buildLineOption(card, points, texts, mobile, selectedLegendMap); + } else if (renderType === "bar") { + option = buildBarOption(points, card, texts, mobile, frameLabel, selectedLegendMap); + } else if (renderType === "pie" || renderType === "ring") { + option = buildPieOption(card, points, renderType, texts, mobile, selectedLegendMap); + } else { + option = buildFunnelOption(points, texts, selectedLegendMap); + } + + const overrideOption = parseOptionOverrides(texts.optionOverrides); + return overrideOption ? mergeOptions(option, overrideOption) : option; +} + +function EmptyState({ text, height }: { text?: string; height: number }) { + return ( +
+ {text || "暂无图表数据"} +
+ ); +} + +export default function DashboardAnalyticsEChart({ + card, + expanded, + heightOverride, +}: { + card: DashboardAnalyticsCard; + expanded?: boolean; + heightOverride?: number; +}) { + const chartRef = useRef(null); + const instanceRef = useRef(null); + const isMobile = useIsMobileViewport(); + const renderType = (card.renderType || "bar") as RenderType; + const texts = useMemo( + () => parseDisplayTextConfig(card.displayTextConfig, renderType), + [card.displayTextConfig, renderType], + ); + const points = useMemo(() => normalizePoints(card.chartData ?? []), [card.chartData]); + const frameNames = useMemo(() => getFrameNames(points), [points]); + const [activeFrameIndex, setActiveFrameIndex] = useState(0); + const [legendSelection, setLegendSelection] = useState({}); + + useEffect(() => { + setActiveFrameIndex(0); + }, [card.cardKey, frameNames.join("|"), texts.chartPreset]); + + useEffect(() => { + setLegendSelection({}); + }, [card.cardKey, texts.chartPreset, frameNames.join("|")]); + + useEffect(() => { + if (texts.chartPreset !== "bar-race-timeline" || frameNames.length <= 1) { + return undefined; + } + const timer = window.setInterval(() => { + setActiveFrameIndex((current) => (current + 1) % frameNames.length); + }, 2200); + return () => window.clearInterval(timer); + }, [frameNames, texts.chartPreset]); + + const visiblePoints = useMemo(() => { + if (texts.chartPreset === "bar-race-timeline" && frameNames.length) { + const currentFrame = frameNames[Math.min(activeFrameIndex, frameNames.length - 1)]; + return points.filter((point) => point.frameName === currentFrame); + } + return points; + }, [activeFrameIndex, frameNames, points, texts.chartPreset]); + const height = useMemo( + () => resolveChartHeight(renderType, texts, visiblePoints, isMobile, expanded, heightOverride), + [expanded, heightOverride, isMobile, renderType, texts, visiblePoints], + ); + + const option = useMemo( + () => buildOption( + card, + visiblePoints, + texts, + isMobile, + texts.chartPreset === "bar-race-timeline" ? frameNames[Math.min(activeFrameIndex, Math.max(frameNames.length - 1, 0))] : undefined, + legendSelection, + ), + [activeFrameIndex, card, frameNames, isMobile, legendSelection, texts, visiblePoints], + ); + + useEffect(() => { + if (!chartRef.current || !visiblePoints.length) { + return undefined; + } + const chart = echarts.init(chartRef.current); + instanceRef.current = chart; + + const handleResize = () => { + chart.resize(); + }; + const observer = typeof ResizeObserver !== "undefined" + ? new ResizeObserver(handleResize) + : null; + if (observer) { + observer.observe(chartRef.current); + } else if (typeof window !== "undefined") { + window.addEventListener("resize", handleResize); + } + + const handleLegendSelectChanged = (event: { selected?: LegendSelectionMap }) => { + setLegendSelection(event.selected || {}); + }; + chart.on("legendselectchanged", handleLegendSelectChanged); + + return () => { + chart.off("legendselectchanged", handleLegendSelectChanged); + if (observer) { + observer.disconnect(); + } else if (typeof window !== "undefined") { + window.removeEventListener("resize", handleResize); + } + chart.dispose(); + instanceRef.current = null; + }; + }, [visiblePoints.length]); + + useEffect(() => { + if (!instanceRef.current || !visiblePoints.length) { + return; + } + instanceRef.current.setOption(option, true); + }, [option, visiblePoints.length]); + + if (!visiblePoints.length) { + return ; + } + + return
; +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 2865a74e..00b6ff54 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -114,9 +114,10 @@ export interface DashboardActivity { export interface DashboardAnalyticsCard { id?: number; cardKey?: string; + groupName?: string; title?: string; subtitle?: string; - renderType?: "metric" | "line" | "bar" | "pie" | "ring" | "funnel" | "ranking" | "table"; + renderType?: "metric" | "line" | "bar" | "pie" | "ring" | "funnel"; description?: string; value?: string; valueText?: string; @@ -127,6 +128,7 @@ export interface DashboardAnalyticsCard { layoutType?: "vertical" | "horizontal"; fullRow?: boolean; sortOrder?: number; + displayLimit?: number; errorMessage?: string; totalCount?: number; hasMore?: boolean; @@ -134,6 +136,8 @@ export interface DashboardAnalyticsCard { label?: string; value?: string; valueText?: string; + seriesName?: string; + frameName?: string; secondaryValue?: string; secondaryValueText?: string; description?: string; @@ -370,6 +374,7 @@ export interface WorkReportLineItem { evaluationContent?: string; nextPlan?: string; latestProgress?: string; + stage?: string; communicationTime?: string; communicationContent?: string; } @@ -541,6 +546,8 @@ export interface RelatedProjectSummary { opportunityId: number; opportunityCode?: string; opportunityName?: string; + stageCode?: string; + stage?: string; amount?: number; } @@ -595,6 +602,8 @@ export interface ChannelRelatedProjectSummary { opportunityId: number; opportunityCode?: string; opportunityName?: string; + stageCode?: string; + stage?: string; amount?: number; } @@ -1010,8 +1019,9 @@ export async function getDashboardHome() { return request("/api/dashboard/home", undefined, true); } -export async function getDashboardAnalyticsCardDetail(cardKey: string) { - return request(`/api/dashboard/analytics-cards/${encodeURIComponent(cardKey)}`, undefined, true); +export async function getDashboardAnalyticsCardDetail(cardKey: string, dimension?: string) { + const params = dimension ? `?dimension=${encodeURIComponent(dimension)}` : ""; + return request(`/api/dashboard/analytics-cards/${encodeURIComponent(cardKey)}${params}`, undefined, true); } export async function completeDashboardTodo(todoId: string) { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f074ced1..78a90ac7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Activity, ArrowDownRight, @@ -8,6 +8,7 @@ import { BriefcaseBusiness, Building2, CalendarDays, + ChevronDown, Check, CircleDollarSign, ClipboardList, @@ -72,7 +73,29 @@ const amountMetricKeys = new Set<(typeof baseStats)[number]["metricKey"]>([ type AnalyticsCardDisplayConfig = Partial<{ horizontalColumns: number; metricIconKey: string; + metricStylePreset: "classic" | "gradient" | "contrast" | "soft"; + metricTheme: "blue" | "green" | "orange" | "purple" | "red" | "slate"; + metricIconShape: "rounded-square" | "circle" | "pill"; + metricTrendMode: "auto" | "up" | "down" | "neutral"; + metricBadgeText: string; + dimensionSwitchEnabled: boolean; + defaultDimension: string; + dimensionOptions: Array<{ value: string; label?: string }>; + pcLayout: { + preset?: "auto" | "left" | "right" | "top" | "bottom" | "center"; + chartWidthPercent?: number; + chartHeight?: number; + }; + mobileLayout: { + preset?: "auto" | "left" | "right" | "top" | "bottom" | "center"; + chartWidthPercent?: number; + chartHeight?: number; + }; }>; +type AnalyticsDimensionOption = { + value: string; + label: string; +}; type MetricIconComponent = | typeof Activity | typeof BadgeDollarSign @@ -105,6 +128,19 @@ type MetricVisual = { iconClassName: string; backgroundClassName: string; }; +type GroupedDashboardCard = { + key: string; + title: string; + isDefault: boolean; + cards: T[]; +}; +type AnalyticsGroupTab = { + key: string; + title: string; +}; + +const ANALYTICS_ALL_TAB_KEY = "__all__"; +const DASHBOARD_ANALYTICS_TAB_STORAGE_KEY = "crm-dashboard-analytics-tab"; function formatStatDisplay(metricKey: (typeof baseStats)[number]["metricKey"], value?: number) { const numericValue = value ?? 0; @@ -155,12 +191,64 @@ function parseAnalyticsDisplayConfig(raw?: string): AnalyticsCardDisplayConfig { } try { const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed as AnalyticsCardDisplayConfig : {}; + if (!parsed || typeof parsed !== "object") { + return {}; + } + const config = parsed as Record; + const normalizeDimensionOptions = (value: unknown): AnalyticsDimensionOption[] => { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => { + if (typeof item === "string") { + const normalized = item.trim(); + return normalized ? { value: normalized, label: normalized } : null; + } + if (!item || typeof item !== "object") { + return null; + } + const option = item as Record; + const optionValue = typeof option.value === "string" ? option.value.trim() : ""; + if (!optionValue) { + return null; + } + const optionLabel = typeof option.label === "string" ? option.label.trim() : optionValue; + return { value: optionValue, label: optionLabel }; + }) + .filter(Boolean) as AnalyticsDimensionOption[]; + }; + return { + ...(config as AnalyticsCardDisplayConfig), + dimensionSwitchEnabled: Boolean(config.dimensionSwitchEnabled), + defaultDimension: typeof config.defaultDimension === "string" ? config.defaultDimension : undefined, + dimensionOptions: normalizeDimensionOptions(config.dimensionOptions), + pcLayout: config.pcLayout && typeof config.pcLayout === "object" ? config.pcLayout as AnalyticsCardDisplayConfig["pcLayout"] : undefined, + mobileLayout: config.mobileLayout && typeof config.mobileLayout === "object" ? config.mobileLayout as AnalyticsCardDisplayConfig["mobileLayout"] : undefined, + }; } catch { return {}; } } +function getAnalyticsDimensionOptions(card: DashboardAnalyticsCard): AnalyticsDimensionOption[] { + const config = parseAnalyticsDisplayConfig(card.displayTextConfig); + if (!config.dimensionSwitchEnabled) { + return []; + } + return (config.dimensionOptions || []).filter((item): item is AnalyticsDimensionOption => Boolean(item?.value)); +} + +function getAnalyticsDefaultDimension(card: DashboardAnalyticsCard) { + const config = parseAnalyticsDisplayConfig(card.displayTextConfig); + const options = getAnalyticsDimensionOptions(card); + const configuredDefault = config.defaultDimension?.trim(); + if (configuredDefault && options.some((item) => item.value === configuredDefault)) { + return configuredDefault; + } + return options[0]?.value || ""; +} + function resolveHorizontalColumns(card: DashboardAnalyticsCard) { const config = parseAnalyticsDisplayConfig(card.displayTextConfig); const horizontalColumns = Number(config.horizontalColumns); @@ -216,6 +304,30 @@ const LEGACY_METRIC_ICON_KEY_MAP: Record = { channel: "building-2", }; +const METRIC_THEME_COLORS = { + blue: { accent: "#2563eb", soft: "#dbeafe", pale: "#eff6ff", dark: "#1d4ed8" }, + green: { accent: "#059669", soft: "#a7f3d0", pale: "#ecfdf5", dark: "#047857" }, + orange: { accent: "#ea580c", soft: "#fdba74", pale: "#fff7ed", dark: "#c2410c" }, + purple: { accent: "#7c3aed", soft: "#c4b5fd", pale: "#f5f3ff", dark: "#6d28d9" }, + red: { accent: "#dc2626", soft: "#fca5a5", pale: "#fef2f2", dark: "#b91c1c" }, + slate: { accent: "#475569", soft: "#cbd5e1", pale: "#f8fafc", dark: "#334155" }, +} as const; + +function hexToRgba(hex: string, alpha: number) { + const normalized = hex.replace("#", ""); + const full = normalized.length === 3 + ? normalized.split("").map((char) => `${char}${char}`).join("") + : normalized; + const value = Number.parseInt(full, 16); + if (!Number.isFinite(value)) { + return `rgba(37, 99, 235, ${alpha})`; + } + const red = (value >> 16) & 255; + const green = (value >> 8) & 255; + const blue = value & 255; + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + function resolveConfiguredMetricIconKey(metricIconKey?: string) { if (!metricIconKey) { return ""; @@ -226,13 +338,150 @@ function resolveConfiguredMetricIconKey(metricIconKey?: string) { return LEGACY_METRIC_ICON_KEY_MAP[metricIconKey] || ""; } -function getAnalyticsCardLayoutClass(card: DashboardAnalyticsCard) { - if (card.renderType === "table" || card.fullRow) { +function resolveMetricThemeKey(config: AnalyticsCardDisplayConfig, iconKey?: string): keyof typeof METRIC_THEME_COLORS { + if (config.metricTheme && config.metricTheme in METRIC_THEME_COLORS) { + return config.metricTheme; + } + if (iconKey && ["shopping-cart", "store", "package", "trophy"].includes(iconKey)) { + return "orange"; + } + if (iconKey && ["users", "building-2", "rocket", "pie-chart"].includes(iconKey)) { + return "purple"; + } + if (iconKey && ["trending-up", "activity", "phone-call", "hand-coins"].includes(iconKey)) { + return "green"; + } + if (iconKey && ["calendar-days", "target", "megaphone"].includes(iconKey)) { + return "red"; + } + if (iconKey && ["briefcase-business", "file-text", "landmark", "clipboard-list"].includes(iconKey)) { + return "slate"; + } + return "blue"; +} + +function getMetricCardStyles(config: AnalyticsCardDisplayConfig, iconKey?: string) { + const theme = METRIC_THEME_COLORS[resolveMetricThemeKey(config, iconKey)]; + const preset = config.metricStylePreset || "classic"; + const iconShape = config.metricIconShape || "rounded-square"; + const iconRadius = iconShape === "circle" ? 999 : iconShape === "pill" ? 24 : 18; + + if (preset === "contrast") { + return { + container: { + border: `1px solid ${hexToRgba("#ffffff", 0.08)}`, + background: `linear-gradient(135deg, #0f172a 0%, ${theme.dark} 100%)`, + boxShadow: `0 16px 36px ${hexToRgba(theme.accent, 0.2)}`, + }, + iconShell: { + background: hexToRgba("#ffffff", 0.14), + borderRadius: iconRadius, + boxShadow: `inset 0 0 0 1px ${hexToRgba("#ffffff", 0.1)}`, + }, + iconColor: "#ffffff", + titleColor: hexToRgba("#ffffff", 0.72), + valueColor: "#ffffff", + metaColor: hexToRgba("#ffffff", 0.84), + badgeStyle: { + background: hexToRgba("#ffffff", 0.12), + color: "#ffffff", + border: `1px solid ${hexToRgba("#ffffff", 0.16)}`, + }, + }; + } + + if (preset === "gradient") { + return { + container: { + border: `1px solid ${hexToRgba(theme.soft, 0.42)}`, + background: `linear-gradient(135deg, ${hexToRgba(theme.accent, 0.14)} 0%, #ffffff 52%, ${hexToRgba(theme.soft, 0.62)} 100%)`, + boxShadow: `0 12px 28px ${hexToRgba(theme.accent, 0.08)}`, + }, + iconShell: { + background: `linear-gradient(135deg, ${theme.accent} 0%, ${theme.dark} 100%)`, + borderRadius: iconRadius, + boxShadow: `0 14px 28px ${hexToRgba(theme.accent, 0.22)}`, + }, + iconColor: "#ffffff", + titleColor: theme.dark, + valueColor: "#0f172a", + metaColor: "#475569", + badgeStyle: { + background: hexToRgba(theme.accent, 0.1), + color: theme.dark, + border: `1px solid ${hexToRgba(theme.accent, 0.16)}`, + }, + }; + } + + if (preset === "soft") { + return { + container: { + border: `1px solid ${hexToRgba(theme.accent, 0.12)}`, + background: `radial-gradient(circle at top right, ${hexToRgba(theme.soft, 0.68)} 0%, #ffffff 58%)`, + boxShadow: `0 12px 28px ${hexToRgba(theme.accent, 0.06)}`, + }, + iconShell: { + background: hexToRgba(theme.accent, 0.12), + borderRadius: iconRadius, + }, + iconColor: theme.accent, + titleColor: "#64748b", + valueColor: "#0f172a", + metaColor: "#64748b", + badgeStyle: { + background: hexToRgba(theme.accent, 0.08), + color: theme.dark, + border: `1px solid ${hexToRgba(theme.accent, 0.14)}`, + }, + }; + } + + return { + container: { + border: `1px solid ${hexToRgba(theme.accent, 0.12)}`, + background: `linear-gradient(180deg, #ffffff 0%, ${theme.pale} 100%)`, + boxShadow: `0 12px 28px ${hexToRgba(theme.accent, 0.06)}`, + }, + iconShell: { + background: hexToRgba(theme.accent, 0.12), + borderRadius: iconRadius, + }, + iconColor: theme.accent, + titleColor: "#64748b", + valueColor: "#0f172a", + metaColor: "#475569", + badgeStyle: { + background: hexToRgba(theme.accent, 0.08), + color: theme.dark, + border: `1px solid ${hexToRgba(theme.accent, 0.14)}`, + }, + }; +} + +function getAnalyticsCardLayoutClass(card: DashboardAnalyticsCard, isMobileViewport: boolean) { + if (card.fullRow) { + return "col-span-12"; + } + if (isMobileViewport && isChartAnalyticsCard(card)) { return "col-span-12"; } if (card.layoutType !== "horizontal") { return "col-span-12"; } + if (isMobileViewport) { + switch (resolveHorizontalColumns(card)) { + case 1: + return "col-span-12"; + case 3: + return "col-span-4"; + case 4: + return "col-span-3"; + case 2: + default: + return "col-span-6"; + } + } switch (resolveHorizontalColumns(card)) { case 1: return "col-span-12"; @@ -280,11 +529,8 @@ function getAnalyticsMetricVisual(card: DashboardAnalyticsCard, index: number) { return visualOptions[index % visualOptions.length]; } -function getAnalyticsMetricFootnote(card: DashboardAnalyticsCard) { +function getAnalyticsMetricFootnote(card: DashboardAnalyticsCard, config: AnalyticsCardDisplayConfig) { const text = card.description?.trim() || card.subtitle?.trim() || ""; - if (!text) { - return null; - } const normalized = text.toLowerCase(); const numericMatch = normalized.match(/[+-]?\d+(?:\.\d+)?%?/); const numericValue = numericMatch ? Number.parseFloat(numericMatch[0].replace("%", "")) : Number.NaN; @@ -298,12 +544,76 @@ function getAnalyticsMetricFootnote(card: DashboardAnalyticsCard) { || normalized.includes("下滑") || normalized.includes("减少") || (Number.isFinite(numericValue) && numericValue < 0); + const autoTone = positive ? "up" : negative ? "down" : "neutral"; + const tone = config.metricTrendMode && config.metricTrendMode !== "auto" ? config.metricTrendMode : autoTone; + const fallbackText = tone === "up" + ? "较上周期保持增长" + : tone === "down" + ? "较上周期小幅回落" + : "趋势保持平稳"; + if (!text && config.metricTrendMode === "auto") { + return null; + } return { - text, - tone: positive ? "up" : negative ? "down" : "neutral", + text: text || fallbackText, + tone, } as const; } +function groupDashboardCards(cards: T[]): GroupedDashboardCard[] { + const grouped = new Map>(); + cards.forEach((card) => { + const groupName = card.groupName?.trim() || ""; + const groupKey = groupName || "__default__"; + const current = grouped.get(groupKey); + if (current) { + current.cards.push(card); + return; + } + grouped.set(groupKey, { + key: groupKey, + title: groupName, + isDefault: !groupName, + cards: [card], + }); + }); + return Array.from(grouped.values()); +} + +function buildAnalyticsGroupTabs(sections: GroupedDashboardCard[]) { + if (sections.length <= 1) { + return [] as AnalyticsGroupTab[]; + } + return [ + { key: ANALYTICS_ALL_TAB_KEY, title: "全部" }, + ...sections.map((section) => ({ + key: section.key, + title: section.title || "未分组", + })), + ]; +} + +function resolveMobileMetricScale(card: DashboardAnalyticsCard, isMobileViewport: boolean) { + if (!isMobileViewport || card.layoutType !== "horizontal" || card.fullRow || isChartAnalyticsCard(card)) { + return 1; + } + const columns = resolveHorizontalColumns(card); + if (columns <= 2) { + return 1; + } + return Number((2 / columns).toFixed(4)); +} + +function resolveMobileMetricCompactLevel(scale: number) { + if (scale <= 0.68) { + return 2; + } + if (scale < 1) { + return 1; + } + return 0; +} + export default function Dashboard() { const navigate = useNavigate(); const isMobileViewport = useIsMobileViewport(); @@ -318,6 +628,17 @@ export default function Dashboard() { const [detailCard, setDetailCard] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(""); + const [analyticsCardOverrides, setAnalyticsCardOverrides] = useState>({}); + const [analyticsSelectedDimensions, setAnalyticsSelectedDimensions] = useState>({}); + const [analyticsDimensionLoadingKey, setAnalyticsDimensionLoadingKey] = useState(""); + const [openDimensionCardKey, setOpenDimensionCardKey] = useState(null); + const dimensionMenuRef = useRef(null); + const [activeAnalyticsTab, setActiveAnalyticsTab] = useState(() => { + if (typeof window === "undefined") { + return ANALYTICS_ALL_TAB_KEY; + } + return window.localStorage.getItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY) || ANALYTICS_ALL_TAB_KEY; + }); useEffect(() => { let cancelled = false; @@ -376,7 +697,65 @@ export default function Dashboard() { const showTodoCard = home.todoCardVisible !== false; const showActivityCard = home.activityCardVisible !== false; const showAnalyticsCard = home.analyticsCardVisible !== false && home.analyticsPanel?.enabled === true; - const analyticsCards = (home.analyticsPanel?.cards ?? []).filter((item) => !item.errorMessage); + const analyticsCards = useMemo( + () => (home.analyticsPanel?.cards ?? []) + .filter((item) => !item.errorMessage) + .map((item) => { + const override = item.cardKey ? analyticsCardOverrides[item.cardKey] : undefined; + return override ? { ...item, ...override } : item; + }), + [analyticsCardOverrides, home.analyticsPanel?.cards], + ); + const analyticsSections = useMemo( + () => groupDashboardCards(analyticsCards), + [analyticsCards], + ); + const analyticsTabs = useMemo( + () => buildAnalyticsGroupTabs(analyticsSections), + [analyticsSections], + ); + const visibleAnalyticsSections = useMemo( + () => ( + activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY || !analyticsTabs.length + ? analyticsSections + : analyticsSections.filter((section) => section.key === activeAnalyticsTab) + ), + [activeAnalyticsTab, analyticsSections, analyticsTabs.length], + ); + + useEffect(() => { + if (!analyticsTabs.length) { + if (activeAnalyticsTab !== ANALYTICS_ALL_TAB_KEY) { + setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY); + } + return; + } + if (!analyticsTabs.some((item) => item.key === activeAnalyticsTab)) { + setActiveAnalyticsTab(ANALYTICS_ALL_TAB_KEY); + } + }, [activeAnalyticsTab, analyticsTabs]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(DASHBOARD_ANALYTICS_TAB_STORAGE_KEY, activeAnalyticsTab); + }, [activeAnalyticsTab]); + + useEffect(() => { + if (!openDimensionCardKey) { + return undefined; + } + const handlePointerDown = (event: MouseEvent) => { + if (dimensionMenuRef.current?.contains(event.target as Node)) { + return; + } + setOpenDimensionCardKey(null); + }; + window.addEventListener("mousedown", handlePointerDown); + return () => window.removeEventListener("mousedown", handlePointerDown); + }, [openDimensionCardKey]); + const handleCompleteTodo = async (todoId: string) => { if (!todoId || completingTodoId === todoId) { return; @@ -431,10 +810,41 @@ export default function Dashboard() { void openAnalyticsCardDetail(card); }; + const handleAnalyticsDimensionChange = async (card: DashboardAnalyticsCard, dimension: string) => { + if (!card?.cardKey || !dimension || analyticsDimensionLoadingKey === card.cardKey) { + return; + } + setOpenDimensionCardKey(null); + setAnalyticsDimensionLoadingKey(card.cardKey); + setAnalyticsSelectedDimensions((current) => ({ + ...current, + [card.cardKey as string]: dimension, + })); + try { + const data = await getDashboardAnalyticsCardDetail(card.cardKey, dimension); + setAnalyticsCardOverrides((current) => ({ + ...current, + [card.cardKey as string]: data, + })); + if (detailCard?.cardKey === card.cardKey) { + setDetailCard(data); + } + } catch { + setAnalyticsSelectedDimensions((current) => { + const next = { ...current }; + delete next[card.cardKey as string]; + return next; + }); + } finally { + setAnalyticsDimensionLoadingKey(""); + } + }; + const openAnalyticsCardDetail = async (card: DashboardAnalyticsCard) => { if (!card?.cardKey || !supportsAnalyticsDetail(card)) { return; } + const selectedDimension = analyticsSelectedDimensions[card.cardKey] || getAnalyticsDefaultDimension(card); setDetailLoading(true); setDetailError(""); setDetailCard({ @@ -442,7 +852,7 @@ export default function Dashboard() { chartData: [], }); try { - const data = await getDashboardAnalyticsCardDetail(card.cardKey); + const data = await getDashboardAnalyticsCardDetail(card.cardKey, selectedDimension || undefined); setDetailCard(data); } catch (error) { setDetailError(error instanceof Error ? error.message : "加载完整卡片详情失败"); @@ -643,17 +1053,92 @@ export default function Dashboard() { transition={disableMobileMotion ? { duration: 0 } : { delay: 0.45 }} > {analyticsCards.length ? ( -
- {analyticsCards.map((card, index) => { +
+
+ {home.analyticsPanel?.title || home.analyticsPanel?.subtitle ? ( +
+
+ {home.analyticsPanel?.title ? ( +

{home.analyticsPanel.title}

+ ) : null} + {home.analyticsPanel?.subtitle ? ( +

{home.analyticsPanel.subtitle}

+ ) : null} +
+
+ ) : null} + {analyticsTabs.length ? ( +
+
+ {analyticsTabs.map((tab) => { + const active = tab.key === activeAnalyticsTab; + return ( + + ); + })} +
+
+ ) : null} +
+ {visibleAnalyticsSections.map((section) => { + const showSectionShell = !section.isDefault || analyticsSections.length > 1; + const showSectionTitle = showSectionShell && activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY; + return ( +
+ {showSectionTitle ? ( +
+

+ {section.title || "未分组"} +

+
+ ) : null} +
+ {section.cards.map((card, index) => { const clickable = supportsAnalyticsDetail(card); const chartCard = isChartAnalyticsCard(card); const horizontalColumns = resolveHorizontalColumns(card); - const compactMobileCard = isMobileViewport && card.layoutType === "horizontal" && !card.fullRow && horizontalColumns >= 3; - const ultraCompactMobileCard = compactMobileCard && horizontalColumns >= 4; + const mobileMetricScale = resolveMobileMetricScale(card, isMobileViewport); + const mobileMetricCompactLevel = resolveMobileMetricCompactLevel(mobileMetricScale); + const compactMobileCard = isMobileViewport && card.layoutType === "horizontal" && !card.fullRow && !chartCard && mobileMetricCompactLevel >= 1; + const ultraCompactMobileCard = compactMobileCard && mobileMetricCompactLevel >= 2; + const metricDisplayConfig = parseAnalyticsDisplayConfig(card.displayTextConfig); const metricVisual = getAnalyticsMetricVisual(card, index); - const metricFootnote = getAnalyticsMetricFootnote(card); + const metricStyles = getMetricCardStyles(metricDisplayConfig, metricVisual.key); const MetricIcon = metricVisual.icon; const cardSummary = getAnalyticsCardPreviewSummary(card); + const dimensionOptions = getAnalyticsDimensionOptions(card); + const selectedDimension = card.cardKey + ? analyticsSelectedDimensions[card.cardKey] || getAnalyticsDefaultDimension(card) + : ""; + const dimensionLoading = card.cardKey ? analyticsDimensionLoadingKey === card.cardKey : false; + const hideMetricDecorationLevel1 = isMobileViewport && mobileMetricScale <= 0.82; + const hideMetricDecorationLevel2 = isMobileViewport && mobileMetricScale <= 0.68; + const showMetricIcon = !hideMetricDecorationLevel2; + const showMetricBadge = Boolean(metricDisplayConfig.metricBadgeText?.trim()) && !hideMetricDecorationLevel1; + const showMetricSubtitle = Boolean(card.subtitle) && !hideMetricDecorationLevel1; + const metricScaleStyle = mobileMetricScale < 1 + ? { + zoom: mobileMetricScale, + width: `${100 / mobileMetricScale}%`, + } + : undefined; return (
{chartCard ? ( <> @@ -696,9 +1180,68 @@ export default function Dashboard() {

) : null}
- - - + {dimensionOptions.length ? ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {openDimensionCardKey === card.cardKey ? ( +
+ {dimensionOptions.map((option) => { + const active = option.value === selectedDimension; + return ( + + ); + })} +
+ ) : null} +
+ ) : ( + + + + )}
{cardSummary ? ( @@ -708,53 +1251,62 @@ export default function Dashboard() { ) : null} ) : ( - <> -
- +
+
+ {showMetricIcon ? ( +
+ +
+ ) : ( + + )} + {showMetricBadge ? ( + + {metricDisplayConfig.metricBadgeText?.trim()} + + ) : null}

+ } ${showMetricIcon ? "mt-3" : "mt-1.5"} line-clamp-2 font-bold uppercase tracking-wider`} style={{ color: metricStyles.titleColor }}> {card.title || "未命名卡片"}

+ } font-bold`} style={{ color: metricStyles.valueColor }}> {card.valueText || card.value || "0"}

- {metricFootnote ? ( -
- {metricFootnote.tone === "up" ? : null} - {metricFootnote.tone === "down" ? : null} - {metricFootnote.text} -
- ) : card.subtitle ? ( -

{card.subtitle}

) : null} - +
)}
); + })} +
+ + ); })}
) : ( diff --git a/frontend/src/pages/Expansion.tsx b/frontend/src/pages/Expansion.tsx index f2ce1534..29b4f63c 100644 --- a/frontend/src/pages/Expansion.tsx +++ b/frontend/src/pages/Expansion.tsx @@ -11,6 +11,7 @@ import { getExpansionCityOptions, getExpansionMeta, getExpansionOverview, + getOpportunityMeta, getStoredCurrentUserId, updateChannelExpansion, updateSalesExpansion, @@ -43,6 +44,7 @@ type ExpansionExportFilters = { establishedStartDate?: string; establishedEndDate?: string; hasRelatedProject?: string; + relatedProjectStageCodes?: string[]; selectedSalesFields?: SalesExportFieldKey[]; selectedChannelFields?: ChannelExportFieldKey[]; }; @@ -85,11 +87,20 @@ type ChannelExportFieldKey = | "updatedAt" | "followUps"; type ExportColumnKind = "default" | "longText" | "project" | "contact" | "followup"; +type ExportCellValue = string | number; +type RelatedProjectLike = { + opportunityCode?: string; + opportunityName?: string; + stageCode?: string; + stage?: string; + amount?: number | null; +}; type ExportColumn = { key: K; label: string; kind?: ExportColumnKind; - value: (item: T) => string; + numFmt?: string; + value: (item: T) => ExportCellValue; }; type SalesCreateField = | "employeeNo" @@ -229,6 +240,21 @@ function normalizeExportText(value?: string | number | boolean | null) { return normalized; } +function normalizeExportNumber(value?: string | number | null) { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + const normalized = value.replace(/[¥,\s]|人/g, "").trim(); + if (!normalized) { + return undefined; + } + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + function normalizeExportFilterText(value?: string | number | boolean | null) { return normalizeExportText(value).toLowerCase(); } @@ -274,6 +300,86 @@ function matchesRelatedProjectFilter(projects: Array<{ amount?: number }> | unde return true; } +function normalizeMultiSelectValues(values?: string[]) { + return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); +} + +function getDictOptionValue(option?: ExpansionDictOption) { + const value = option?.value?.trim() || option?.label?.trim() || ""; + return value; +} + +function getDictOptionLabel(option?: ExpansionDictOption) { + return option?.label?.trim() || option?.value?.trim() || ""; +} + +function isLostProjectStageOption(option?: ExpansionDictOption) { + const value = getDictOptionValue(option).toLowerCase(); + const label = getDictOptionLabel(option).toLowerCase(); + return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃"); +} + +function getDefaultRelatedProjectStageCodes(options: ExpansionDictOption[]) { + const orderedValues = options.map(getDictOptionValue).filter(Boolean); + const preferredValues = options.filter((option) => !isLostProjectStageOption(option)).map(getDictOptionValue).filter(Boolean); + return normalizeMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues); +} + +function applyDefaultRelatedProjectStageFilters(filters: ExpansionExportFilters, options: ExpansionDictOption[]) { + if (filters.relatedProjectStageCodes !== undefined) { + return { + ...filters, + relatedProjectStageCodes: normalizeMultiSelectValues(filters.relatedProjectStageCodes), + }; + } + if (options.length <= 0) { + return filters; + } + return { + ...filters, + relatedProjectStageCodes: getDefaultRelatedProjectStageCodes(options), + }; +} + +function areSameStringSets(leftValues?: string[], rightValues?: string[]) { + const left = normalizeMultiSelectValues(leftValues).sort(); + const right = normalizeMultiSelectValues(rightValues).sort(); + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function filterRelatedProjectsByStage(projects: T[] | undefined, selectedStageCodes?: string[]) { + const projectList = projects ?? []; + if (selectedStageCodes === undefined) { + return projectList; + } + + const normalizedStageCodes = new Set(normalizeMultiSelectValues(selectedStageCodes)); + if (normalizedStageCodes.size <= 0) { + return []; + } + + return projectList.filter((project) => { + const projectStageCode = project.stageCode?.trim(); + const projectStageLabel = project.stage?.trim(); + return (projectStageCode && normalizedStageCodes.has(projectStageCode)) + || (projectStageLabel && normalizedStageCodes.has(projectStageLabel)); + }); +} + +function withFilteredSalesRelatedProjects(item: SalesExpansionItem, filters: ExpansionExportFilters): SalesExpansionItem { + return { + ...item, + relatedProjects: filterRelatedProjectsByStage(item.relatedProjects, filters.relatedProjectStageCodes), + }; +} + +function withFilteredChannelRelatedProjects(item: ChannelExpansionItem, filters: ExpansionExportFilters): ChannelExpansionItem { + return { + ...item, + relatedProjects: filterRelatedProjectsByStage(item.relatedProjects, filters.relatedProjectStageCodes), + }; +} + function matchesSalesExportFilters(item: SalesExpansionItem, filters: ExpansionExportFilters) { const keywordText = [ item.employeeNo, @@ -487,7 +593,7 @@ const salesExportColumns: Array (item.active === null || item.active === undefined ? "" : item.active ? "是" : "否") }, { key: "hasExp", label: "销售以前是否做过云桌面项目", value: (item) => formatExportBoolean(item.hasExp) }, { key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) }, - { key: "relatedProjectAmount", label: "跟进项目金额", value: (item) => normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)) }, + { key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, @@ -518,15 +624,15 @@ const channelExportColumns: Array normalizeExportText(item.internalAttribute) }, { key: "intent", label: "合作意向", value: (item) => normalizeExportText(item.intent) }, { key: "establishedDate", label: "建立联系时间", value: (item) => normalizeExportText(item.establishedDate) }, - { key: "revenue", label: "年度营业额", value: (item) => normalizeExportText(item.revenue) }, - { key: "size", label: "人员规模", value: (item) => (item.size ? `${item.size}人` : "") }, + { key: "revenue", label: "年度营业额", numFmt: "#,##0.00", value: (item) => normalizeExportNumber(item.revenue ?? item.annualRevenue) ?? "" }, + { key: "size", label: "人员规模", numFmt: "#,##0", value: (item) => normalizeExportNumber(item.size) ?? "" }, { key: "hasDesktopExp", label: "以前是否做过云桌面项目", value: (item) => formatExportBoolean(item.hasDesktopExp) }, { key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) }, { key: "contacts", label: "人员信息", kind: "contact", value: (item) => formatExportContactListCell(item.contacts) }, { key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) }, { key: "channelCode", label: "编码", value: (item) => normalizeExportText(item.channelCode) }, { key: "city", label: "市", value: (item) => normalizeExportText(item.city) }, - { key: "relatedProjectAmount", label: "跟进项目金额", value: (item) => normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)) }, + { key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" }, { key: "notes", label: "备注说明", kind: "longText", value: (item) => normalizeExportText(item.notes) }, { key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) }, { key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) }, @@ -810,6 +916,7 @@ function ExpansionExportFilterModal({ provinceOptions, certificationLevelOptions, channelAttributeOptions, + relatedProjectStageOptions, onClose, onConfirm, }: { @@ -822,15 +929,31 @@ function ExpansionExportFilterModal({ provinceOptions: ExpansionDictOption[]; certificationLevelOptions: ExpansionDictOption[]; channelAttributeOptions: ExpansionDictOption[]; + relatedProjectStageOptions: ExpansionDictOption[]; onClose: () => void; onConfirm: (filters: ExpansionExportFilters) => void; }) { - const [draftFilters, setDraftFilters] = useState(initialFilters); + const normalizedInitialFilters = applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions); + const [draftFilters, setDraftFilters] = useState(normalizedInitialFilters); const isSalesTab = activeTab === "sales"; const selectedSalesFields = resolveSelectedExpansionFields(draftFilters.selectedSalesFields, defaultSalesExportFields); const selectedChannelFields = resolveSelectedExpansionFields(draftFilters.selectedChannelFields, defaultChannelExportFields); const activeFieldOptions = isSalesTab ? salesExportColumns : channelExportColumns; const selectedFieldKeys = isSalesTab ? selectedSalesFields : selectedChannelFields; + const defaultRelatedProjectStageCodes = getDefaultRelatedProjectStageCodes(relatedProjectStageOptions); + const relatedProjectStageFilterOptions = relatedProjectStageOptions + .map((option) => { + const value = getDictOptionValue(option); + const label = getDictOptionLabel(option); + return value && label ? { value, label } : null; + }) + .filter((option): option is { value: string; label: string } => Boolean(option)); + const selectedRelatedProjectStageCodes = normalizeMultiSelectValues(draftFilters.relatedProjectStageCodes); + + useEffect(() => { + setDraftFilters(applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions)); + }, [initialFilters, relatedProjectStageOptions]); + const hasDraftFilters = Boolean( draftFilters.keyword || draftFilters.intent @@ -844,7 +967,7 @@ function ExpansionExportFilterModal({ || draftFilters.establishedStartDate || draftFilters.establishedEndDate || draftFilters.hasRelatedProject, - ) || (isSalesTab + ) || !areSameStringSets(selectedRelatedProjectStageCodes, defaultRelatedProjectStageCodes) || (isSalesTab ? JSON.stringify(selectedSalesFields) !== JSON.stringify(defaultSalesExportFields) : JSON.stringify(selectedChannelFields) !== JSON.stringify(defaultChannelExportFields)); const hasSelectedFields = selectedFieldKeys.length > 0; @@ -867,6 +990,18 @@ function ExpansionExportFilterModal({ return { ...current, selectedChannelFields: nextFields }; }); }; + const handleRelatedProjectStageToggle = (stageCode: string) => { + setDraftFilters((current) => { + const currentStageCodes = normalizeMultiSelectValues(current.relatedProjectStageCodes); + const nextStageCodeSet = currentStageCodes.includes(stageCode) + ? new Set(currentStageCodes.filter((value) => value !== stageCode)) + : new Set([...currentStageCodes, stageCode]); + const orderedStageCodes = relatedProjectStageFilterOptions + .map((option) => option.value) + .filter((value) => nextStageCodeSet.has(value)); + return { ...current, relatedProjectStageCodes: orderedStageCodes }; + }); + }; const handleFilterChange = (key: keyof ExpansionExportFilters, value: string) => { setDraftFilters((current) => ({ ...current, [key]: value })); }; @@ -894,6 +1029,7 @@ function ExpansionExportFilterModal({ + + +
+ ) : null} +
+ {relatedProjectStageFilterOptions.length > 0 ? ( +
+ {relatedProjectStageFilterOptions.map((option) => { + const checked = selectedRelatedProjectStageCodes.includes(option.value); + return ( + + ); + })} +
+ ) : ( +

未加载到阶段字典,导出时不会按关联项目阶段过滤。

+ )} +
@@ -1144,6 +1338,7 @@ export default function Expansion() { const [editCityOptions, setEditCityOptions] = useState([]); const [channelAttributeOptions, setChannelAttributeOptions] = useState([]); const [internalAttributeOptions, setInternalAttributeOptions] = useState([]); + const [relatedProjectStageOptions, setRelatedProjectStageOptions] = useState([]); const [nextChannelCode, setNextChannelCode] = useState(""); const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? ""; const [refreshTick, setRefreshTick] = useState(0); @@ -1189,6 +1384,40 @@ export default function Expansion() { return data; }, []); + useEffect(() => { + let cancelled = false; + + async function loadRelatedProjectStageDict() { + try { + const data = await getOpportunityMeta(); + if (!cancelled) { + setRelatedProjectStageOptions(data.stageOptions ?? []); + } + } catch { + if (!cancelled) { + setRelatedProjectStageOptions([]); + } + } + } + + void loadRelatedProjectStageDict(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (relatedProjectStageOptions.length <= 0) { + return; + } + setExportFilters((current) => ( + current.relatedProjectStageCodes === undefined + ? applyDefaultRelatedProjectStageFilters(current, relatedProjectStageOptions) + : current + )); + }, [relatedProjectStageOptions]); + const loadCityOptions = useCallback(async (provinceName?: string, isEdit = false) => { const setter = isEdit ? setEditCityOptions : setCreateCityOptions; const normalizedProvinceName = provinceName?.trim(); @@ -1765,18 +1994,23 @@ export default function Expansion() { } const isSalesTab = activeTab === "sales"; + const normalizedFilters = applyDefaultRelatedProjectStageFilters(filters, relatedProjectStageOptions); setExporting(true); setExportError(""); - setExportFilters(filters); - persistExpansionExportPreferences(filters); + setExportFilters(normalizedFilters); + persistExpansionExportPreferences(normalizedFilters); try { const overview = await getExpansionOverview(""); - const exportSalesItems = dedupeExpansionItemsById(overview.salesItems ?? []).filter((item) => matchesSalesExportFilters(item, filters)); - const exportChannelItems = dedupeExpansionItemsById(overview.channelItems ?? []).filter((item) => matchesChannelExportFilters(item, filters)); + const exportSalesItems = dedupeExpansionItemsById(overview.salesItems ?? []) + .map((item) => withFilteredSalesRelatedProjects(item, normalizedFilters)) + .filter((item) => matchesSalesExportFilters(item, normalizedFilters)); + const exportChannelItems = dedupeExpansionItemsById(overview.channelItems ?? []) + .map((item) => withFilteredChannelRelatedProjects(item, normalizedFilters)) + .filter((item) => matchesChannelExportFilters(item, normalizedFilters)); const exportItems = isSalesTab ? exportSalesItems : exportChannelItems; - const selectedSalesFieldKeys = resolveSelectedExpansionFields(filters.selectedSalesFields, defaultSalesExportFields); - const selectedChannelFieldKeys = resolveSelectedExpansionFields(filters.selectedChannelFields, defaultChannelExportFields); + const selectedSalesFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedSalesFields, defaultSalesExportFields); + const selectedChannelFieldKeys = resolveSelectedExpansionFields(normalizedFilters.selectedChannelFields, defaultChannelExportFields); const selectedFieldKeys = isSalesTab ? selectedSalesFieldKeys : selectedChannelFieldKeys; if (exportItems.length <= 0) { throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : "渠道拓展"}数据`); @@ -1835,6 +2069,9 @@ export default function Expansion() { columns.forEach((columnConfig, index) => { const column = worksheet.getColumn(index + 1); column.width = columnWidths[index]; + if (columnConfig.numFmt) { + column.numFmt = columnConfig.numFmt; + } column.alignment = { vertical: "top", horizontal: "left", @@ -2448,7 +2685,7 @@ export default function Expansion() { {exportFilterOpen && ( setExportFilterOpen(false)} onConfirm={(filters) => void handleExport(filters)} /> @@ -2764,11 +3002,19 @@ function formatAmount(value: number) { return `¥${new Intl.NumberFormat("zh-CN").format(value)}`; } -function formatRelatedProjectAmount(projects?: Array<{ amount?: number }>) { +function sumRelatedProjectAmount(projects?: Array<{ amount?: number }>) { if (!projects || projects.length === 0) { - return "无"; + return undefined; } const totalAmount = projects.reduce((sum, project) => sum + Number(project.amount || 0), 0); + return Number.isFinite(totalAmount) ? totalAmount : undefined; +} + +function formatRelatedProjectAmount(projects?: Array<{ amount?: number }>) { + const totalAmount = sumRelatedProjectAmount(projects); + if (totalAmount === undefined) { + return "无"; + } return formatAmount(totalAmount); } diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index 8edaf343..641b0a38 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -87,6 +87,7 @@ type OpportunityExportFilters = { expectedStartDate?: string; expectedEndDate?: string; stage?: string; + stageCodes?: string[]; confidence?: string; projectLocation?: string; opportunityType?: string; @@ -128,7 +129,8 @@ type OpportunityExportColumn = { key: OpportunityExportFieldKey; label: string; kind?: "default" | "longText" | "followup"; - value: (item: OpportunityItem, relatedSales: SalesExpansionItem | null, relatedChannel: ChannelExpansionItem | null) => string; + numFmt?: string; + value: (item: OpportunityItem, relatedSales: SalesExpansionItem | null, relatedChannel: ChannelExpansionItem | null) => string | number; }; type OpportunityField = | "projectLocation" @@ -241,6 +243,21 @@ function formatOpportunityExportFilenameTime(date = new Date()) { return `${year}${month}${day}_${hours}${minutes}${seconds}`; } +function normalizeOpportunityExportNumber(value?: string | number | null) { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + const normalized = value.replace(/,/g, "").trim(); + if (!normalized) { + return undefined; + } + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + const opportunityExportColumns: OpportunityExportColumn[] = [ { key: "code", label: "项目编码", value: (item) => normalizeOpportunityExportText(item.code) }, { key: "name", label: "项目名称", value: (item) => normalizeOpportunityExportText(item.name) }, @@ -252,7 +269,7 @@ const opportunityExportColumns: OpportunityExportColumn[] = [ { key: "channelName", label: "渠道名称", value: (item, _relatedSales, relatedChannel) => normalizeOpportunityExportText(item.channelExpansionName || relatedChannel?.name) }, { key: "stage", label: "项目阶段", value: (item) => normalizeOpportunityExportText(item.stage) }, { key: "confidence", label: "项目把握度", value: (item) => normalizeOpportunityExportText(item.confidence) }, - { key: "amount", label: "预计金额(元)", value: (item) => (item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`) }, + { key: "amount", label: "预计金额(元)", numFmt: "#,##0.00", value: (item) => normalizeOpportunityExportNumber(item.amount) ?? "" }, { key: "date", label: "预计下单时间", value: (item) => normalizeOpportunityExportText(item.date) }, { key: "competitorName", label: "竞争对手", value: (item) => normalizeOpportunityExportText(item.competitorName) }, { key: "latestProgress", label: "项目最新进展", kind: "longText", value: (item) => normalizeOpportunityExportText(item.latestProgress) }, @@ -357,6 +374,65 @@ function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: st return true; } +function normalizeOpportunityMultiSelectValues(values?: string[]) { + return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); +} + +function getOpportunityDictOptionValue(option?: OpportunityDictOption) { + return option?.value?.trim() || option?.label?.trim() || ""; +} + +function getOpportunityDictOptionLabel(option?: OpportunityDictOption) { + return option?.label?.trim() || option?.value?.trim() || ""; +} + +function isLostOpportunityStageOption(option?: OpportunityDictOption) { + const value = getOpportunityDictOptionValue(option).toLowerCase(); + const label = getOpportunityDictOptionLabel(option).toLowerCase(); + return value === "lost" || value.includes("丢单") || label.includes("丢单") || value.includes("放弃") || label.includes("放弃"); +} + +function getDefaultOpportunityExportStageCodes(options: OpportunityDictOption[]) { + const orderedValues = options.map(getOpportunityDictOptionValue).filter(Boolean); + const preferredValues = options.filter((option) => !isLostOpportunityStageOption(option)).map(getOpportunityDictOptionValue).filter(Boolean); + return normalizeOpportunityMultiSelectValues(preferredValues.length > 0 ? preferredValues : orderedValues); +} + +function applyDefaultOpportunityExportStageFilters(filters: OpportunityExportFilters, stageOptions: OpportunityDictOption[]) { + if (filters.stageCodes !== undefined) { + return { + ...filters, + stage: undefined, + stageCodes: normalizeOpportunityMultiSelectValues(filters.stageCodes), + }; + } + const legacyStage = filters.stage?.trim(); + if (legacyStage) { + return { + ...filters, + stage: undefined, + stageCodes: [legacyStage], + }; + } + if (stageOptions.length <= 0) { + return { + ...filters, + stage: undefined, + }; + } + return { + ...filters, + stage: undefined, + stageCodes: getDefaultOpportunityExportStageCodes(stageOptions), + }; +} + +function areSameOpportunityStringSets(leftValues?: string[], rightValues?: string[]) { + const left = normalizeOpportunityMultiSelectValues(leftValues).sort(); + const right = normalizeOpportunityMultiSelectValues(rightValues).sort(); + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function matchesOpportunityExportFilters( item: OpportunityItem, filters: OpportunityExportFilters, @@ -387,8 +463,16 @@ function matchesOpportunityExportFilters( if (!matchesOpportunityDateRange(item.date, filters.expectedStartDate, filters.expectedEndDate)) { return false; } - if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) { - return false; + if (filters.stageCodes !== undefined) { + const normalizedStageCodes = new Set(normalizeOpportunityMultiSelectValues(filters.stageCodes)); + if (normalizedStageCodes.size <= 0) { + return false; + } + const itemStageCode = item.stageCode?.trim(); + const itemStageLabel = item.stage?.trim(); + if (!(itemStageCode && normalizedStageCodes.has(itemStageCode)) && !(itemStageLabel && normalizedStageCodes.has(itemStageLabel))) { + return false; + } } if (filters.confidence && normalizeConfidenceValue(item.confidence, confidenceOptions) !== filters.confidence) { return false; @@ -816,24 +900,53 @@ function OpportunityExportFilterModal({ onClose: () => void; onConfirm: (filters: OpportunityExportFilters) => void; }) { - const [draftFilters, setDraftFilters] = useState(initialFilters); + const normalizedInitialFilters = applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions); + const [draftFilters, setDraftFilters] = useState(normalizedInitialFilters); const selectedFields = resolveSelectedOpportunityFields(draftFilters.selectedFields); + const defaultStageCodes = getDefaultOpportunityExportStageCodes(stageOptions); + const exportStageOptions = stageOptions + .map((option) => { + const value = getOpportunityDictOptionValue(option); + const label = getOpportunityDictOptionLabel(option); + return value && label ? { value, label } : null; + }) + .filter((option): option is { value: string; label: string } => Boolean(option)); + const selectedStageCodes = normalizeOpportunityMultiSelectValues(draftFilters.stageCodes); + + useEffect(() => { + setDraftFilters(applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions)); + }, [initialFilters, stageOptions]); + const hasDraftFilters = Boolean( draftFilters.keyword || draftFilters.expectedStartDate || draftFilters.expectedEndDate - || draftFilters.stage || draftFilters.confidence || draftFilters.projectLocation || draftFilters.opportunityType || draftFilters.operatorName || draftFilters.hasSalesExpansion || draftFilters.hasChannelExpansion, - ) || JSON.stringify(selectedFields) !== JSON.stringify(defaultOpportunityExportFields); + ) || !areSameOpportunityStringSets(selectedStageCodes, defaultStageCodes) + || JSON.stringify(selectedFields) !== JSON.stringify(defaultOpportunityExportFields); const hasSelectedFields = selectedFields.length > 0; const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => { setDraftFilters((current) => ({ ...current, [key]: value })); }; + const handleStageToggle = (stageCode: string) => { + setDraftFilters((current) => { + const currentStageCodes = normalizeOpportunityMultiSelectValues(current.stageCodes); + const nextStageCodeSet = currentStageCodes.includes(stageCode) + ? new Set(currentStageCodes.filter((value) => value !== stageCode)) + : new Set([...currentStageCodes, stageCode]); + const orderedStageCodes = exportStageOptions.map((option) => option.value).filter((value) => nextStageCodeSet.has(value)); + return { + ...current, + stage: undefined, + stageCodes: orderedStageCodes, + }; + }); + }; const toggleField = (fieldKey: OpportunityExportFieldKey) => { setDraftFilters((current) => { const currentFields = resolveSelectedOpportunityFields(current.selectedFields); @@ -869,6 +982,7 @@ function OpportunityExportFilterModal({ + + +
+ ) : null} +
+ {exportStageOptions.length > 0 ? ( +
+ {exportStageOptions.map((option) => { + const checked = selectedStageCodes.includes(option.value); + return ( + + ); + })} +
+ ) : ( +

未加载到阶段字典,导出时不会按项目阶段过滤。

+ )} +