经营分析模块

main
kangwenjing 2026-05-20 10:01:28 +08:00
parent 4b7177e652
commit aa5ff4f073
59 changed files with 8886 additions and 1940 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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);

View File

@ -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);
}

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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,

View File

@ -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);
}

View File

@ -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

View File

@ -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() {

View File

@ -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);

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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);

View File

@ -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"
}
}
}

View File

@ -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"
},

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -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

View File

@ -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) {

View File

@ -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>
) : (

View File

@ -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);
}

View File

@ -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}

View File

@ -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");
}

View File

@ -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',

View File

@ -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>

View File

@ -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",

View File

@ -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";

View File

@ -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"
},

View File

@ -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";

View File

@ -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";

View File

@ -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",

View File

@ -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",

View File

@ -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;
}

View File

@ -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",

View File

@ -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: "同步并保存",

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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>

View File

@ -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",
},
];

View File

@ -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`
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。

View File

@ -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', '商机阶段'),

View File

@ -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;

View File

@ -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;

View File

@ -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;