经营分析模块
parent
4b7177e652
commit
aa5ff4f073
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ public class DashboardAnalyticsAdminController {
|
|||
@GetMapping("/dashboard-analytics-config/preview/card-detail")
|
||||
public ApiResponse<DashboardAnalyticsCardDTO> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashboardAnalyticsCardDTO> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> ALLOWED_VALUE_TYPES = Set.of("number", "amount", "percent", "text");
|
||||
private static final Set<String> ALLOWED_RENDER_TYPES = Set.of("metric", "line", "bar", "pie", "ring", "funnel", "ranking", "table");
|
||||
private static final Set<String> ALLOWED_RENDER_TYPES = Set.of("metric", "line", "bar", "pie", "ring", "funnel");
|
||||
private static final Set<String> ALLOWED_SORT_DIRECTIONS = Set.of("sql", "asc", "desc");
|
||||
private static final Set<String> ALLOWED_LAYOUT_TYPES = Set.of("vertical", "horizontal");
|
||||
private static final Set<String> 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("(?<!:):([A-Za-z][A-Za-z0-9_]*)");
|
||||
private static final Pattern DATA_SCOPE_MACRO_PATTERN = Pattern.compile("\\{\\{\\s*DATA_SCOPE\\s*:\\s*([A-Za-z_][A-Za-z0-9_\\.]*)\\s*\\}\\}");
|
||||
|
|
@ -133,6 +142,7 @@ public class DashboardAnalyticsConfigService {
|
|||
insert into dashboard_analytics_card_config (
|
||||
tenant_id,
|
||||
card_key,
|
||||
group_name,
|
||||
title,
|
||||
subtitle,
|
||||
render_type,
|
||||
|
|
@ -153,10 +163,11 @@ public class DashboardAnalyticsConfigService {
|
|||
enabled,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
""",
|
||||
tenantId,
|
||||
card.getCardKey(),
|
||||
emptyToNull(card.getGroupName()),
|
||||
card.getTitle(),
|
||||
card.getSubtitle(),
|
||||
card.getRenderType(),
|
||||
|
|
@ -197,7 +208,7 @@ public class DashboardAnalyticsConfigService {
|
|||
return buildPanel(tenantId, currentUserId, false);
|
||||
}
|
||||
|
||||
public DashboardAnalyticsCardDTO previewCard(Long tenantId, String cardKey) {
|
||||
public DashboardAnalyticsCardDTO previewCard(Long tenantId, String cardKey, String dimension) {
|
||||
requirePermission(PREVIEW_PERM, "无权预览首页经营分析配置");
|
||||
Long resolvedTenantId = resolveTenantId(tenantId);
|
||||
Long currentUserId = userProvider.getCurrentUserId();
|
||||
|
|
@ -205,10 +216,10 @@ public class DashboardAnalyticsConfigService {
|
|||
throw new UnauthorizedException("登录已失效,请重新登录");
|
||||
}
|
||||
DashboardAnalyticsCardConfigDTO cardConfig = findCardConfig(resolvedTenantId, cardKey, true);
|
||||
return executeCardWithDetailFallback(resolvedTenantId, currentUserId, cardConfig);
|
||||
return executeCardWithDetailFallback(resolvedTenantId, currentUserId, cardConfig, dimension);
|
||||
}
|
||||
|
||||
public DashboardAnalyticsCardDTO getDashboardCardDetail(Long currentUserId, String cardKey) {
|
||||
public DashboardAnalyticsCardDTO getDashboardCardDetail(Long currentUserId, String cardKey, String dimension) {
|
||||
Long tenantId = tenantProvider.getCurrentTenantId();
|
||||
if (tenantId == null || tenantId <= 0 || currentUserId == null || currentUserId <= 0) {
|
||||
throw new BusinessException("未找到当前租户或登录用户");
|
||||
|
|
@ -217,19 +228,20 @@ public class DashboardAnalyticsConfigService {
|
|||
if (!Boolean.TRUE.equals(cardConfig.getEnabled())) {
|
||||
throw new BusinessException("卡片未启用或不存在");
|
||||
}
|
||||
return executeCardWithDetailFallback(tenantId, currentUserId, cardConfig);
|
||||
return executeCardWithDetailFallback(tenantId, currentUserId, cardConfig, dimension);
|
||||
}
|
||||
|
||||
private DashboardAnalyticsCardDTO executeCardWithDetailFallback(
|
||||
Long tenantId,
|
||||
Long currentUserId,
|
||||
DashboardAnalyticsCardConfigDTO cardConfig) {
|
||||
DashboardAnalyticsCardConfigDTO cardConfig,
|
||||
String dimension) {
|
||||
try {
|
||||
return executeCard(tenantId, currentUserId, cardConfig, false);
|
||||
return executeCard(tenantId, currentUserId, cardConfig, false, dimension);
|
||||
} catch (Exception exception) {
|
||||
log.warn("Failed to load full dashboard analytics detail for card {} in tenant {}, fallback to limited data",
|
||||
cardConfig.getCardKey(), tenantId, exception);
|
||||
return executeCard(tenantId, currentUserId, cardConfig, true);
|
||||
return executeCard(tenantId, currentUserId, cardConfig, true, dimension);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,11 +259,11 @@ public class DashboardAnalyticsConfigService {
|
|||
|
||||
List<DashboardAnalyticsCardDTO> 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<Map<String, Object>> rows = namedParameterJdbcTemplate.queryForList(executableSql.sql(), executableSql.params());
|
||||
Map<String, Object> 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<DashboardAnalyticsCardConfigDTO> loadCards(Long tenantId) {
|
||||
return jdbcTemplate.query("""
|
||||
List<DashboardAnalyticsCardConfigDTO> 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<DimensionOption> 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<DimensionOption> 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<DimensionOption> 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<String, Object> 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<DashboardAnalyticsChartPointDTO> points = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows == null ? List.<Map<String, Object>>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<DashboardAnalyticsChartPointDTO> normalizedPoints = applyConfiguredCategories(points, config, renderType);
|
||||
|
|
@ -880,6 +1009,9 @@ public class DashboardAnalyticsConfigService {
|
|||
List<DashboardAnalyticsChartPointDTO> points,
|
||||
DashboardAnalyticsCardConfigDTO config,
|
||||
String renderType) {
|
||||
if (points.stream().anyMatch(this::usesAdvancedVisualizationProtocol)) {
|
||||
return points;
|
||||
}
|
||||
List<CategoryOption> 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<CategoryOption> deriveBuiltinCategoryOptions(
|
||||
List<DashboardAnalyticsChartPointDTO> 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<String> 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<DashboardAnalyticsChartPointDTO> sortVisualizationData(
|
||||
List<DashboardAnalyticsChartPointDTO> 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<DashboardAnalyticsChartPointDTO> 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<DashboardAnalyticsChartPointDTO> applyDisplayLimitByLabel(
|
||||
List<DashboardAnalyticsChartPointDTO> points,
|
||||
Integer displayLimit,
|
||||
String renderType) {
|
||||
Integer normalizedLimit = normalizeDisplayLimit(displayLimit);
|
||||
int effectiveLimit = normalizedLimit == null ? defaultDisplayLimit(renderType) : normalizedLimit;
|
||||
if (effectiveLimit == Integer.MAX_VALUE) {
|
||||
return points;
|
||||
}
|
||||
|
||||
Set<String> 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<DashboardAnalyticsChartPointDTO> points,
|
||||
int totalCount,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<foreach collection="bizIds" item="id" open="(" separator="," close=")">
|
||||
#{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
|
||||
<foreach collection="bizIds" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
|
|
|
|||
|
|
@ -632,6 +632,7 @@
|
|||
update crm_opportunity
|
||||
set latest_progress = #{latestProgress},
|
||||
next_plan = #{nextPlan},
|
||||
stage = coalesce(#{stage}, stage),
|
||||
updated_at = now()
|
||||
where id = #{opportunityId}
|
||||
</update>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<DashboardAnalyticsCard["chartData"]>[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<SupportedChartRenderType>(["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<string, unknown>;
|
||||
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 (
|
||||
<div className="flex min-h-[220px] items-center justify-center rounded-[24px] border border-dashed border-slate-200 bg-slate-50/60 px-6 text-center text-sm text-slate-400">
|
||||
|
|
@ -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 (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white px-4 py-3 shadow-[0_10px_15px_-3px_rgb(0_0_0_/_0.1)]">
|
||||
<div className="text-xs font-bold text-slate-700">{point.label}</div>
|
||||
<div className={`mt-1 text-sm font-semibold ${getPercentTextClass(point.valueText, "text-slate-900")}`}>{point.valueText}</div>
|
||||
<div className={`mt-1 text-[11px] ${getPercentTextClassFromRatio(ratio)}`}>
|
||||
{mode === "peak" ? ratioLabel : ratioLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ points }: { points: NormalizedPoint[] }) {
|
||||
return (
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-x-4 gap-y-2">
|
||||
{points.map((point) => (
|
||||
<div key={point.label} className="inline-flex items-center gap-2 text-[10px] font-medium text-slate-500">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: point.color }} />
|
||||
<span>{point.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={getChartHeightClass("line", mobile, expanded)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={points} margin={{ top: 12, right: 10, left: mobile ? -24 : -12, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="#2563eb" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="4 6" />
|
||||
<XAxis dataKey="label" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: "#9ca3af" }} />
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 10, fill: "#9ca3af" }}
|
||||
width={mobile ? 28 : 36}
|
||||
tickFormatter={formatAxisValue}
|
||||
/>
|
||||
<Tooltip content={<AnalyticsTooltip prefix={texts.peakPrefix || "相对峰值"} mode="peak" total={max} />} />
|
||||
<Area type="monotone" dataKey="value" stroke="#2563eb" strokeWidth={3} fillOpacity={1} fill={`url(#${gradientId})`} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={getChartHeightClass("bar", mobile, expanded)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={points} margin={{ top: 12, right: 10, left: mobile ? -24 : -12, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="4 6" />
|
||||
<XAxis dataKey="label" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: "#9ca3af" }} />
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 10, fill: "#9ca3af" }}
|
||||
width={mobile ? 28 : 36}
|
||||
tickFormatter={formatAxisValue}
|
||||
/>
|
||||
<Tooltip content={<AnalyticsTooltip prefix={texts.peakPrefix || "相对峰值"} mode="peak" total={max} />} cursor={{ fill: "transparent" }} />
|
||||
<Bar dataKey="value" radius={[6, 6, 0, 0]} barSize={mobile ? 18 : 24}>
|
||||
{points.map((point) => (
|
||||
<Cell key={point.label} fill={point.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={getChartHeightClass(ring ? "ring" : "pie", mobile, expanded)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={points}
|
||||
cx="50%"
|
||||
cy={ring ? "46%" : "44%"}
|
||||
innerRadius={ring ? (mobile ? 52 : 60) : 0}
|
||||
outerRadius={mobile ? 76 : 84}
|
||||
paddingAngle={ring ? 5 : 2}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
label={ring ? false : ({ value }) => `${value}`}
|
||||
labelLine={!ring}
|
||||
>
|
||||
{points.map((point) => (
|
||||
<Cell key={point.label} fill={point.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<AnalyticsTooltip prefix={texts.sharePrefix || "占比"} mode="share" total={total} />} />
|
||||
{ring ? (
|
||||
<>
|
||||
<text x="50%" y="42%" textAnchor="middle" dominantBaseline="middle" className="fill-slate-400 text-[10px] font-semibold">
|
||||
{centerTitle}
|
||||
</text>
|
||||
<text x="50%" y="52%" textAnchor="middle" dominantBaseline="middle" className="fill-slate-800 text-xl font-bold">
|
||||
<tspan fill={getPercentFillColor(centerValue, "#0f172a")}>{centerValue}</tspan>
|
||||
</text>
|
||||
</>
|
||||
) : null}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<ChartLegend points={points} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelChartCard({
|
||||
points,
|
||||
mobile,
|
||||
expanded,
|
||||
}: {
|
||||
points: NormalizedPoint[];
|
||||
mobile: boolean;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const funnelData = points.map((point) => ({
|
||||
...point,
|
||||
fill: point.color,
|
||||
name: point.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={getChartHeightClass("funnel", mobile, expanded)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<FunnelChart>
|
||||
<Tooltip content={<AnalyticsTooltip prefix="步骤占比" mode="share" total={Math.max(...points.map((point) => point.value), 0)} />} />
|
||||
<Funnel dataKey="value" data={funnelData} isAnimationActive={false}>
|
||||
<LabelList position="right" fill="#64748b" stroke="none" dataKey="name" className="text-[10px]" />
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankingChartCard({
|
||||
points,
|
||||
texts,
|
||||
}: {
|
||||
points: NormalizedPoint[];
|
||||
texts: DisplayTextConfig;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{points.map((point, index) => (
|
||||
<div key={point.label} className="flex items-center justify-between rounded-2xl p-3 transition-colors hover:bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-4 text-xs font-bold text-slate-400">{index + 1}</span>
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold ${RANKING_BADGE_CLASSES[index % RANKING_BADGE_CLASSES.length]}`}>
|
||||
{point.initials}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-slate-800">{point.label}</p>
|
||||
<p className="truncate text-[10px] text-slate-400">{point.description || "成交效率优秀"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 pl-3 text-right">
|
||||
<p className={`text-sm font-bold ${getPercentTextClass(point.valueText, "text-slate-900")}`}>{point.valueText}</p>
|
||||
<p
|
||||
className={`text-[10px] font-bold ${
|
||||
point.secondaryValueText
|
||||
? getPercentTextClass(point.secondaryValueText, "text-slate-400")
|
||||
: getPercentTextClassFromRatio(points[0]?.value ? point.value / points[0].value : 0)
|
||||
}`}
|
||||
>
|
||||
{formatRankingSecondaryLabel(
|
||||
point.secondaryValueText,
|
||||
points[0]?.value ? point.value / points[0].value : 0,
|
||||
texts.ratioPrefix,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCard({
|
||||
points,
|
||||
texts,
|
||||
}: {
|
||||
points: NormalizedPoint[];
|
||||
texts: DisplayTextConfig;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-slate-100">
|
||||
<div className="grid grid-cols-[minmax(0,1.2fr)_auto_auto] gap-4 border-b border-slate-100 bg-slate-50 px-4 py-3 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<span>{texts.tableLabelHeader || "项目"}</span>
|
||||
<span className="hidden sm:block">说明</span>
|
||||
<span className="text-right">{texts.tableValueHeader || "数值"}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 bg-white">
|
||||
{points.map((point, index) => (
|
||||
<div key={`${point.label}-${index}`} className="grid grid-cols-[minmax(0,1.2fr)_auto] gap-4 px-4 py-3.5 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)_auto]">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-bold text-slate-800">{point.label}</div>
|
||||
<div className="mt-1 text-[10px] text-slate-400 sm:hidden">{point.description || "-"}</div>
|
||||
</div>
|
||||
<div className="hidden min-w-0 sm:block">
|
||||
<div className="truncate text-xs text-slate-400">{point.description || "-"}</div>
|
||||
</div>
|
||||
<div className={`text-right text-sm font-bold ${getPercentTextClass(point.valueText, "text-slate-900")}`}>{point.valueText}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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 ? <TableCard points={chartPoints} texts={texts} /> : <EmptyState text={texts.emptyText} />;
|
||||
}
|
||||
|
||||
if (!chartPoints.length) {
|
||||
if (!renderType || !SUPPORTED_CHART_RENDER_TYPES.has(renderType)) {
|
||||
return <EmptyState text={texts.emptyText} />;
|
||||
}
|
||||
|
||||
if (renderType === "line") {
|
||||
return <LineChartCard points={chartPoints} texts={texts} mobile={isMobile} expanded={expanded} />;
|
||||
if (!(card.chartData?.length)) {
|
||||
return <EmptyState text={texts.emptyText} />;
|
||||
}
|
||||
if (renderType === "bar") {
|
||||
return <BarChartCard points={chartPoints} texts={texts} mobile={isMobile} expanded={expanded} />;
|
||||
}
|
||||
if (renderType === "pie") {
|
||||
return <PieChartCard points={chartPoints} texts={texts} mobile={isMobile} expanded={expanded} />;
|
||||
}
|
||||
if (renderType === "ring") {
|
||||
return <PieChartCard points={chartPoints} texts={texts} ring mobile={isMobile} expanded={expanded} />;
|
||||
}
|
||||
if (renderType === "ranking") {
|
||||
return <RankingChartCard points={chartPoints} texts={texts} />;
|
||||
}
|
||||
if (renderType === "funnel") {
|
||||
return <FunnelChartCard points={chartPoints} mobile={isMobile} expanded={expanded} />;
|
||||
}
|
||||
return <EmptyState text={texts.emptyText} />;
|
||||
|
||||
return (
|
||||
<div style={layout.containerStyle}>
|
||||
<div style={layout.innerStyle}>
|
||||
<DashboardAnalyticsEChart card={card} expanded={expanded} heightOverride={layout.chartHeight} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<DashboardHome>("/api/dashboard/home", undefined, true);
|
||||
}
|
||||
|
||||
export async function getDashboardAnalyticsCardDetail(cardKey: string) {
|
||||
return request<DashboardAnalyticsCard>(`/api/dashboard/analytics-cards/${encodeURIComponent(cardKey)}`, undefined, true);
|
||||
export async function getDashboardAnalyticsCardDetail(cardKey: string, dimension?: string) {
|
||||
const params = dimension ? `?dimension=${encodeURIComponent(dimension)}` : "";
|
||||
return request<DashboardAnalyticsCard>(`/api/dashboard/analytics-cards/${encodeURIComponent(cardKey)}${params}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function completeDashboardTodo(todoId: string) {
|
||||
|
|
|
|||
|
|
@ -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<T extends { groupName?: string }> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, string> = {
|
|||
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<T extends { groupName?: string }>(cards: T[]): GroupedDashboardCard<T>[] {
|
||||
const grouped = new Map<string, GroupedDashboardCard<T>>();
|
||||
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<T extends { groupName?: string }>(sections: GroupedDashboardCard<T>[]) {
|
||||
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<DashboardAnalyticsCard | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string>("");
|
||||
const [analyticsCardOverrides, setAnalyticsCardOverrides] = useState<Record<string, DashboardAnalyticsCard>>({});
|
||||
const [analyticsSelectedDimensions, setAnalyticsSelectedDimensions] = useState<Record<string, string>>({});
|
||||
const [analyticsDimensionLoadingKey, setAnalyticsDimensionLoadingKey] = useState<string>("");
|
||||
const [openDimensionCardKey, setOpenDimensionCardKey] = useState<string | null>(null);
|
||||
const dimensionMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeAnalyticsTab, setActiveAnalyticsTab] = useState<string>(() => {
|
||||
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 ? (
|
||||
<div className={`grid grid-cols-12 gap-3 min-[420px]:gap-4 ${isMobileViewport ? "" : "sm:gap-4"}`}>
|
||||
{analyticsCards.map((card, index) => {
|
||||
<div className="space-y-3 min-[420px]:space-y-4">
|
||||
<div className="space-y-3">
|
||||
{home.analyticsPanel?.title || home.analyticsPanel?.subtitle ? (
|
||||
<div className="crm-dashboard-panel-header">
|
||||
<div className="crm-dashboard-panel-heading">
|
||||
{home.analyticsPanel?.title ? (
|
||||
<h2 className="crm-dashboard-panel-title">{home.analyticsPanel.title}</h2>
|
||||
) : null}
|
||||
{home.analyticsPanel?.subtitle ? (
|
||||
<p className="crm-dashboard-panel-subtitle">{home.analyticsPanel.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{analyticsTabs.length ? (
|
||||
<div className="crm-filter-bar overflow-x-auto px-1 py-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex min-w-full gap-1">
|
||||
{analyticsTabs.map((tab) => {
|
||||
const active = tab.key === activeAnalyticsTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveAnalyticsTab(tab.key)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-200 max-sm:min-w-[84px] max-sm:flex-none sm:flex-1 ${
|
||||
active
|
||||
? "bg-white text-violet-600 shadow-sm"
|
||||
: "text-slate-600 hover:text-slate-900"
|
||||
}`}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{visibleAnalyticsSections.map((section) => {
|
||||
const showSectionShell = !section.isDefault || analyticsSections.length > 1;
|
||||
const showSectionTitle = showSectionShell && activeAnalyticsTab === ANALYTICS_ALL_TAB_KEY;
|
||||
return (
|
||||
<section
|
||||
key={section.key}
|
||||
className={showSectionShell
|
||||
? "rounded-[28px] border border-slate-100 bg-white/96 p-4 shadow-[0_16px_36px_-28px_rgba(15,23,42,0.16)] min-[420px]:p-5"
|
||||
: ""}
|
||||
>
|
||||
{showSectionTitle ? (
|
||||
<div className="mb-3 min-[420px]:mb-4">
|
||||
<h3 className="truncate text-sm font-bold text-slate-900 min-[420px]:text-[15px]">
|
||||
{section.title || "未分组"}
|
||||
</h3>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`grid grid-cols-12 gap-3 min-[420px]:gap-4 ${isMobileViewport ? "" : "sm:gap-4"}`}>
|
||||
{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 (
|
||||
<div
|
||||
key={card.cardKey || card.id || card.title}
|
||||
|
|
@ -669,19 +1154,18 @@ export default function Dashboard() {
|
|||
}}
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
className={`${getAnalyticsCardLayoutClass(card)} overflow-hidden border border-slate-100 bg-white text-left shadow-sm transition-all ${
|
||||
clickable ? "cursor-pointer hover:-translate-y-0.5 hover:shadow-md" : ""
|
||||
className={`${getAnalyticsCardLayoutClass(card, isMobileViewport)} ${chartCard ? "overflow-visible" : "overflow-hidden"} text-left transition-all ${
|
||||
clickable ? "cursor-pointer hover:-translate-y-0.5" : ""
|
||||
} ${
|
||||
chartCard
|
||||
? compactMobileCard
|
||||
? "rounded-[24px] p-4"
|
||||
: "rounded-[32px] p-6"
|
||||
? `${compactMobileCard ? "rounded-[24px] p-4" : "rounded-[32px] p-6"} border border-slate-100 bg-white shadow-sm ${clickable ? "hover:shadow-md" : ""}`
|
||||
: ultraCompactMobileCard
|
||||
? "rounded-[18px] p-3"
|
||||
: compactMobileCard
|
||||
? "rounded-[20px] p-4"
|
||||
: "rounded-[24px] p-5"
|
||||
}`}
|
||||
style={chartCard ? undefined : metricStyles.container}
|
||||
>
|
||||
{chartCard ? (
|
||||
<>
|
||||
|
|
@ -696,9 +1180,68 @@ export default function Dashboard() {
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`${compactMobileCard ? "p-1.5" : "p-2"} rounded-xl text-slate-300`}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</span>
|
||||
{dimensionOptions.length ? (
|
||||
<div
|
||||
ref={openDimensionCardKey === card.cardKey ? dimensionMenuRef : undefined}
|
||||
className="relative shrink-0"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dimensionLoading}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={openDimensionCardKey === card.cardKey}
|
||||
aria-label={`${card.title || "图表"}维度筛选`}
|
||||
onClick={() => setOpenDimensionCardKey((current) => current === card.cardKey ? null : (card.cardKey || null))}
|
||||
className={`inline-flex h-[12px] items-center gap-0 rounded-sm bg-transparent px-0 text-[4px] font-medium text-slate-400 shadow-none transition-all ${
|
||||
dimensionLoading
|
||||
? "opacity-70"
|
||||
: "hover:text-slate-600"
|
||||
}`}
|
||||
>
|
||||
<span className="max-w-[18px] truncate leading-none">
|
||||
{dimensionOptions.find((option) => option.value === selectedDimension)?.label || selectedDimension}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-1.5 w-1.5 text-slate-300 transition-transform ${
|
||||
openDimensionCardKey === card.cardKey ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{openDimensionCardKey === card.cardKey ? (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute right-0 top-[calc(100%+4px)] z-20 min-w-[60px] overflow-hidden rounded-md bg-white/92 p-0.5 shadow-[0_14px_24px_-18px_rgba(15,23,42,0.22)] backdrop-blur"
|
||||
>
|
||||
{dimensionOptions.map((option) => {
|
||||
const active = option.value === selectedDimension;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => void handleAnalyticsDimensionChange(card, option.value)}
|
||||
className={`flex w-full items-center justify-between rounded-sm px-1 py-[2px] text-left text-[4px] font-medium leading-none transition-colors ${
|
||||
active
|
||||
? "bg-slate-900 text-white"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{active ? <Check className="h-1.5 w-1.5" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className={`${compactMobileCard ? "p-1.5" : "p-2"} rounded-xl text-slate-300`}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DashboardAnalyticsChart card={card} />
|
||||
{cardSummary ? (
|
||||
|
|
@ -708,53 +1251,62 @@ export default function Dashboard() {
|
|||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={`${
|
||||
ultraCompactMobileCard
|
||||
? "mb-3 h-8 w-8 rounded-xl"
|
||||
: compactMobileCard
|
||||
? "mb-3 h-9 w-9 rounded-2xl"
|
||||
: "mb-4 h-10 w-10 rounded-2xl"
|
||||
} flex items-center justify-center ${metricVisual.backgroundClassName}`}>
|
||||
<MetricIcon className={`${
|
||||
ultraCompactMobileCard ? "h-4 w-4" : "h-[18px] w-[18px]"
|
||||
} ${metricVisual.iconClassName}`} />
|
||||
<div style={metricScaleStyle}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{showMetricIcon ? (
|
||||
<div
|
||||
className={`${
|
||||
ultraCompactMobileCard
|
||||
? "h-8 w-8"
|
||||
: compactMobileCard
|
||||
? "h-9 w-9"
|
||||
: "h-10 w-10"
|
||||
} flex items-center justify-center`}
|
||||
style={metricStyles.iconShell}
|
||||
>
|
||||
<MetricIcon
|
||||
className={ultraCompactMobileCard ? "h-4 w-4" : "h-[18px] w-[18px]"}
|
||||
style={{ color: metricStyles.iconColor }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{showMetricBadge ? (
|
||||
<span
|
||||
className={`${ultraCompactMobileCard ? "px-2 py-1 text-[8px]" : "px-2.5 py-1 text-[10px]"} inline-flex items-center rounded-full font-bold`}
|
||||
style={metricStyles.badgeStyle}
|
||||
>
|
||||
{metricDisplayConfig.metricBadgeText?.trim()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className={`${
|
||||
ultraCompactMobileCard ? "text-[9px] leading-4" : "text-[10px]"
|
||||
} line-clamp-2 font-bold uppercase tracking-wider text-slate-400`}>
|
||||
} ${showMetricIcon ? "mt-3" : "mt-1.5"} line-clamp-2 font-bold uppercase tracking-wider`} style={{ color: metricStyles.titleColor }}>
|
||||
{card.title || "未命名卡片"}
|
||||
</p>
|
||||
<h3 className={`${
|
||||
ultraCompactMobileCard ? "mt-1 text-lg leading-tight" : compactMobileCard ? "mt-1 text-[19px]" : "mt-1 text-xl"
|
||||
} font-bold text-slate-900`}>
|
||||
} font-bold`} style={{ color: metricStyles.valueColor }}>
|
||||
{card.valueText || card.value || "0"}
|
||||
</h3>
|
||||
{metricFootnote ? (
|
||||
<div
|
||||
className={`${
|
||||
ultraCompactMobileCard ? "mt-1.5 text-[9px]" : "mt-2 text-[10px]"
|
||||
} inline-flex items-center gap-0.5 font-bold ${
|
||||
metricFootnote.tone === "up"
|
||||
? "text-emerald-500"
|
||||
: metricFootnote.tone === "down"
|
||||
? "text-rose-500"
|
||||
: "text-slate-400"
|
||||
}`}
|
||||
{showMetricSubtitle ? (
|
||||
<p
|
||||
className={`${ultraCompactMobileCard ? "mt-1 text-[9px]" : "mt-1.5 text-[10px]"} font-medium`}
|
||||
style={{ color: metricStyles.metaColor }}
|
||||
>
|
||||
{metricFootnote.tone === "up" ? <ArrowUpRight className="h-3 w-3" /> : null}
|
||||
{metricFootnote.tone === "down" ? <ArrowDownRight className="h-3 w-3" /> : null}
|
||||
<span>{metricFootnote.text}</span>
|
||||
</div>
|
||||
) : card.subtitle ? (
|
||||
<p className={`${ultraCompactMobileCard ? "mt-1.5 text-[9px]" : "mt-2 text-[10px]"} font-medium text-slate-400`}>
|
||||
{card.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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<T, K extends string> = {
|
||||
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<T extends RelatedProjectLike>(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<ExportColumn<SalesExpansionItem, SalesExportFiel
|
|||
{ key: "active", label: "销售是否在职", value: (item) => (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<ExportColumn<ChannelExpansionItem, ChannelExpo
|
|||
{ key: "internalAttribute", label: "新华三内部属性", value: (item) => 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<ExpansionExportFilters>(initialFilters);
|
||||
const normalizedInitialFilters = applyDefaultRelatedProjectStageFilters(initialFilters, relatedProjectStageOptions);
|
||||
const [draftFilters, setDraftFilters] = useState<ExpansionExportFilters>(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({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({
|
||||
...applyDefaultRelatedProjectStageFilters({}, relatedProjectStageOptions),
|
||||
selectedSalesFields: resolveSelectedExpansionFields(current.selectedSalesFields, defaultSalesExportFields),
|
||||
selectedChannelFields: resolveSelectedExpansionFields(current.selectedChannelFields, defaultChannelExportFields),
|
||||
}))}
|
||||
|
|
@ -954,6 +1090,64 @@ function ExpansionExportFilterModal({
|
|||
onChange={(value) => handleFilterChange("hasRelatedProject", value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">关联项目阶段</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">多选,默认排除已丢单/已放弃阶段。</p>
|
||||
</div>
|
||||
{relatedProjectStageFilterOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({ ...current, relatedProjectStageCodes: defaultRelatedProjectStageCodes }))}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({ ...current, relatedProjectStageCodes: relatedProjectStageFilterOptions.map((option) => option.value) }))}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
全选阶段
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({ ...current, relatedProjectStageCodes: [] }))}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
全部取消
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{relatedProjectStageFilterOptions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{relatedProjectStageFilterOptions.map((option) => {
|
||||
const checked = selectedRelatedProjectStageCodes.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} className={cn(
|
||||
"flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-colors",
|
||||
checked
|
||||
? "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
|
||||
: "border-slate-200 bg-white text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleRelatedProjectStageToggle(option.value)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">未加载到阶段字典,导出时不会按关联项目阶段过滤。</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
|
|
@ -1144,6 +1338,7 @@ export default function Expansion() {
|
|||
const [editCityOptions, setEditCityOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [relatedProjectStageOptions, setRelatedProjectStageOptions] = useState<ExpansionDictOption[]>([]);
|
||||
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 && (
|
||||
<ExpansionExportFilterModal
|
||||
activeTab={activeTab}
|
||||
initialFilters={exportFilters}
|
||||
initialFilters={applyDefaultRelatedProjectStageFilters(exportFilters, relatedProjectStageOptions)}
|
||||
exporting={exporting}
|
||||
exportError={exportError}
|
||||
officeOptions={officeOptions}
|
||||
|
|
@ -2456,6 +2693,7 @@ export default function Expansion() {
|
|||
provinceOptions={provinceOptions}
|
||||
certificationLevelOptions={certificationLevelOptions}
|
||||
channelAttributeOptions={channelAttributeOptions}
|
||||
relatedProjectStageOptions={relatedProjectStageOptions}
|
||||
onClose={() => 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OpportunityExportFilters>(initialFilters);
|
||||
const normalizedInitialFilters = applyDefaultOpportunityExportStageFilters(initialFilters, stageOptions);
|
||||
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(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({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({
|
||||
...applyDefaultOpportunityExportStageFilters({}, stageOptions),
|
||||
selectedFields: resolveSelectedOpportunityFields(current.selectedFields),
|
||||
}))}
|
||||
disabled={!hasDraftFilters}
|
||||
|
|
@ -915,17 +1029,64 @@ function OpportunityExportFilterModal({
|
|||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目阶段</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.stage ?? ""}
|
||||
options={toSearchableOptions(stageOptions, "全部阶段")}
|
||||
placeholder="全部阶段"
|
||||
sheetTitle="选择项目阶段"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("stage", value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-900/40 sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">项目阶段</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">多选,默认排除已丢单/已放弃阶段。</p>
|
||||
</div>
|
||||
{exportStageOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({ ...current, stage: undefined, stageCodes: defaultStageCodes }))}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({ ...current, stage: undefined, stageCodes: exportStageOptions.map((option) => option.value) }))}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
全选阶段
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters((current) => ({ ...current, stage: undefined, stageCodes: [] }))}
|
||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
全部取消
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{exportStageOptions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{exportStageOptions.map((option) => {
|
||||
const checked = selectedStageCodes.includes(option.value);
|
||||
return (
|
||||
<label key={option.value} className={cn(
|
||||
"flex items-center gap-3 rounded-xl border px-3 py-2 text-sm transition-colors",
|
||||
checked
|
||||
? "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
|
||||
: "border-slate-200 bg-white text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300",
|
||||
)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleStageToggle(option.value)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">未加载到阶段字典,导出时不会按项目阶段过滤。</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目把握度</span>
|
||||
<AdaptiveSelect
|
||||
|
|
@ -1937,6 +2098,17 @@ export default function Opportunities() {
|
|||
setForm((current) => (current.stage ? current : { ...current, stage: defaultStage }));
|
||||
}, [stageOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stageOptions.length <= 0) {
|
||||
return;
|
||||
}
|
||||
setExportFilters((current) => (
|
||||
current.stageCodes === undefined
|
||||
? applyDefaultOpportunityExportStageFilters(current, stageOptions)
|
||||
: current
|
||||
));
|
||||
}, [stageOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opportunityTypeOptions.length) {
|
||||
return;
|
||||
|
|
@ -2163,17 +2335,18 @@ export default function Opportunities() {
|
|||
return;
|
||||
}
|
||||
|
||||
const normalizedFilters = applyDefaultOpportunityExportStageFilters(filters, stageOptions);
|
||||
setExporting(true);
|
||||
setExportError("");
|
||||
setExportFilters(filters);
|
||||
persistOpportunityExportPreferences(filters);
|
||||
setExportFilters(normalizedFilters);
|
||||
persistOpportunityExportPreferences(normalizedFilters);
|
||||
|
||||
try {
|
||||
const overview = await getOpportunityOverview("", "全部");
|
||||
const exportItems = (overview.items ?? [])
|
||||
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
|
||||
.filter((item) => matchesOpportunityExportFilters(item, filters, effectiveConfidenceOptions));
|
||||
const selectedFieldKeys = resolveSelectedOpportunityFields(filters.selectedFields);
|
||||
.filter((item) => matchesOpportunityExportFilters(item, normalizedFilters, effectiveConfidenceOptions));
|
||||
const selectedFieldKeys = resolveSelectedOpportunityFields(normalizedFilters.selectedFields);
|
||||
if (exportItems.length <= 0) {
|
||||
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未签单" : "已签单"}商机`);
|
||||
}
|
||||
|
|
@ -2225,6 +2398,9 @@ export default function Opportunities() {
|
|||
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",
|
||||
|
|
@ -2813,7 +2989,7 @@ export default function Opportunities() {
|
|||
<AnimatePresence>
|
||||
{exportFilterOpen ? (
|
||||
<OpportunityExportFilterModal
|
||||
initialFilters={exportFilters}
|
||||
initialFilters={applyDefaultOpportunityExportStageFilters(exportFilters, stageOptions)}
|
||||
exporting={exporting}
|
||||
exportError={exportError}
|
||||
archiveTab={archiveTab}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import { isWecomBrowser, isWecomJsSdkLocationEnabled, resolveWecomLocation } fro
|
|||
|
||||
const LEGACY_NEXT_PLAN_LABEL = "后续规划";
|
||||
const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
||||
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
||||
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
||||
|
||||
const reportFieldLabels = {
|
||||
|
|
@ -215,6 +216,7 @@ function createEmptyReportLine(): WorkReportLineItem {
|
|||
evaluationContent: "",
|
||||
nextPlan: "",
|
||||
latestProgress: "",
|
||||
stage: "",
|
||||
communicationTime: "",
|
||||
communicationContent: "",
|
||||
};
|
||||
|
|
@ -697,6 +699,8 @@ export default function Work() {
|
|||
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
|
||||
const [channelOptions, setChannelOptions] = useState<WorkRelationOption[]>([]);
|
||||
const [opportunityOptions, setOpportunityOptions] = useState<WorkRelationOption[]>([]);
|
||||
const [opportunityItems, setOpportunityItems] = useState<OpportunityItem[]>([]);
|
||||
const [reportOpportunityStageOptions, setReportOpportunityStageOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [reportTargetsLoaded, setReportTargetsLoaded] = useState(false);
|
||||
const [reportTargetsLoading, setReportTargetsLoading] = useState(false);
|
||||
const [quickCreateOpen, setQuickCreateOpen] = useState(false);
|
||||
|
|
@ -750,6 +754,12 @@ export default function Work() {
|
|||
}
|
||||
return options.filter((option) => option.label.toLowerCase().includes(keyword));
|
||||
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
|
||||
const opportunityItemsById = useMemo(() => {
|
||||
const entries = opportunityItems
|
||||
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
|
||||
.map((item) => [item.id, item] as const);
|
||||
return new Map<number, OpportunityItem>(entries);
|
||||
}, [opportunityItems]);
|
||||
const quickOpportunityOperatorMode = useMemo(
|
||||
() => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions),
|
||||
[quickOpportunityForm.operatorName, quickOpportunityOperatorOptions],
|
||||
|
|
@ -806,9 +816,10 @@ export default function Work() {
|
|||
return;
|
||||
}
|
||||
setReportTargetsLoading(true);
|
||||
const [expansionResult, opportunityResult] = await Promise.allSettled([
|
||||
const [expansionResult, opportunityResult, opportunityMetaResult] = await Promise.allSettled([
|
||||
getExpansionOverview(""),
|
||||
getOpportunityOverview(),
|
||||
getOpportunityMeta(),
|
||||
]);
|
||||
|
||||
if (expansionResult.status === "fulfilled") {
|
||||
|
|
@ -817,7 +828,13 @@ export default function Work() {
|
|||
}
|
||||
|
||||
if (opportunityResult.status === "fulfilled") {
|
||||
setOpportunityOptions(buildOpportunityOptions(opportunityResult.value.items ?? []));
|
||||
const items = opportunityResult.value.items ?? [];
|
||||
setOpportunityOptions(buildOpportunityOptions(items));
|
||||
setOpportunityItems(items);
|
||||
}
|
||||
|
||||
if (opportunityMetaResult.status === "fulfilled") {
|
||||
setReportOpportunityStageOptions((opportunityMetaResult.value.stageOptions ?? []).filter((item) => item.value));
|
||||
}
|
||||
if (expansionResult.status === "fulfilled" && opportunityResult.status === "fulfilled") {
|
||||
setReportTargetsLoaded(true);
|
||||
|
|
@ -826,13 +843,16 @@ export default function Work() {
|
|||
}, [reportTargetsLoaded, reportTargetsLoading]);
|
||||
|
||||
const refreshReportTargets = useCallback(async () => {
|
||||
const [expansionData, opportunityData] = await Promise.all([
|
||||
const [expansionData, opportunityData, opportunityMeta] = await Promise.all([
|
||||
getExpansionOverview(""),
|
||||
getOpportunityOverview(),
|
||||
getOpportunityMeta(),
|
||||
]);
|
||||
setSalesOptions(buildSalesOptions(expansionData.salesItems ?? []));
|
||||
setChannelOptions(buildChannelOptions(expansionData.channelItems ?? []));
|
||||
setOpportunityOptions(buildOpportunityOptions(opportunityData.items ?? []));
|
||||
setOpportunityItems(opportunityData.items ?? []);
|
||||
setReportOpportunityStageOptions((opportunityMeta.stageOptions ?? []).filter((item) => item.value));
|
||||
setReportTargetsLoaded(true);
|
||||
return {
|
||||
salesItems: expansionData.salesItems ?? [],
|
||||
|
|
@ -986,7 +1006,14 @@ export default function Work() {
|
|||
setPageError("");
|
||||
|
||||
try {
|
||||
const data = await getWorkOverview();
|
||||
const [data, opportunityMeta] = await Promise.all([
|
||||
getWorkOverview(),
|
||||
getOpportunityMeta().catch(() => null),
|
||||
]);
|
||||
const effectiveStageOptions = ((opportunityMeta?.stageOptions ?? reportOpportunityStageOptions) || []).filter((item) => item.value);
|
||||
if (effectiveStageOptions.length > 0) {
|
||||
setReportOpportunityStageOptions(effectiveStageOptions);
|
||||
}
|
||||
setReportStatus(data.todayReport?.status);
|
||||
setCheckInForm({
|
||||
...defaultCheckInForm,
|
||||
|
|
@ -1005,7 +1032,7 @@ export default function Work() {
|
|||
setReportForm({
|
||||
workContent: data.todayReport?.workContent || "",
|
||||
lineItems: data.todayReport?.lineItems?.length
|
||||
? data.todayReport.lineItems.map(normalizeLoadedLineItem)
|
||||
? data.todayReport.lineItems.map((item) => normalizeLoadedLineItem(item, effectiveStageOptions))
|
||||
: [createEmptyReportLine()],
|
||||
planItems: data.todayReport?.planItems?.length
|
||||
? data.todayReport.planItems.map((item) => ({ content: item.content || "" }))
|
||||
|
|
@ -1524,6 +1551,15 @@ export default function Work() {
|
|||
}
|
||||
|
||||
const lineIndex = objectPicker.lineIndex ?? 0;
|
||||
const currentOpportunity = objectPicker.bizType === "opportunity" ? opportunityItemsById.get(option.id) : undefined;
|
||||
const opportunityStageLabel = resolveOpportunityStageLabel(
|
||||
currentOpportunity?.stageCode || currentOpportunity?.stage,
|
||||
reportOpportunityStageOptions,
|
||||
);
|
||||
const opportunityStageCode = resolveOpportunityStageCode(
|
||||
currentOpportunity?.stageCode || currentOpportunity?.stage,
|
||||
reportOpportunityStageOptions,
|
||||
) || currentOpportunity?.stageCode || currentOpportunity?.stage || "";
|
||||
setReportForm((current) => ({
|
||||
...current,
|
||||
lineItems: current.lineItems.map((item, index) => {
|
||||
|
|
@ -1536,7 +1572,14 @@ export default function Work() {
|
|||
bizId: option.id,
|
||||
bizName: option.label,
|
||||
workDate: currentWorkDate,
|
||||
editorText: buildEditorTemplate(objectPicker.bizType, option.label),
|
||||
stage: objectPicker.bizType === "opportunity" ? opportunityStageCode : "",
|
||||
editorText: buildEditorTemplate(
|
||||
objectPicker.bizType,
|
||||
option.label,
|
||||
objectPicker.bizType === "opportunity" && opportunityStageLabel
|
||||
? { [OPPORTUNITY_STAGE_LABEL]: opportunityStageLabel }
|
||||
: undefined,
|
||||
),
|
||||
content: "",
|
||||
};
|
||||
}),
|
||||
|
|
@ -1575,6 +1618,26 @@ export default function Work() {
|
|||
}));
|
||||
};
|
||||
|
||||
const handleReportLineStageChange = (index: number, value: string) => {
|
||||
setReportForm((current) => ({
|
||||
...current,
|
||||
lineItems: current.lineItems.map((item, itemIndex) => {
|
||||
if (itemIndex !== index) {
|
||||
return item;
|
||||
}
|
||||
if (item.bizType !== "opportunity" || !item.bizName) {
|
||||
return item;
|
||||
}
|
||||
const stageCode = resolveOpportunityStageCode(value, reportOpportunityStageOptions) || value.trim();
|
||||
return {
|
||||
...item,
|
||||
stage: stageCode,
|
||||
editorText: sanitizeEditorText(item.bizType, item.bizName, item.editorText || buildEditorTemplate(item.bizType, item.bizName)),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddReportLine = () => {
|
||||
setReportForm((current) => ({
|
||||
...current,
|
||||
|
|
@ -1689,7 +1752,9 @@ export default function Work() {
|
|||
if (isOnlySeeRole) {
|
||||
throw new Error("当前角色仅可查看日报历史记录");
|
||||
}
|
||||
const normalizedLineItems = reportForm.lineItems.map((item) => normalizeReportLineItem(item, currentWorkDate));
|
||||
const normalizedLineItems = reportForm.lineItems.map((item) => (
|
||||
normalizeReportLineItem(item, currentWorkDate, reportOpportunityStageOptions)
|
||||
));
|
||||
const normalizedPlanItems = reportForm.planItems
|
||||
.map((item) => ({ content: item.content.trim() }))
|
||||
.filter((item) => item.content);
|
||||
|
|
@ -1697,7 +1762,7 @@ export default function Work() {
|
|||
if (!normalizedLineItems.length) {
|
||||
throw new Error("请至少填写一条今日工作内容");
|
||||
}
|
||||
validateReportLineItems(normalizedLineItems);
|
||||
validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions);
|
||||
if (!normalizedPlanItems.length) {
|
||||
throw new Error("请至少填写一条明日工作计划");
|
||||
}
|
||||
|
|
@ -1813,11 +1878,13 @@ export default function Work() {
|
|||
reportStatus={reportStatus}
|
||||
currentWorkDate={currentWorkDate}
|
||||
reportForm={reportForm}
|
||||
reportOpportunityStageOptions={reportOpportunityStageOptions}
|
||||
onAddReportLine={handleAddReportLine}
|
||||
onRemoveReportLine={handleRemoveReportLine}
|
||||
onOpenObjectPicker={handleOpenObjectPicker}
|
||||
onReportLineKeyDown={handleReportLineKeyDown}
|
||||
onReportLineChange={handleReportLineChange}
|
||||
onReportLineStageChange={handleReportLineStageChange}
|
||||
onAddPlanItem={handleAddPlanItem}
|
||||
onPlanItemChange={handlePlanItemChange}
|
||||
onRemovePlanItem={handleRemovePlanItem}
|
||||
|
|
@ -3473,11 +3540,13 @@ function ReportPanel({
|
|||
reportStatus,
|
||||
currentWorkDate,
|
||||
reportForm,
|
||||
reportOpportunityStageOptions,
|
||||
onAddReportLine,
|
||||
onRemoveReportLine,
|
||||
onOpenObjectPicker,
|
||||
onReportLineKeyDown,
|
||||
onReportLineChange,
|
||||
onReportLineStageChange,
|
||||
onAddPlanItem,
|
||||
onPlanItemChange,
|
||||
onRemovePlanItem,
|
||||
|
|
@ -3492,11 +3561,13 @@ function ReportPanel({
|
|||
reportStatus?: string;
|
||||
currentWorkDate: string;
|
||||
reportForm: CreateWorkDailyReportPayload;
|
||||
reportOpportunityStageOptions: OpportunityDictOption[];
|
||||
onAddReportLine: () => void;
|
||||
onRemoveReportLine: (index: number) => void;
|
||||
onOpenObjectPicker: (mode: PickerMode, lineIndex?: number, bizType?: BizType) => void;
|
||||
onReportLineKeyDown: (index: number, event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onReportLineChange: (index: number, value: string) => void;
|
||||
onReportLineStageChange: (index: number, value: string) => void;
|
||||
onAddPlanItem: () => void;
|
||||
onPlanItemChange: (index: number, value: string) => void;
|
||||
onRemovePlanItem: (index: number) => void;
|
||||
|
|
@ -3658,43 +3729,124 @@ function ReportPanel({
|
|||
{reportForm.lineItems.map((item, index) => {
|
||||
const isEditing = editingReportLineIndex === index;
|
||||
const collapsedPreviewLines = buildCollapsedPreviewLines(item.editorText, "先输入 @ 选择对象,系统会自动生成固定字段。");
|
||||
const opportunityStageValue = resolveOpportunityStageCode(item.stage, reportOpportunityStageOptions) || item.stage || "";
|
||||
const opportunityStageLabel = resolveOpportunityStageLabel(item.stage, reportOpportunityStageOptions);
|
||||
const objectLine = item.bizName ? buildEditorMentionLine(item.bizType, item.bizName) : "";
|
||||
const detailPreviewLines = item.bizName ? collapsedPreviewLines.slice(1) : collapsedPreviewLines;
|
||||
const editorBodyText = item.editorText
|
||||
? item.editorText.replace(/\r/g, "").split("\n").slice(1).join("\n")
|
||||
: "";
|
||||
return (
|
||||
<div key={`report-line-${index}`} className="flex items-start gap-3">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
rows={3}
|
||||
ref={(element) => {
|
||||
reportLineTextareaRefs.current[index] = element;
|
||||
syncAutoHeightTextarea(element);
|
||||
}}
|
||||
value={item.editorText || ""}
|
||||
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
||||
onChange={(event) => {
|
||||
syncAutoHeightTextarea(event.currentTarget);
|
||||
onReportLineChange(index, event.target.value);
|
||||
}}
|
||||
onBlur={() => setEditingReportLineIndex((current) => (current === index ? null : current))}
|
||||
placeholder="先输入 @ 选择对象,系统会自动生成固定字段。"
|
||||
className="crm-input-box crm-input-text min-h-[96px] flex-1 resize-none overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||
/>
|
||||
<div className="flex-1 space-y-2.5">
|
||||
{item.bizName ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-3.5 py-2.5 dark:border-slate-800 dark:bg-slate-900/30">
|
||||
<p className="min-w-0 flex-1 text-sm font-semibold leading-6 text-slate-900 dark:text-white">
|
||||
{objectLine}
|
||||
</p>
|
||||
{item.bizType === "opportunity" ? (
|
||||
<div className="w-full sm:w-[180px]">
|
||||
<AdaptiveSelect
|
||||
value={opportunityStageValue}
|
||||
placeholder="项目阶段"
|
||||
sheetTitle="项目阶段"
|
||||
searchable
|
||||
searchPlaceholder="搜索项目阶段"
|
||||
options={reportOpportunityStageOptions.map((stageOption) => ({
|
||||
value: stageOption.value || "",
|
||||
label: stageOption.label || stageOption.value || "",
|
||||
}))}
|
||||
className={cn(
|
||||
"min-h-9 rounded-full border-violet-200 bg-white px-3 py-2 text-sm font-medium text-violet-700 shadow-none hover:border-violet-300 dark:border-violet-500/30 dark:bg-slate-950/40 dark:text-violet-200 dark:hover:border-violet-400",
|
||||
!opportunityStageValue ? "text-slate-400 dark:text-slate-500" : "",
|
||||
)}
|
||||
onChange={(value) => onReportLineStageChange(index, value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{item.bizType !== "opportunity" && opportunityStageLabel ? (
|
||||
<span className="inline-flex rounded-full bg-violet-100 px-2.5 py-1 text-xs font-medium text-violet-700 dark:bg-violet-500/15 dark:text-violet-300">
|
||||
{opportunityStageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : item.bizType === "opportunity" ? (
|
||||
<div className="w-full sm:w-[180px]">
|
||||
<AdaptiveSelect
|
||||
value={opportunityStageValue}
|
||||
placeholder="项目阶段"
|
||||
sheetTitle="项目阶段"
|
||||
searchable
|
||||
searchPlaceholder="搜索项目阶段"
|
||||
options={reportOpportunityStageOptions.map((stageOption) => ({
|
||||
value: stageOption.value || "",
|
||||
label: stageOption.label || stageOption.value || "",
|
||||
}))}
|
||||
className={cn(
|
||||
"min-h-9 rounded-full border-violet-200 bg-white px-3 py-2 text-sm font-medium text-violet-700 shadow-none hover:border-violet-300 dark:border-violet-500/30 dark:bg-slate-950/40 dark:text-violet-200 dark:hover:border-violet-400",
|
||||
!opportunityStageValue ? "text-slate-400 dark:text-slate-500" : "",
|
||||
)}
|
||||
onChange={(value) => onReportLineStageChange(index, value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
rows={3}
|
||||
ref={(element) => {
|
||||
reportLineTextareaRefs.current[index] = element;
|
||||
syncAutoHeightTextarea(element);
|
||||
}}
|
||||
value={item.bizName ? editorBodyText : item.editorText || ""}
|
||||
onKeyDown={(event) => onReportLineKeyDown(index, event)}
|
||||
onChange={(event) => {
|
||||
syncAutoHeightTextarea(event.currentTarget);
|
||||
onReportLineChange(
|
||||
index,
|
||||
item.bizName
|
||||
? `${objectLine}\n${event.target.value}`
|
||||
: event.target.value,
|
||||
);
|
||||
}}
|
||||
placeholder="先输入 @ 选择对象,系统会自动生成固定字段。"
|
||||
className="crm-input-box crm-input-text min-h-[88px] w-full resize-none overflow-hidden rounded-2xl border border-slate-200 bg-white leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => activateReportLineEditor(index)}
|
||||
className="min-h-[72px] flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition-colors hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
||||
className="min-h-[68px] flex-1 rounded-2xl border border-slate-200 bg-slate-50/80 px-3.5 py-3 text-left transition-colors hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-violet-500"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{collapsedPreviewLines.map((line, lineIndex) => (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p
|
||||
key={`report-line-preview-${index}-${lineIndex}`}
|
||||
className={cn(
|
||||
"truncate text-sm leading-6",
|
||||
item.editorText ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500",
|
||||
"min-w-0 flex-1 truncate text-sm font-semibold leading-6",
|
||||
item.bizName ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500",
|
||||
)}
|
||||
>
|
||||
{line}
|
||||
{item.bizName ? objectLine : "先输入 @ 选择对象,系统会自动生成固定字段。"}
|
||||
</p>
|
||||
))}
|
||||
{item.bizType === "opportunity" && opportunityStageLabel ? (
|
||||
<span className="inline-flex rounded-full bg-violet-100 px-2.5 py-1 text-xs font-medium text-violet-700 dark:bg-violet-500/15 dark:text-violet-300">
|
||||
{opportunityStageLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{detailPreviewLines.map((line, lineIndex) => (
|
||||
<p
|
||||
key={`report-line-preview-${index}-${lineIndex}`}
|
||||
className={cn(
|
||||
"truncate text-sm leading-6",
|
||||
item.editorText ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500",
|
||||
)}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -4340,6 +4492,38 @@ function normalizeObjectPickerQuery(query: string) {
|
|||
return query.replace(/@+/g, "").trimStart();
|
||||
}
|
||||
|
||||
function resolveOpportunityStageCode(rawStage: string | undefined, stageOptions: OpportunityDictOption[]) {
|
||||
const normalizedStage = rawStage?.trim() || "";
|
||||
if (!normalizedStage) {
|
||||
return "";
|
||||
}
|
||||
const matchedByValue = stageOptions.find((item) => (item.value || "").trim() === normalizedStage);
|
||||
if (matchedByValue?.value) {
|
||||
return matchedByValue.value;
|
||||
}
|
||||
const matchedByLabel = stageOptions.find((item) => (item.label || "").trim() === normalizedStage);
|
||||
if (matchedByLabel?.value) {
|
||||
return matchedByLabel.value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveOpportunityStageLabel(rawStage: string | undefined, stageOptions: OpportunityDictOption[]) {
|
||||
const normalizedStage = rawStage?.trim() || "";
|
||||
if (!normalizedStage) {
|
||||
return "";
|
||||
}
|
||||
const matchedByValue = stageOptions.find((item) => (item.value || "").trim() === normalizedStage);
|
||||
if (matchedByValue?.label) {
|
||||
return matchedByValue.label;
|
||||
}
|
||||
const matchedByLabel = stageOptions.find((item) => (item.label || "").trim() === normalizedStage);
|
||||
if (matchedByLabel?.label) {
|
||||
return matchedByLabel.label;
|
||||
}
|
||||
return normalizedStage;
|
||||
}
|
||||
|
||||
function getTemplateFields(bizType: BizType) {
|
||||
return [...reportFieldLabels[bizType]];
|
||||
}
|
||||
|
|
@ -4433,7 +4617,7 @@ function sanitizeEditorText(bizType: BizType, bizName: string, input: string) {
|
|||
return buildEditorTemplate(bizType, bizName, parseTemplateValues(bizType, input));
|
||||
}
|
||||
|
||||
function normalizeLoadedLineItem(item: WorkReportLineItem): WorkReportLineItem {
|
||||
function normalizeLoadedLineItem(item: WorkReportLineItem, opportunityStageOptions: OpportunityDictOption[]): WorkReportLineItem {
|
||||
const normalized: WorkReportLineItem = { ...createEmptyReportLine(), ...item };
|
||||
const fieldValues: Record<string, string> = {};
|
||||
if (normalized.bizType === "sales" || normalized.bizType === "channel") {
|
||||
|
|
@ -4441,7 +4625,10 @@ function normalizeLoadedLineItem(item: WorkReportLineItem): WorkReportLineItem {
|
|||
fieldValues[LEGACY_NEXT_PLAN_LABEL] = normalized.nextPlan || extractContentByLabel(normalized.content, LEGACY_NEXT_PLAN_LABEL) || "";
|
||||
} else {
|
||||
fieldValues["项目最新进展"] = normalized.latestProgress || extractContentByLabel(normalized.content, "项目最新进展") || "";
|
||||
const resolvedStageValue = normalized.stage || extractContentByLabel(normalized.content, OPPORTUNITY_STAGE_LABEL) || "";
|
||||
fieldValues[OPPORTUNITY_STAGE_LABEL] = resolveOpportunityStageLabel(resolvedStageValue, opportunityStageOptions);
|
||||
fieldValues[OPPORTUNITY_NEXT_PLAN_LABEL] = normalized.nextPlan || extractContentByLabels(normalized.content, [OPPORTUNITY_NEXT_PLAN_LABEL, LEGACY_NEXT_PLAN_LABEL]) || "";
|
||||
normalized.stage = resolveOpportunityStageCode(resolvedStageValue, opportunityStageOptions) || normalized.stage || "";
|
||||
}
|
||||
const bizName = normalized.bizName || "未命名对象";
|
||||
normalized.editorText = sanitizeEditorText(
|
||||
|
|
@ -4452,19 +4639,29 @@ function normalizeLoadedLineItem(item: WorkReportLineItem): WorkReportLineItem {
|
|||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeReportLineItem(item: WorkReportLineItem, currentWorkDate: string): WorkReportLineItem {
|
||||
function normalizeReportLineItem(
|
||||
item: WorkReportLineItem,
|
||||
currentWorkDate: string,
|
||||
opportunityStageOptions: OpportunityDictOption[],
|
||||
): WorkReportLineItem {
|
||||
const bizName = item.bizName?.trim() || "";
|
||||
const editorText = bizName ? sanitizeEditorText(item.bizType, bizName, item.editorText || "") : "";
|
||||
const parsedValues = parseTemplateValues(item.bizType, item.editorText || "");
|
||||
const editorText = bizName ? buildEditorTemplate(item.bizType, bizName, parsedValues) : "";
|
||||
const values = parseTemplateValues(item.bizType, editorText);
|
||||
const normalizedStage = item.bizType === "opportunity"
|
||||
? (resolveOpportunityStageCode(item.stage, opportunityStageOptions) || item.stage?.trim() || "")
|
||||
: item.stage;
|
||||
return {
|
||||
...item,
|
||||
workDate: currentWorkDate,
|
||||
bizName,
|
||||
stage: normalizedStage,
|
||||
editorText,
|
||||
content: buildLinePreview(item.bizType, parseTemplateValues(item.bizType, editorText)),
|
||||
content: buildLinePreview(item.bizType, values, normalizedStage, opportunityStageOptions),
|
||||
};
|
||||
}
|
||||
|
||||
function validateReportLineItems(lineItems: WorkReportLineItem[]) {
|
||||
function validateReportLineItems(lineItems: WorkReportLineItem[], opportunityStageOptions: OpportunityDictOption[]) {
|
||||
for (const item of lineItems) {
|
||||
if (!item.bizId || !item.bizName || !item.editorText) {
|
||||
throw new Error("每一条日报都需要先通过 @ 选择对象");
|
||||
|
|
@ -4474,6 +4671,12 @@ function validateReportLineItems(lineItems: WorkReportLineItem[]) {
|
|||
if (!values["项目最新进展"]?.trim()) {
|
||||
throw new Error(`商机“${item.bizName}”请填写项目最新进展`);
|
||||
}
|
||||
if (!item.stage?.trim()) {
|
||||
throw new Error(`商机“${item.bizName}”请选择项目阶段`);
|
||||
}
|
||||
if (opportunityStageOptions.length > 0 && !resolveOpportunityStageCode(item.stage, opportunityStageOptions)) {
|
||||
throw new Error(`商机“${item.bizName}”的项目阶段无效,请按字典选择`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!values["沟通内容"]?.trim()) {
|
||||
|
|
@ -4482,10 +4685,17 @@ function validateReportLineItems(lineItems: WorkReportLineItem[]) {
|
|||
}
|
||||
}
|
||||
|
||||
function buildLinePreview(bizType: BizType, values: Record<string, string>) {
|
||||
function buildLinePreview(
|
||||
bizType: BizType,
|
||||
values: Record<string, string>,
|
||||
opportunityStage?: string,
|
||||
opportunityStageOptions: OpportunityDictOption[] = [],
|
||||
) {
|
||||
if (bizType === "opportunity") {
|
||||
const stageLabel = resolveOpportunityStageLabel(opportunityStage, opportunityStageOptions);
|
||||
return [
|
||||
values["项目最新进展"] ? `项目最新进展:${values["项目最新进展"]}` : "",
|
||||
stageLabel ? `${OPPORTUNITY_STAGE_LABEL}:${stageLabel}` : "",
|
||||
values[OPPORTUNITY_NEXT_PLAN_LABEL] ? `${OPPORTUNITY_NEXT_PLAN_LABEL}:${values[OPPORTUNITY_NEXT_PLAN_LABEL]}` : "",
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
|
@ -4498,7 +4708,7 @@ function buildLinePreview(bizType: BizType, values: Record<string, string>) {
|
|||
function buildReportSummary(lineItems: WorkReportLineItem[]) {
|
||||
return lineItems.map((item, index) => {
|
||||
const values = parseTemplateValues(item.bizType, item.editorText || "");
|
||||
const detail = buildLinePreview(item.bizType, values).replace(/\n/g, ";");
|
||||
const detail = buildLinePreview(item.bizType, values, item.stage).replace(/\n/g, ";");
|
||||
return `${index + 1}. ${item.workDate} 跟进${getBizTypeLabel(item.bizType)}“${item.bizName || ""}”:${detail}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export default defineConfig(({mode}) => {
|
|||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UnisBase - 智能会议系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-DM2jW1WT.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BYhaESGV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1831,6 +1831,22 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
|
|
@ -3483,6 +3499,21 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ import {
|
|||
ZoomInOutlined_default,
|
||||
ZoomOutOutlined_default,
|
||||
require_react_is
|
||||
} from "./chunk-NDVMXJDK.js";
|
||||
} from "./chunk-JVSQUNE5.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-RLJ2RCJQ.js";
|
||||
|
|
|
|||
|
|
@ -1,116 +1,122 @@
|
|||
{
|
||||
"hash": "c73f5e08",
|
||||
"hash": "354f676b",
|
||||
"configHash": "dbaa87de",
|
||||
"lockfileHash": "cd02bc2e",
|
||||
"browserHash": "052213b8",
|
||||
"lockfileHash": "056b0532",
|
||||
"browserHash": "4d741381",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "2387bc28",
|
||||
"fileHash": "bd6731e7",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "3d2f5ddf",
|
||||
"fileHash": "089bfd09",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "64ec3b5f",
|
||||
"fileHash": "ef1fa8d6",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "1969f2a4",
|
||||
"fileHash": "229eff9e",
|
||||
"needsInterop": true
|
||||
},
|
||||
"@ant-design/icons": {
|
||||
"src": "../../@ant-design/icons/es/index.js",
|
||||
"file": "@ant-design_icons.js",
|
||||
"fileHash": "4e7c5123",
|
||||
"fileHash": "79cdebf0",
|
||||
"needsInterop": false
|
||||
},
|
||||
"antd": {
|
||||
"src": "../../antd/es/index.js",
|
||||
"file": "antd.js",
|
||||
"fileHash": "f83c9b2b",
|
||||
"fileHash": "5ad9a71d",
|
||||
"needsInterop": false
|
||||
},
|
||||
"axios": {
|
||||
"src": "../../axios/index.js",
|
||||
"file": "axios.js",
|
||||
"fileHash": "33f1c643",
|
||||
"fileHash": "e9c89b86",
|
||||
"needsInterop": false
|
||||
},
|
||||
"dayjs": {
|
||||
"src": "../../dayjs/dayjs.min.js",
|
||||
"file": "dayjs.js",
|
||||
"fileHash": "f75c44a0",
|
||||
"fileHash": "f57cff8b",
|
||||
"needsInterop": true
|
||||
},
|
||||
"i18next": {
|
||||
"src": "../../i18next/dist/esm/i18next.js",
|
||||
"file": "i18next.js",
|
||||
"fileHash": "771d450b",
|
||||
"fileHash": "97b64fe9",
|
||||
"needsInterop": false
|
||||
},
|
||||
"i18next-browser-languagedetector": {
|
||||
"src": "../../i18next-browser-languagedetector/dist/esm/i18nextBrowserLanguageDetector.js",
|
||||
"file": "i18next-browser-languagedetector.js",
|
||||
"fileHash": "861910aa",
|
||||
"fileHash": "73c25f20",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "af92414c",
|
||||
"fileHash": "a00ea4e6",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-i18next": {
|
||||
"src": "../../react-i18next/dist/es/index.js",
|
||||
"file": "react-i18next.js",
|
||||
"fileHash": "e4a57ecd",
|
||||
"fileHash": "b235953c",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-router-dom": {
|
||||
"src": "../../react-router-dom/dist/index.js",
|
||||
"file": "react-router-dom.js",
|
||||
"fileHash": "346068ef",
|
||||
"fileHash": "dde6a3e5",
|
||||
"needsInterop": false
|
||||
},
|
||||
"zustand": {
|
||||
"src": "../../zustand/esm/index.mjs",
|
||||
"file": "zustand.js",
|
||||
"fileHash": "2dc24c20",
|
||||
"fileHash": "3b7b5f29",
|
||||
"needsInterop": false
|
||||
},
|
||||
"lucide-react": {
|
||||
"src": "../../lucide-react/dist/esm/lucide-react.js",
|
||||
"file": "lucide-react.js",
|
||||
"fileHash": "2001bf42",
|
||||
"fileHash": "f6d59b6c",
|
||||
"needsInterop": false
|
||||
},
|
||||
"echarts": {
|
||||
"src": "../../echarts/index.js",
|
||||
"file": "echarts.js",
|
||||
"fileHash": "bdf6a248",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-IDVUNHDH": {
|
||||
"file": "chunk-IDVUNHDH.js"
|
||||
},
|
||||
"chunk-GL7YRBYQ": {
|
||||
"file": "chunk-GL7YRBYQ.js"
|
||||
},
|
||||
"chunk-IDVUNHDH": {
|
||||
"file": "chunk-IDVUNHDH.js"
|
||||
},
|
||||
"chunk-NUMECXU6": {
|
||||
"file": "chunk-NUMECXU6.js"
|
||||
},
|
||||
"chunk-JVSQUNE5": {
|
||||
"file": "chunk-JVSQUNE5.js"
|
||||
},
|
||||
"chunk-CM2AK5IQ": {
|
||||
"file": "chunk-CM2AK5IQ.js"
|
||||
},
|
||||
"chunk-NDVMXJDK": {
|
||||
"file": "chunk-NDVMXJDK.js"
|
||||
},
|
||||
"chunk-RLJ2RCJQ": {
|
||||
"file": "chunk-RLJ2RCJQ.js"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-NUMECXU6.js";
|
||||
import {
|
||||
require_dayjs_min
|
||||
} from "./chunk-CM2AK5IQ.js";
|
||||
import {
|
||||
BarsOutlined_default,
|
||||
CalendarOutlined_default,
|
||||
|
|
@ -58,7 +55,10 @@ import {
|
|||
ZoomInOutlined_default,
|
||||
ZoomOutOutlined_default,
|
||||
require_react_is
|
||||
} from "./chunk-NDVMXJDK.js";
|
||||
} from "./chunk-JVSQUNE5.js";
|
||||
import {
|
||||
require_dayjs_min
|
||||
} from "./chunk-CM2AK5IQ.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-RLJ2RCJQ.js";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import {
|
||||
keysFromSelector
|
||||
} from "./chunk-IDVUNHDH.js";
|
||||
import {
|
||||
require_shim
|
||||
} from "./chunk-GL7YRBYQ.js";
|
||||
import {
|
||||
keysFromSelector
|
||||
} from "./chunk-IDVUNHDH.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-RLJ2RCJQ.js";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"antd": "^5.13.2",
|
||||
"axios": "^1.6.7",
|
||||
"classnames": "^2.5.1",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "^25.8.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
|
|
@ -1811,6 +1812,22 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
|
|
@ -3463,6 +3480,21 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"antd": "^5.13.2",
|
||||
"axios": "^1.6.7",
|
||||
"classnames": "^2.5.1",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "^25.8.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export async function previewDashboardAnalyticsConfig(tenantId?: number) {
|
|||
return resp.data.data as DashboardAnalyticsPanelPreview;
|
||||
}
|
||||
|
||||
export async function previewDashboardAnalyticsCardDetail(cardKey: string, tenantId?: number) {
|
||||
const resp = await http.get("/sys/api/admin/dashboard-analytics-config/preview/card-detail", { params: { tenantId, cardKey } });
|
||||
export async function previewDashboardAnalyticsCardDetail(cardKey: string, tenantId?: number, dimension?: string) {
|
||||
const resp = await http.get("/sys/api/admin/dashboard-analytics-config/preview/card-detail", { params: { tenantId, cardKey, dimension } });
|
||||
return resp.data.data as DashboardAnalyticsPreviewCard;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -19,6 +19,11 @@ export default {
|
|||
previewError: "Failed to preview home analytics settings.",
|
||||
saveError: "Failed to save home analytics settings.",
|
||||
cardKey: "Card Key",
|
||||
groupName: "Group Name",
|
||||
groupNameHint: "Cards with the same group name are rendered in one section on the home page, which works well for business or data-domain grouping.",
|
||||
groupNamePlaceholder: "For example: Sales Overview",
|
||||
groupTabAll: "All",
|
||||
ungrouped: "Ungrouped",
|
||||
cardTitle: "Card Title",
|
||||
cardSubtitle: "Card Subtitle",
|
||||
renderType: "Render Type",
|
||||
|
|
@ -30,6 +35,50 @@ export default {
|
|||
displayTextConfig: "Display Copy Config",
|
||||
displayTextSection: "Display Copy",
|
||||
displayTextHint: "Configure chart copy for the current render type. The system will save it as config automatically.",
|
||||
chartPreset: "Official Chart Preset",
|
||||
chartPresetHint: "Generate the option from Apache ECharts official chart presets and keep using the current SQL result as the data source.",
|
||||
seriesField: "Series Field",
|
||||
seriesFieldHint: "For multi-series charts, let the SQL return this field as well, for example `series`. It is used for stacked lines, grouped bars, nested pies, multi-funnels, and similar official presets.",
|
||||
frameField: "Frame Field",
|
||||
frameFieldHint: "The timeline version of bar race needs this extra SQL field, for example `frame`. The system will animate ranking changes across frames.",
|
||||
optionSchemaSection: "Grouped Option Schema",
|
||||
optionSchemaHint: "Use the official preset as the chart skeleton first, then fine-tune legend, axes, grid, and series styles with structured fields. Extra JSON overrides are still available at the end.",
|
||||
optionSchemaLegend: "Legend",
|
||||
optionSchemaLabel: "Labels",
|
||||
optionSchemaAxisGrid: "Axes & Grid",
|
||||
optionSchemaSeries: "Series Style",
|
||||
optionSchemaAdvanced: "Advanced",
|
||||
optionSchemaDefault: "Use preset default",
|
||||
showLegend: "Show Legend",
|
||||
legendSeriesOrder: "Legend Order",
|
||||
legendSeriesOrderHint: "Enter series names separated by commas or new lines. Both the legend and stacked series order will follow this list, and missing series will be appended automatically.",
|
||||
legendSeriesOrderPlaceholder: "For example: A:100%-80%, B:80%-60%, C:60%-40%",
|
||||
showDataLabels: "Show Data Labels",
|
||||
dataLabelMode: "Label Display Mode",
|
||||
dataLabelModeHint: "Only stacked line and stacked bar presets support showing totals only. Switch to a stacked preset to enable it.",
|
||||
legendPosition: "Legend Position",
|
||||
legendOrient: "Legend Orientation",
|
||||
labelPosition: "Label Position",
|
||||
categoryBoundaryGap: "Category Axis Gap",
|
||||
axisLabelRotate: "Category Label Rotation",
|
||||
valueAxisMin: "Value Axis Min",
|
||||
valueAxisMax: "Value Axis Max",
|
||||
gridTop: "Grid Top",
|
||||
gridRight: "Grid Right",
|
||||
gridBottom: "Grid Bottom",
|
||||
gridLeft: "Grid Left",
|
||||
lineStyleType: "Line Style",
|
||||
stepPosition: "Step Position",
|
||||
lineWidth: "Line Width",
|
||||
symbolSize: "Symbol Size",
|
||||
barMaxWidth: "Max Bar Width",
|
||||
barBorderRadius: "Bar Radius",
|
||||
pieRadiusOuter: "Outer Radius",
|
||||
pieRadiusInner: "Inner Radius",
|
||||
funnelSort: "Funnel Sort",
|
||||
funnelGap: "Funnel Gap",
|
||||
optionOverrides: "Extra ECharts Config",
|
||||
optionOverridesHint: "Optional. When filled with JSON, it will be merged into the generated ECharts option so you can match the official chart details precisely.",
|
||||
tableLabelHeader: "Table Left Header",
|
||||
tableValueHeader: "Table Right Header",
|
||||
peakPrefix: "Peak Prefix",
|
||||
|
|
@ -55,6 +104,11 @@ export default {
|
|||
metricIconCategoryChannel: "Channel",
|
||||
metricIconCategoryTask: "Task",
|
||||
metricIconCategoryGrowth: "Growth",
|
||||
metricStylePreset: "Metric Style",
|
||||
metricTheme: "Theme Palette",
|
||||
metricIconShape: "Icon Plate",
|
||||
metricTrendMode: "Trend Hint",
|
||||
metricBadgeText: "Highlight Badge",
|
||||
categoryOptions: "Fixed Categories With Zero Fill",
|
||||
categoryOptionsHint: "Configure fixed categories in order. If SQL misses one category, the system will auto-fill it with 0 and reuse the configured label and color.",
|
||||
categoryOptionLabel: "Category Label",
|
||||
|
|
@ -78,11 +132,42 @@ export default {
|
|||
enabledStatus: "Enabled",
|
||||
disabledStatus: "Disabled",
|
||||
preview: "Refresh Preview",
|
||||
previewMode: "Preview Mode",
|
||||
previewDesktop: "Desktop",
|
||||
previewMobile: "Mobile",
|
||||
livePreviewTitle: "Live Preview While Editing",
|
||||
livePreviewHint: "This area reflects the current form settings instantly. Saved preview data is used first, and sample data is used when none is available.",
|
||||
livePreviewSavedData: "The live preview is reusing saved query results and only updates the display configuration you are editing.",
|
||||
livePreviewSampleData: "The live preview is using sample data for now. It will switch to real SQL results after you save.",
|
||||
editorBasicSection: "Basic Info",
|
||||
editorBasicHint: "Start with the card key, title, and render type. These determine which configuration groups are available next.",
|
||||
editorDataSection: "Field Mapping",
|
||||
editorDataHint: "SQL fetches the data. This section tells the system which column is the value, category, color, series, and timeline frame.",
|
||||
dimensionSection: "Dimension Switch",
|
||||
dimensionHint: "Add a dimension dropdown for chart cards so each card can switch independently on the home page without refreshing the whole panel.",
|
||||
dimensionSwitchEnabled: "Enable Dimension Switch",
|
||||
dimensionOptions: "Dimension Options",
|
||||
dimensionOptionsHint: "Choose the filter items you want to expose. In most cases Monthly, Quarterly, Yearly, and All are enough.",
|
||||
dimensionOptionsPlaceholder: "Select dimension options",
|
||||
defaultDimension: "Default Dimension",
|
||||
defaultDimensionPlaceholder: "Select the default dimension",
|
||||
editorAppearanceSection: "Preset & Appearance",
|
||||
editorAppearanceHint: "This section follows the chart engine structure: choose a preset first, then fine-tune legend, labels, grid, and series styles.",
|
||||
editorMetricSection: "Metric Visual",
|
||||
editorMetricHint: "Metric cards use a unified chart-inspired visual language for theme, icon plate, and trend hint, and the preview mirrors the final result.",
|
||||
editorLayoutSection: "Layout & Interaction",
|
||||
editorLayoutHint: "Control how values are displayed, how cards are arranged, how charts are positioned on desktop and mobile, where they link to, and the order shown on the home page.",
|
||||
pcLayoutPreset: "Desktop Chart Position",
|
||||
mobileLayoutPreset: "Mobile Chart Position",
|
||||
pcChartWidthPercent: "Desktop Chart Width",
|
||||
mobileChartWidthPercent: "Mobile Chart Width",
|
||||
pcChartHeight: "Desktop Chart Height",
|
||||
mobileChartHeight: "Mobile Chart Height",
|
||||
sqlHelpTitle: "Supported SQL Rules",
|
||||
sqlHelp1: "Only SELECT / WITH queries are supported and multiple statements are blocked.",
|
||||
sqlHelp2: "Available params: :tenantId, :currentUserId, :today, :yesterday, :monthStart, :monthEnd, :nextMonthStart, :weekStart, :weekEnd, :now.",
|
||||
sqlHelp3: "To follow system role data scope, use the macro {{dataScopeMacro}} in SQL. ALL / SELF / DEPT / DEPT_AND_CHILD / CUSTOM are expanded on the backend.",
|
||||
sqlHelp4: "Metric cards should return one row with value. Chart cards should return multiple rows with label/value, and can optionally return color.",
|
||||
sqlHelp4: "Metric cards should return one row with value. Chart cards should return multiple rows with label/value, and can optionally return color. Multi-series charts can also return `series`, and timeline bar race can also return `frame`.",
|
||||
sortDirectionSql: "Use SQL Order",
|
||||
sortDirectionAsc: "Value Ascending",
|
||||
sortDirectionDesc: "Value Descending",
|
||||
|
|
@ -92,9 +177,12 @@ export default {
|
|||
previewDisabled: "The panel is currently disabled and will not be shown on the home page.",
|
||||
deleteConfirm: "Delete card \"{{name}}\"?",
|
||||
unsavedCardTitle: "This card has unsynced changes",
|
||||
unsavedCardContent: "Closing the drawer will discard the current card edits that have not been synced yet. Do you want to discard them?",
|
||||
unsavedCardContent: "Leaving this page will discard the current card edits that have not been saved yet. Do you want to discard them?",
|
||||
discardCardChanges: "Discard Changes",
|
||||
keepEditing: "Keep Editing",
|
||||
editorPageSubtitle: "Preview on the left, settings on the right.",
|
||||
backToCards: "Back to List",
|
||||
cardNotFound: "The card you tried to edit was not found. Returned to the card list.",
|
||||
syncCardBeforeSaveTitle: "Sync this card before saving",
|
||||
syncCardBeforeSaveContent: "This card still has unsynced changes. Confirm to sync the current card first, then save the whole page configuration.",
|
||||
syncAndSave: "Sync and Save",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export default {
|
||||
dashboardAnalytics: {
|
||||
title: "首页经营分析配置",
|
||||
subtitle: "配置 frontend 首页经营分析卡片的标题、SQL 取数逻辑与展示方式,并按权限控制首页是否可见。",
|
||||
subtitle: "配置首页经营分析卡片的标题、SQL 取数逻辑与展示方式,并按权限控制首页是否可见。",
|
||||
noViewPermission: "你没有查看首页经营分析配置的权限。",
|
||||
tenantTip: "当前正在配置租户 {{tenantName}}(编码:{{tenantCode}},ID:{{tenantId}})的首页经营分析,H5 与电脑端共用同一套卡片定义。",
|
||||
tenantTip: "当前正在配置租户 {{tenantName}}(编码:{{tenantCode}},编号:{{tenantId}})的首页经营分析,移动端与电脑端共用同一套卡片定义。",
|
||||
tenantRequired: "请先切换到具体租户后再配置首页经营分析。",
|
||||
panelTitle: "模块配置",
|
||||
cardsTitle: "卡片配置",
|
||||
|
|
@ -19,6 +19,11 @@ export default {
|
|||
previewError: "预览首页经营分析配置失败",
|
||||
saveError: "保存首页经营分析配置失败",
|
||||
cardKey: "卡片编码",
|
||||
groupName: "分组名称",
|
||||
groupNameHint: "相同分组名称的卡片会在首页展示时归为一个区块,适合按业务、数据域或角色分区。",
|
||||
groupNamePlaceholder: "例如:销售概览、客户分析、渠道经营",
|
||||
groupTabAll: "全部",
|
||||
ungrouped: "未分组",
|
||||
cardTitle: "卡片标题",
|
||||
cardSubtitle: "卡片副标题",
|
||||
renderType: "展示方式",
|
||||
|
|
@ -30,6 +35,56 @@ export default {
|
|||
displayTextConfig: "展示文案配置",
|
||||
displayTextSection: "展示文案",
|
||||
displayTextHint: "按当前图形类型配置展示文案,保存时会自动生成配置。",
|
||||
chartPreset: "官方图形预设",
|
||||
chartPresetHint: "按官方图形预设生成图表配置,并继续使用当前 SQL 的返回结果进行渲染。",
|
||||
seriesField: "系列字段",
|
||||
seriesFieldHint: "多系列图形请让 SQL 额外返回这个字段,例如“系列”。用于堆叠、多线、多柱、嵌套饼图、多漏斗等图形。",
|
||||
frameField: "时间帧字段",
|
||||
frameFieldHint: "动态排序柱状图(时间帧)需要 SQL 额外返回这个字段,例如“时间帧”。系统会按帧轮播同一批类目的排序变化。",
|
||||
optionSchemaSection: "图形样式分组配置",
|
||||
optionSchemaHint: "先用官方预设确定图形骨架,再按分组字段细调图例、坐标轴、网格和系列样式,最后可继续追加附加配置。",
|
||||
optionSchemaLegend: "图例配置",
|
||||
optionSchemaLabel: "标签配置",
|
||||
optionSchemaAxisGrid: "坐标轴与网格",
|
||||
optionSchemaSeries: "系列样式",
|
||||
optionSchemaAdvanced: "高级补充",
|
||||
optionSchemaDefault: "按预设默认",
|
||||
showLegend: "显示图例",
|
||||
legendSeriesOrder: "图例顺序",
|
||||
legendSeriesOrderHint: "按逗号或换行填写系列名称顺序。图例和堆叠顺序都会按这里重排,未填写的系列会自动补在后面。",
|
||||
legendSeriesOrderPlaceholder: "例如:A:100%-80%, B:80%-60%, C:60%-40%",
|
||||
showDataLabels: "显示数值标签",
|
||||
dataLabelMode: "标签显示模式",
|
||||
dataLabelModeHint: "仅堆叠折线图和堆叠柱状图支持“只显示总数”。切换到堆叠预设后即可配置。",
|
||||
legendPosition: "图例位置",
|
||||
legendOrient: "图例排布",
|
||||
labelPosition: "标签位置",
|
||||
categoryBoundaryGap: "类目轴留白",
|
||||
axisLabelRotate: "类目标签旋转",
|
||||
valueAxisMin: "数值轴最小值",
|
||||
valueAxisMax: "数值轴最大值",
|
||||
gridTop: "上边距",
|
||||
gridRight: "右边距",
|
||||
gridBottom: "下边距",
|
||||
gridLeft: "左边距",
|
||||
lineStyleType: "线条样式",
|
||||
stepPosition: "阶梯拐点位置",
|
||||
lineWidth: "线条宽度",
|
||||
symbolSize: "节点大小",
|
||||
barMaxWidth: "柱宽上限",
|
||||
barBorderRadius: "柱体圆角",
|
||||
pieRadiusOuter: "外半径",
|
||||
pieRadiusInner: "内半径",
|
||||
pieCenterX: "中心点横向位置",
|
||||
pieCenterY: "中心点纵向位置",
|
||||
pieLabelEdgeDistance: "标签边缘留白",
|
||||
pieLabelBleedMargin: "标签文本预留",
|
||||
pieLabelLineLength: "引导线第一段",
|
||||
pieLabelLineLength2: "引导线第二段",
|
||||
funnelSort: "漏斗排序",
|
||||
funnelGap: "漏斗间距",
|
||||
optionOverrides: "附加配置文本",
|
||||
optionOverridesHint: "可选。填写附加配置文本后会和系统生成的图表配置做合并,用于精确还原图形细节。",
|
||||
tableLabelHeader: "表格左列表头",
|
||||
tableValueHeader: "表格右列表头",
|
||||
peakPrefix: "峰值前缀文案",
|
||||
|
|
@ -55,6 +110,11 @@ export default {
|
|||
metricIconCategoryChannel: "渠道类",
|
||||
metricIconCategoryTask: "任务类",
|
||||
metricIconCategoryGrowth: "增长类",
|
||||
metricStylePreset: "指标卡风格",
|
||||
metricTheme: "主题色板",
|
||||
metricIconShape: "图标底板",
|
||||
metricTrendMode: "趋势提示",
|
||||
metricBadgeText: "强调标签",
|
||||
categoryOptions: "固定类目补零",
|
||||
categoryOptionsHint: "可按顺序配置固定类目;如果 SQL 少返回了某一项,系统会自动补 0,并沿用这里的颜色和名称。",
|
||||
categoryOptionLabel: "类目名称",
|
||||
|
|
@ -78,11 +138,42 @@ export default {
|
|||
enabledStatus: "启用",
|
||||
disabledStatus: "停用",
|
||||
preview: "刷新预览",
|
||||
previewMode: "预览视图",
|
||||
previewDesktop: "电脑端",
|
||||
previewMobile: "手机端",
|
||||
livePreviewTitle: "编辑中实时预览",
|
||||
livePreviewHint: "当前区域会即时反映你正在编辑的配置,SQL 结果优先使用已保存预览,没有时自动补示例数据。",
|
||||
livePreviewSavedData: "当前实时预览正在复用已保存的取数结果,只同步你正在编辑的展示配置。",
|
||||
livePreviewSampleData: "当前实时预览使用的是系统示例数据,保存 SQL 后会切换为真实结果。",
|
||||
editorBasicSection: "基础信息",
|
||||
editorBasicHint: "先确定卡片编码、标题和图形类型,这些信息会决定后续可配置的中文选项。",
|
||||
editorDataSection: "数据字段映射",
|
||||
editorDataHint: "SQL 负责取数;这里负责告诉系统哪一列是数值、分类、颜色、系列和时间帧。",
|
||||
dimensionSection: "数据维度切换",
|
||||
dimensionHint: "给图形卡增加维度下拉,首页可按当前卡片独立切换,不影响整页其他卡片。",
|
||||
dimensionSwitchEnabled: "启用维度切换",
|
||||
dimensionOptions: "维度选项",
|
||||
dimensionOptionsHint: "直接选择需要开放的筛选项,建议只保留月度、季度、年度、全量这几个常用项。",
|
||||
dimensionOptionsPlaceholder: "请选择需要开放的筛选项",
|
||||
defaultDimension: "默认维度",
|
||||
defaultDimensionPlaceholder: "请选择默认展示维度",
|
||||
editorAppearanceSection: "图形预设与样式",
|
||||
editorAppearanceHint: "这一组按图表官方配置思路组织,先选预设,再细调图例、标签、网格和系列样式。",
|
||||
editorMetricSection: "指标卡视觉",
|
||||
editorMetricHint: "指标卡会按统一的图表风格做主题、图标容器和趋势提示设计,预览会同步显示最终效果。",
|
||||
editorLayoutSection: "布局与交互",
|
||||
editorLayoutHint: "这里控制数值展示方式、卡片排列、PC/手机端图形位置,以及首页上的展示顺序。",
|
||||
pcLayoutPreset: "PC 端图形位置",
|
||||
mobileLayoutPreset: "手机端图形位置",
|
||||
pcChartWidthPercent: "PC 端图宽占比",
|
||||
mobileChartWidthPercent: "手机端图宽占比",
|
||||
pcChartHeight: "PC 端图高",
|
||||
mobileChartHeight: "手机端图高",
|
||||
sqlHelpTitle: "受支持的 SQL 规则",
|
||||
sqlHelp1: "仅支持 SELECT / WITH 查询,不支持多语句。",
|
||||
sqlHelp2: "可使用参数::tenantId、:currentUserId、:today、:yesterday、:monthStart、:monthEnd、:nextMonthStart、:weekStart、:weekEnd、:now。",
|
||||
sqlHelp3: "如需沿用系统角色数据权限,请在 SQL 中使用 {{dataScopeMacro}} 宏。ALL / SELF / DEPT / DEPT_AND_CHILD / CUSTOM 会在后端自动展开。",
|
||||
sqlHelp4: "指标卡建议 SQL 返回一行并包含 value 字段;图表卡建议返回多行,并包含 label/value 字段,可额外返回 color。",
|
||||
sqlHelp4: "指标卡建议 SQL 返回一行并包含 value 字段;图表卡建议返回多行,并包含 label/value 字段,可额外返回 color。多系列图形可额外返回 series,动态排序柱状图(时间帧)可额外返回 frame。",
|
||||
sortDirectionSql: "按 SQL 原顺序",
|
||||
sortDirectionAsc: "按数值升序",
|
||||
sortDirectionDesc: "按数值降序",
|
||||
|
|
@ -92,9 +183,12 @@ export default {
|
|||
previewDisabled: "当前模块未启用,首页不会展示该区域。",
|
||||
deleteConfirm: "确定删除卡片“{{name}}”吗?",
|
||||
unsavedCardTitle: "当前卡片还有未同步修改",
|
||||
unsavedCardContent: "关闭抽屉会丢失当前卡片的未同步内容,确认要放弃这些修改吗?",
|
||||
unsavedCardContent: "离开当前页面会丢失这张卡片尚未保存的内容,确认要放弃这些修改吗?",
|
||||
discardCardChanges: "放弃修改",
|
||||
keepEditing: "继续编辑",
|
||||
editorPageSubtitle: "左侧预览,右侧配置。",
|
||||
backToCards: "返回列表",
|
||||
cardNotFound: "未找到要编辑的卡片,已返回卡片配置列表。",
|
||||
syncCardBeforeSaveTitle: "先同步当前卡片再保存",
|
||||
syncCardBeforeSaveContent: "当前卡片还有未同步修改。确认后会先同步这张卡片,再保存整页配置。",
|
||||
syncAndSave: "同步并保存",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,171 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.78fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor__preview {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor-preview {
|
||||
border-radius: 20px;
|
||||
border: 1px solid #dbe7f3;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||
padding: 18px;
|
||||
box-shadow: 0 14px 30px rgba(31, 56, 88, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor-preview__toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor-preview__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor-preview__canvas {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor-preview__canvas--mobile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-surface--mobile {
|
||||
width: 100%;
|
||||
max-width: 390px;
|
||||
margin: 0 auto;
|
||||
padding: var(--dashboard-preview-surface-padding, 14px);
|
||||
border-radius: var(--dashboard-preview-surface-radius, 30px);
|
||||
border: 1px solid #dbe7f3;
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
|
||||
box-shadow: 0 18px 36px rgba(31, 56, 88, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
overflow-x: auto;
|
||||
padding: 4px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 84px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #64748b;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-tab:hover:not(:disabled) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-tab.is-active {
|
||||
background: #ffffff;
|
||||
color: #7c3aed;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--dashboard-preview-sections-gap, 16px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--dashboard-preview-section-gap, 12px);
|
||||
border-radius: var(--dashboard-preview-section-radius, 24px);
|
||||
border: 1px solid rgba(219, 231, 243, 0.92);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.92) 100%);
|
||||
padding: var(--dashboard-preview-section-padding, 14px);
|
||||
box-shadow: 0 14px 28px rgba(31, 56, 88, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--dashboard-preview-header-gap, 12px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-section__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-section__title {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dashboard-analytics-form-section {
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e5edf8;
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%);
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-analytics-form-section__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-form-section__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-item {
|
||||
grid-column: span 12;
|
||||
border-radius: 16px;
|
||||
|
|
@ -32,6 +197,29 @@
|
|||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-item--editor {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-item--editor .dashboard-analytics-preview-item__content {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-item--mobile {
|
||||
min-height: var(--dashboard-preview-item-min-height, 120px);
|
||||
padding: var(--dashboard-preview-item-padding, 12px);
|
||||
border-radius: var(--dashboard-preview-item-radius, 16px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-item--mobile-chart {
|
||||
grid-column: 1 / -1 !important;
|
||||
min-height: 132px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-item--cols-1,
|
||||
.dashboard-analytics-preview-item--cols-2,
|
||||
.dashboard-analytics-preview-item--cols-3,
|
||||
|
|
@ -94,6 +282,55 @@
|
|||
color: #98a2b3;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile {
|
||||
gap: var(--dashboard-preview-grid-gap, 10px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-1,
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-2 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-3,
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item__title {
|
||||
font-size: var(--dashboard-preview-item-title-size, 14px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item__subtitle {
|
||||
font-size: var(--dashboard-preview-item-subtitle-size, 11px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item__content {
|
||||
margin-top: calc(var(--dashboard-preview-item-padding, 12px) + 2px);
|
||||
padding: var(--dashboard-preview-item-padding, 12px);
|
||||
border-radius: var(--dashboard-preview-item-radius, 16px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item__value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-3 .dashboard-analytics-preview-item__title,
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-4 .dashboard-analytics-preview-item__title {
|
||||
font-size: var(--dashboard-preview-item-title-size, 14px);
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-3 .dashboard-analytics-preview-item__subtitle,
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-4 .dashboard-analytics-preview-item__subtitle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-3 .dashboard-analytics-preview-item__content,
|
||||
.dashboard-analytics-preview-grid--mobile .dashboard-analytics-preview-item--cols-4 .dashboard-analytics-preview-item__content {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-icon-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -291,6 +528,31 @@
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-option-schema {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
background: #f8fbff;
|
||||
padding: 8px 12px;
|
||||
|
||||
.ant-collapse-item {
|
||||
border-bottom: 1px solid rgba(219, 231, 243, 0.9);
|
||||
}
|
||||
|
||||
.ant-collapse-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding-inline: 0 !important;
|
||||
font-weight: 600;
|
||||
color: #334155 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 4px 0 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-analytics-sql-sample {
|
||||
border-radius: 14px;
|
||||
background: #0f172a;
|
||||
|
|
@ -399,8 +661,32 @@
|
|||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.dashboard-analytics-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor__preview {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.dashboard-analytics-editor-preview__toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-analytics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-analytics-preview-tabs {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-tab {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
|||
export type DashboardAnalyticsValueType = "number" | "amount" | "percent" | "text";
|
||||
export type DashboardAnalyticsRenderType = "metric" | "line" | "bar" | "pie" | "ring" | "funnel" | "ranking" | "table";
|
||||
export type DashboardAnalyticsRenderType = "metric" | "line" | "bar" | "pie" | "ring" | "funnel";
|
||||
export type DashboardAnalyticsSortDirection = "sql" | "asc" | "desc";
|
||||
export type DashboardAnalyticsLayoutType = "vertical" | "horizontal";
|
||||
|
||||
|
|
@ -7,6 +7,8 @@ export interface DashboardAnalyticsChartPoint {
|
|||
label?: string;
|
||||
value?: string;
|
||||
valueText?: string;
|
||||
seriesName?: string;
|
||||
frameName?: string;
|
||||
secondaryValue?: string;
|
||||
secondaryValueText?: string;
|
||||
description?: string;
|
||||
|
|
@ -16,6 +18,7 @@ export interface DashboardAnalyticsChartPoint {
|
|||
export interface DashboardAnalyticsCardConfig {
|
||||
id?: number;
|
||||
cardKey: string;
|
||||
groupName?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
renderType: DashboardAnalyticsRenderType;
|
||||
|
|
@ -48,6 +51,7 @@ export interface DashboardAnalyticsConfig {
|
|||
export interface DashboardAnalyticsPreviewCard {
|
||||
id?: number;
|
||||
cardKey?: string;
|
||||
groupName?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
renderType?: DashboardAnalyticsRenderType;
|
||||
|
|
@ -61,6 +65,7 @@ export interface DashboardAnalyticsPreviewCard {
|
|||
layoutType?: DashboardAnalyticsLayoutType;
|
||||
fullRow?: boolean;
|
||||
sortOrder?: number;
|
||||
displayLimit?: number;
|
||||
errorMessage?: string;
|
||||
totalCount?: number;
|
||||
hasMore?: boolean;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ function isPlatformOnlyMenu(item: SysPermission) {
|
|||
|| item.path === "/platform-settings";
|
||||
}
|
||||
|
||||
function resolveSelectedMenuPath(pathname: string): string {
|
||||
if (pathname.startsWith("/dashboard-analytics-settings/cards/")) {
|
||||
return "/dashboard-analytics-settings";
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
|
@ -91,6 +98,7 @@ export default function AppLayout() {
|
|||
const { logout } = useAuth();
|
||||
const { load: loadPermissions, can } = usePermission();
|
||||
const { layoutMode } = useThemeStore();
|
||||
const selectedMenuPath = useMemo(() => resolveSelectedMenuPath(location.pathname), [location.pathname]);
|
||||
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -270,11 +278,11 @@ export default function AppLayout() {
|
|||
return null;
|
||||
};
|
||||
|
||||
const keys = findParentKeys(menuItems || [], location.pathname);
|
||||
const keys = findParentKeys(menuItems || [], selectedMenuPath);
|
||||
if (keys?.length) {
|
||||
setOpenKeys((prev) => Array.from(new Set([...prev, ...keys])));
|
||||
}
|
||||
}, [location.pathname, menuItems, menus.length]);
|
||||
}, [menuItems, menus.length, selectedMenuPath]);
|
||||
|
||||
const userMenuItems: MenuProps["items"] = useMemo(() => {
|
||||
const items: NonNullable<MenuProps["items"]> = [
|
||||
|
|
@ -386,7 +394,7 @@ export default function AppLayout() {
|
|||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
selectedKeys={[selectedMenuPath]}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={setOpenKeys}
|
||||
items={menuItems}
|
||||
|
|
@ -426,7 +434,7 @@ export default function AppLayout() {
|
|||
<div style={{ flex: 1, minWidth: 0, padding: "0 24px" }}>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
selectedKeys={[location.pathname]}
|
||||
selectedKeys={[selectedMenuPath]}
|
||||
items={menuItems}
|
||||
style={{
|
||||
borderBottom: 0,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Suspense, lazy } from "react";
|
|||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import AppLayout from "@/layouts/AppLayout";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { menuRoutes } from "./routes";
|
||||
import { hiddenRoutes, menuRoutes } from "./routes";
|
||||
|
||||
const Login = lazy(() => import("@/pages/auth/login"));
|
||||
const ResetPassword = lazy(() => import("@/pages/auth/reset-password"));
|
||||
|
|
@ -39,7 +39,7 @@ export default function AppRoutes() {
|
|||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
{menuRoutes.map((route) => (
|
||||
{[...menuRoutes, ...hiddenRoutes].map((route) => (
|
||||
<Route key={route.path} index={route.path === "/"} path={route.path === "/" ? undefined : route.path.slice(1)} element={route.element} />
|
||||
))}
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ const SysParams = lazy(() => import("@/pages/system/sys-params"));
|
|||
const PlatformSettings = lazy(() => import("@/pages/system/platform-settings"));
|
||||
const Dictionaries = lazy(() => import("@/pages/system/dictionaries"));
|
||||
const Logs = lazy(() => import("@/pages/system/logs"));
|
||||
const ReportReminderSettings = lazy(() => import("@/features/report-reminder"));
|
||||
const DashboardAnalyticsSettings = lazy(() => import("@/features/dashboard-analytics"));
|
||||
const OwnerTransfer = lazy(() => import("@/features/owner-transfer"));
|
||||
const ReportReminderSettings = lazy(() => import("@/features/report-reminder/pages/report-reminder-settings"));
|
||||
const DashboardAnalyticsSettings = lazy(() => import("@/features/dashboard-analytics/pages/dashboard-analytics-settings"));
|
||||
const OwnerTransfer = lazy(() => import("@/features/owner-transfer/pages/owner-transfer"));
|
||||
const Devices = lazy(() => import("@/pages/devices"));
|
||||
const UserRoleBinding = lazy(() => import("@/pages/bindings/user-role"));
|
||||
const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permission"));
|
||||
|
|
@ -51,3 +51,18 @@ export const menuRoutes: MenuRoute[] = [
|
|||
{ path: "/user-roles", label: "用户角色绑定", element: <LazyPage><UserRoleBinding /></LazyPage>, perm: "menu:user-roles" },
|
||||
{ path: "/role-permissions", label: "角色权限绑定", element: <LazyPage><RolePermissionBinding /></LazyPage>, perm: "menu:role-permissions" }
|
||||
];
|
||||
|
||||
export const hiddenRoutes: MenuRoute[] = [
|
||||
{
|
||||
path: "/dashboard-analytics-settings/cards/new",
|
||||
label: "新增首页经营分析卡片",
|
||||
element: <LazyPage><DashboardAnalyticsSettings /></LazyPage>,
|
||||
perm: "menu:dashboard-analytics-settings",
|
||||
},
|
||||
{
|
||||
path: "/dashboard-analytics-settings/cards/:cardRef/edit",
|
||||
label: "编辑首页经营分析卡片",
|
||||
element: <LazyPage><DashboardAnalyticsSettings /></LazyPage>,
|
||||
perm: "menu:dashboard-analytics-settings",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
当前项目的数据库脚本按“一个主初始化脚本 + 一组正式增量脚本 + 一组历史归档脚本”的方式整理:
|
||||
|
||||
- 主初始化脚本:`sql/init_full_pg17.sql`
|
||||
- 当前版本合并升级脚本:`sql/upgrade_dashboard_analytics_and_opportunity_schema_pg17.sql`
|
||||
- 首页经营分析生产升级脚本:`sql/upgrade_dashboard_analytics_prod_pg17.sql`
|
||||
- 经营分析卡片历史展示配置修正脚本:`sql/upgrade_dashboard_analytics_card_display_config_pg17.sql`
|
||||
- 商机实际签约金额字段升级脚本:`sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
|
||||
- 日报提醒功能初始化脚本:`sql/init_report_reminder_pg17.sql`
|
||||
- 归属人转移权限初始化脚本:`sql/init_owner_transfer_permissions_pg17.sql`
|
||||
- 一次性修复/导入工具脚本:
|
||||
|
|
@ -58,6 +61,24 @@ psql -d your_database -f sql/init_report_reminder_pg17.sql
|
|||
psql -d your_database -f sql/init_owner_transfer_permissions_pg17.sql
|
||||
```
|
||||
|
||||
如果老环境需要补“商机实际签约金额”字段,请执行:
|
||||
|
||||
```bash
|
||||
psql -d your_database -f sql/upgrade_opportunity_actual_signed_amount_pg17.sql
|
||||
```
|
||||
|
||||
如果希望直接按当前版本代码一次补齐“经营分析最新结构 + 商机快照字段 + 实际签约金额字段”,可直接执行合并脚本:
|
||||
|
||||
```bash
|
||||
psql -d your_database -f sql/upgrade_dashboard_analytics_and_opportunity_schema_pg17.sql
|
||||
```
|
||||
|
||||
如果需要一次性修正历史经营分析卡片配置,避免旧饼图/环形图/漏斗图升级后标签缺失、旧中文图表预设值无法识别,请执行:
|
||||
|
||||
```bash
|
||||
psql -d your_database -f sql/upgrade_dashboard_analytics_card_display_config_pg17.sql
|
||||
```
|
||||
|
||||
### 3. 一次性工具脚本
|
||||
|
||||
- `sql/import_oms_existing_opportunities_pg17.sql`
|
||||
|
|
@ -85,6 +106,15 @@ psql -d your_database -f sql/init_owner_transfer_permissions_pg17.sql
|
|||
- `sql/upgrade_dashboard_analytics_prod_pg17.sql`
|
||||
老环境补齐本次首页相关能力时使用的正式增量脚本,已合并首页权限、商机快照字段升级和平台菜单权限纠偏。
|
||||
|
||||
- `sql/upgrade_dashboard_analytics_and_opportunity_schema_pg17.sql`
|
||||
当前版本推荐的结构合并升级脚本,适用于老环境一次补齐“经营分析配置最新表结构”和“商机快照/实际签约金额字段”。
|
||||
|
||||
- `sql/upgrade_dashboard_analytics_card_display_config_pg17.sql`
|
||||
经营分析卡片历史展示配置修正脚本,用于清洗旧 `display_text_config` 中的中文图表预设值,并补齐老饼图/环形图/漏斗图的标签显示配置。
|
||||
|
||||
- `sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
|
||||
老环境补齐“商机实际签约金额”字段时使用的正式增量脚本。
|
||||
|
||||
- `sql/init_report_reminder_pg17.sql`
|
||||
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ create table if not exists crm_opportunity (
|
|||
project_location varchar(100),
|
||||
operator_name varchar(100),
|
||||
amount numeric(18, 2) not null default 0,
|
||||
actual_signed_amount numeric(18, 2),
|
||||
expected_close_date date,
|
||||
confidence_pct varchar(1) not null default 'C' check (confidence_pct in ('A', 'B', 'C')),
|
||||
stage varchar(50) not null default 'initial_contact',
|
||||
|
|
@ -786,6 +787,7 @@ WITH column_comments(table_name, column_name, comment_text) AS (
|
|||
('crm_opportunity', 'project_location', '项目所在地'),
|
||||
('crm_opportunity', 'operator_name', '运作方'),
|
||||
('crm_opportunity', 'amount', '商机金额'),
|
||||
('crm_opportunity', 'actual_signed_amount', '实际签约金额'),
|
||||
('crm_opportunity', 'expected_close_date', '预计结单日期'),
|
||||
('crm_opportunity', 'confidence_pct', '把握度等级(A/B/C)'),
|
||||
('crm_opportunity', 'stage', '商机阶段'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
-- 当前版本生产合并升级脚本
|
||||
-- 说明:
|
||||
-- 1. 补齐首页经营分析配置表的最新结构字段。
|
||||
-- 2. 补齐 crm_opportunity 的 latest_progress / next_plan / actual_signed_amount 字段。
|
||||
-- 3. 可重复执行。
|
||||
-- 4. 本脚本只处理本次代码所需的结构升级,不写默认业务卡片,不补菜单/权限初始化数据。
|
||||
|
||||
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 default '未命名卡片',
|
||||
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 if exists dashboard_analytics_panel_config
|
||||
add column if not exists enabled boolean not null default false,
|
||||
add column if not exists title varchar(100) not null default '经营分析',
|
||||
add column if not exists subtitle varchar(255),
|
||||
add column if not exists empty_state_text varchar(255);
|
||||
|
||||
alter table if exists dashboard_analytics_card_config
|
||||
add column if not exists group_name varchar(100),
|
||||
add column if not exists title varchar(100) not null default '未命名卡片',
|
||||
add column if not exists subtitle varchar(255),
|
||||
add column if not exists render_type varchar(20) not null default 'metric',
|
||||
add column if not exists description_field varchar(100),
|
||||
add column if not exists category_field varchar(100),
|
||||
add column if not exists color_field varchar(100),
|
||||
add column if not exists display_text_config text,
|
||||
add column if not exists unit varchar(20),
|
||||
add column if not exists value_type varchar(20) not null default 'number',
|
||||
add column if not exists sort_direction varchar(20) not null default 'sql',
|
||||
add column if not exists display_limit integer,
|
||||
add column if not exists layout_type varchar(20) not null default 'vertical',
|
||||
add column if not exists full_row boolean not null default false,
|
||||
add column if not exists link_path varchar(255);
|
||||
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.tables
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'dashboard_analytics_panel_config'
|
||||
) and 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 exists (
|
||||
select 1
|
||||
from information_schema.tables
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'dashboard_analytics_card_config'
|
||||
) and 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);
|
||||
|
||||
alter table if exists crm_opportunity
|
||||
add column if not exists latest_progress text,
|
||||
add column if not exists next_plan text,
|
||||
add column if not exists actual_signed_amount numeric(18, 2);
|
||||
|
||||
comment on column crm_opportunity.latest_progress is '项目最新进展';
|
||||
comment on column crm_opportunity.next_plan is '下一步销售计划';
|
||||
comment on column crm_opportunity.actual_signed_amount is '实际签约金额';
|
||||
|
||||
commit;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
-- 经营分析卡片历史展示配置修正脚本
|
||||
-- 说明:
|
||||
-- 1. 归一化旧版 chartPreset 中文值,统一转成当前内部预设值。
|
||||
-- 2. 为老的 pie/ring/funnel 卡片补齐 showDataLabels=true,避免升级后标签丢失。
|
||||
-- 3. 对非堆叠 line/bar 卡片,如果误存 dataLabelMode=total,则回退为 value。
|
||||
-- 4. 对 display_text_config 为空、非法 JSON、或非对象结构的卡片,按安全对象配置修正。
|
||||
-- 5. 可重复执行。
|
||||
|
||||
do $$
|
||||
declare
|
||||
rec record;
|
||||
parsed_config jsonb;
|
||||
next_config jsonb;
|
||||
preset_text text;
|
||||
normalized_preset text;
|
||||
parse_failed boolean;
|
||||
begin
|
||||
if to_regclass('public.dashboard_analytics_card_config') is null then
|
||||
raise notice 'skip upgrade_dashboard_analytics_card_display_config_pg17: table dashboard_analytics_card_config does not exist';
|
||||
return;
|
||||
end if;
|
||||
|
||||
for rec in
|
||||
select
|
||||
id,
|
||||
lower(coalesce(render_type, '')) as render_type,
|
||||
display_text_config
|
||||
from dashboard_analytics_card_config
|
||||
loop
|
||||
parse_failed := false;
|
||||
parsed_config := '{}'::jsonb;
|
||||
|
||||
begin
|
||||
if rec.display_text_config is null or btrim(rec.display_text_config) = '' then
|
||||
parsed_config := '{}'::jsonb;
|
||||
else
|
||||
parsed_config := rec.display_text_config::jsonb;
|
||||
if jsonb_typeof(parsed_config) <> 'object' then
|
||||
parsed_config := '{}'::jsonb;
|
||||
parse_failed := true;
|
||||
end if;
|
||||
end if;
|
||||
exception
|
||||
when others then
|
||||
parsed_config := '{}'::jsonb;
|
||||
parse_failed := true;
|
||||
end;
|
||||
|
||||
next_config := parsed_config;
|
||||
preset_text := nullif(btrim(coalesce(parsed_config ->> 'chartPreset', '')), '');
|
||||
|
||||
normalized_preset := case preset_text
|
||||
when '基础折线图' then 'line-basic'
|
||||
when '平滑曲线图' then 'line-smooth'
|
||||
when '面积折线图' then 'line-area'
|
||||
when '阶梯折线图(起点)' then 'line-step-start'
|
||||
when '阶梯折线图(中点)' then 'line-step-middle'
|
||||
when '阶梯折线图(终点)' then 'line-step-end'
|
||||
when '堆叠折线图' then 'line-stacked'
|
||||
when '堆积折线图' then 'line-stacked'
|
||||
when '折线图' then 'line-basic'
|
||||
when '平滑折线图' then 'line-smooth'
|
||||
when '面积图' then 'line-area'
|
||||
when '基础柱状图' then 'bar-basic'
|
||||
when '柱状图' then 'bar-basic'
|
||||
when '横向柱状图' then 'bar-horizontal'
|
||||
when '条形图' then 'bar-horizontal'
|
||||
when '堆叠柱状图' then 'bar-stacked'
|
||||
when '堆积柱状图' then 'bar-stacked'
|
||||
when '横向堆叠柱状图' then 'bar-horizontal-stacked'
|
||||
when '横向堆积柱状图' then 'bar-horizontal-stacked'
|
||||
when '堆积条形图' then 'bar-horizontal-stacked'
|
||||
when '瀑布图' then 'bar-waterfall'
|
||||
when '动态排序柱状图(单帧)' then 'bar-race'
|
||||
when '动态排序柱状图(时间帧)' then 'bar-race-timeline'
|
||||
when '基础饼图' then 'pie-basic'
|
||||
when '饼图' then 'pie-basic'
|
||||
when '圆环图' then 'pie-doughnut'
|
||||
when '环形图' then case rec.render_type
|
||||
when 'ring' then 'ring-basic'
|
||||
else 'pie-doughnut'
|
||||
end
|
||||
when '南丁格尔玫瑰图' then 'pie-rose'
|
||||
when '玫瑰图' then case rec.render_type
|
||||
when 'ring' then 'ring-rose'
|
||||
else 'pie-rose'
|
||||
end
|
||||
when '嵌套饼图' then 'pie-nested'
|
||||
when '基础圆环图' then 'ring-basic'
|
||||
when '玫瑰圆环图' then 'ring-rose'
|
||||
when '嵌套圆环图' then 'ring-nested'
|
||||
when '基础漏斗图' then 'funnel-basic'
|
||||
when '漏斗图' then 'funnel-basic'
|
||||
when '对比漏斗图' then 'funnel-contrast'
|
||||
when '多系列漏斗图' then 'funnel-multiple'
|
||||
else preset_text
|
||||
end;
|
||||
|
||||
if normalized_preset is distinct from preset_text and normalized_preset is not null then
|
||||
next_config := jsonb_set(next_config, '{chartPreset}', to_jsonb(normalized_preset), true);
|
||||
end if;
|
||||
|
||||
if jsonb_typeof(next_config -> 'showDataLabels') = 'string' then
|
||||
if lower(coalesce(next_config ->> 'showDataLabels', '')) in ('true', '1', 'yes', 'y', 'on', '是') then
|
||||
next_config := jsonb_set(next_config, '{showDataLabels}', 'true'::jsonb, true);
|
||||
elsif lower(coalesce(next_config ->> 'showDataLabels', '')) in ('false', '0', 'no', 'n', 'off', '否') then
|
||||
next_config := jsonb_set(next_config, '{showDataLabels}', 'false'::jsonb, true);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
if jsonb_typeof(next_config -> 'showLegend') = 'string' then
|
||||
if lower(coalesce(next_config ->> 'showLegend', '')) in ('true', '1', 'yes', 'y', 'on', '是') then
|
||||
next_config := jsonb_set(next_config, '{showLegend}', 'true'::jsonb, true);
|
||||
elsif lower(coalesce(next_config ->> 'showLegend', '')) in ('false', '0', 'no', 'n', 'off', '否') then
|
||||
next_config := jsonb_set(next_config, '{showLegend}', 'false'::jsonb, true);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
if rec.render_type in ('pie', 'ring', 'funnel') and not (next_config ? 'showDataLabels') then
|
||||
next_config := jsonb_set(next_config, '{showDataLabels}', 'true'::jsonb, true);
|
||||
end if;
|
||||
|
||||
if coalesce(next_config ->> 'dataLabelMode', '') = '只显示总数' then
|
||||
next_config := jsonb_set(next_config, '{dataLabelMode}', to_jsonb('total'::text), true);
|
||||
elsif coalesce(next_config ->> 'dataLabelMode', '') = '显示每段数值' then
|
||||
next_config := jsonb_set(next_config, '{dataLabelMode}', to_jsonb('value'::text), true);
|
||||
end if;
|
||||
|
||||
if rec.render_type in ('line', 'bar')
|
||||
and coalesce(next_config ->> 'dataLabelMode', '') = 'total'
|
||||
and position('stacked' in coalesce(next_config ->> 'chartPreset', '')) = 0 then
|
||||
next_config := jsonb_set(next_config, '{dataLabelMode}', to_jsonb('value'::text), true);
|
||||
end if;
|
||||
|
||||
if parse_failed or next_config is distinct from parsed_config then
|
||||
update dashboard_analytics_card_config
|
||||
set display_text_config = next_config::text,
|
||||
updated_at = now()
|
||||
where id = rec.id;
|
||||
end if;
|
||||
end loop;
|
||||
end
|
||||
$$;
|
||||
|
||||
commit;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
-- 商机表补充实际签约金额字段
|
||||
-- 说明:
|
||||
-- 1. 为 crm_opportunity 增加 actual_signed_amount 字段。
|
||||
-- 2. 字段类型为 numeric(18, 2),允许为空,便于区分“未签约”和“已签约但金额为 0”。
|
||||
-- 3. 可重复执行。
|
||||
|
||||
alter table if exists crm_opportunity
|
||||
add column if not exists actual_signed_amount numeric(18, 2);
|
||||
|
||||
comment on column crm_opportunity.actual_signed_amount is '实际签约金额';
|
||||
|
||||
commit;
|
||||
Loading…
Reference in New Issue