From 31b0e21d3df4013049879c7cdb21eee56ae0cb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 6 May 2026 19:22:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E8=B6=8B=E5=8A=BF=E6=A6=82=E8=A7=88=E4=B8=8E=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=8E=92=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持用户活跃与智能体活跃趋势统计及自定义时间范围 - 增加用户活跃榜与智能体趋势数据结构及查询实现 - 同步补齐工作台页面展示与定向测试 --- .../DashboardAssistantTrendPointVo.java | 37 + .../DashboardAssistantTrendSeriesVo.java | 50 ++ .../DashboardDistributionItemVo.java | 10 + .../dashboard/DashboardOverviewQuery.java | 18 + .../model/dashboard/DashboardOverviewVo.java | 20 + .../model/dashboard/DashboardSummaryVo.java | 10 + .../dashboard/DashboardUserRankItemVo.java | 59 ++ .../dashboard/impl/DashboardServiceImpl.java | 284 ++++++- .../impl/DashboardServiceImplTest.java | 291 ++++++- .../domain/dto/ChatActiveUserRank.java | 19 + .../domain/dto/ChatAssistantSessionTrend.java | 17 + .../domain/dto/ChatAssistantUsageRank.java | 2 + .../domain/dto/ChatDashboardSummary.java | 8 +- .../domain/dto/ChatDashboardTrend.java | 6 +- .../ChatAnalyticalDBRepository.java | 277 +++++- .../service/ChatDashboardQueryService.java | 44 + .../impl/ChatDashboardQueryServiceImpl.java | 62 ++ .../ChatAnalyticalDBRepositoryTest.java | 188 ++++ easyflow-ui-admin/app/src/api/dashboard.ts | 29 +- .../src/views/dashboard/workspace/index.vue | 802 ++++++++++++++++-- 20 files changed, 2087 insertions(+), 146 deletions(-) create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendPointVo.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendSeriesVo.java create mode 100644 easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatActiveUserRank.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantSessionTrend.java create mode 100644 easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendPointVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendPointVo.java new file mode 100644 index 0000000..e298e2a --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendPointVo.java @@ -0,0 +1,37 @@ +package tech.easyflow.admin.model.dashboard; + +/** + * 工作台智能体趋势点位。 + */ +public class DashboardAssistantTrendPointVo { + + private String key; + + private String label; + + private Long sessionTotal; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public Long getSessionTotal() { + return sessionTotal; + } + + public void setSessionTotal(Long sessionTotal) { + this.sessionTotal = sessionTotal; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendSeriesVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendSeriesVo.java new file mode 100644 index 0000000..9497f96 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardAssistantTrendSeriesVo.java @@ -0,0 +1,50 @@ +package tech.easyflow.admin.model.dashboard; + +import java.math.BigInteger; +import java.util.List; + +/** + * 工作台智能体趋势序列。 + */ +public class DashboardAssistantTrendSeriesVo { + + private BigInteger assistantId; + + private String label; + + private Long totalSessionCount; + + private List points; + + public BigInteger getAssistantId() { + return assistantId; + } + + public void setAssistantId(BigInteger assistantId) { + this.assistantId = assistantId; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public Long getTotalSessionCount() { + return totalSessionCount; + } + + public void setTotalSessionCount(Long totalSessionCount) { + this.totalSessionCount = totalSessionCount; + } + + public List getPoints() { + return points; + } + + public void setPoints(List points) { + this.points = points; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java index 305a5a3..bb7a61d 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardDistributionItemVo.java @@ -29,6 +29,8 @@ public class DashboardDistributionItemVo { private Long sessionTotal; + private Double avgSessionPerUser; + private Double avgMessagePerSession; public String getKey() { @@ -119,6 +121,14 @@ public class DashboardDistributionItemVo { this.sessionTotal = sessionTotal; } + public Double getAvgSessionPerUser() { + return avgSessionPerUser; + } + + public void setAvgSessionPerUser(Double avgSessionPerUser) { + this.avgSessionPerUser = avgSessionPerUser; + } + public Double getAvgMessagePerSession() { return avgMessagePerSession; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewQuery.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewQuery.java index 6dd2ad8..e311b3a 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewQuery.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewQuery.java @@ -6,6 +6,8 @@ package tech.easyflow.admin.model.dashboard; public class DashboardOverviewQuery { private String range; + private String startDate; + private String endDate; public String getRange() { return range; @@ -14,4 +16,20 @@ public class DashboardOverviewQuery { public void setRange(String range) { this.range = range; } + + public String getStartDate() { + return startDate; + } + + public void setStartDate(String startDate) { + this.startDate = startDate; + } + + public String getEndDate() { + return endDate; + } + + public void setEndDate(String endDate) { + this.endDate = endDate; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java index 0b27a65..918411d 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardOverviewVo.java @@ -14,8 +14,12 @@ public class DashboardOverviewVo { private List trends; + private List assistantTrends; + private List distribution; + private List userRanks; + private DashboardOverviewQuery query; private Date updatedAt; @@ -52,6 +56,22 @@ public class DashboardOverviewVo { this.distribution = distribution; } + public List getAssistantTrends() { + return assistantTrends; + } + + public void setAssistantTrends(List assistantTrends) { + this.assistantTrends = assistantTrends; + } + + public List getUserRanks() { + return userRanks; + } + + public void setUserRanks(List userRanks) { + this.userRanks = userRanks; + } + public DashboardOverviewQuery getQuery() { return query; } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java index ef4b201..2395f34 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardSummaryVo.java @@ -21,6 +21,8 @@ public class DashboardSummaryVo { private Long activeAssistantTotal; + private Long chatActiveUserTotal; + public Long getUserTotal() { return userTotal; } @@ -84,4 +86,12 @@ public class DashboardSummaryVo { public void setActiveAssistantTotal(Long activeAssistantTotal) { this.activeAssistantTotal = activeAssistantTotal; } + + public Long getChatActiveUserTotal() { + return chatActiveUserTotal; + } + + public void setChatActiveUserTotal(Long chatActiveUserTotal) { + this.chatActiveUserTotal = chatActiveUserTotal; + } } diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java new file mode 100644 index 0000000..601b743 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/model/dashboard/DashboardUserRankItemVo.java @@ -0,0 +1,59 @@ +package tech.easyflow.admin.model.dashboard; + +import java.math.BigInteger; + +/** + * 工作台用户活跃排行项。 + */ +public class DashboardUserRankItemVo { + + private BigInteger userId; + + private String label; + + private Long sessionTotal; + + private Long messageTotal; + + private Long assistantTotal; + + public BigInteger getUserId() { + return userId; + } + + public void setUserId(BigInteger userId) { + this.userId = userId; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public Long getSessionTotal() { + return sessionTotal; + } + + public void setSessionTotal(Long sessionTotal) { + this.sessionTotal = sessionTotal; + } + + public Long getMessageTotal() { + return messageTotal; + } + + public void setMessageTotal(Long messageTotal) { + this.messageTotal = messageTotal; + } + + public Long getAssistantTotal() { + return assistantTotal; + } + + public void setAssistantTotal(Long assistantTotal) { + this.assistantTotal = assistantTotal; + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java index 276029b..cefba09 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImpl.java @@ -7,12 +7,17 @@ import org.springframework.util.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo; +import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo; +import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardOverviewVo; import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; import tech.easyflow.admin.service.dashboard.DashboardService; +import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; +import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; @@ -22,6 +27,7 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.entity.SysAccountRole; import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysAccountRoleService; import tech.easyflow.system.service.SysRoleService; @@ -35,6 +41,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -49,6 +56,8 @@ public class DashboardServiceImpl implements DashboardService { private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault(); private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用"; private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5; + private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8; + private static final int DEFAULT_USER_RANK_LIMIT = 5; @Resource private SysAccountRoleService sysAccountRoleService; @@ -56,6 +65,9 @@ public class DashboardServiceImpl implements DashboardService { @Resource private SysRoleService sysRoleService; + @Resource + private SysAccountService sysAccountService; + @Resource private ChatDashboardQueryService chatDashboardQueryService; @@ -76,10 +88,14 @@ public class DashboardServiceImpl implements DashboardService { result.setSummary(summary); result.setChatStatus(chatPayload.chatStatus); result.setTrends(chatPayload.trends); + result.setAssistantTrends(chatPayload.assistantTrends); result.setDistribution(chatPayload.distribution); + result.setUserRanks(chatPayload.userRanks); DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery(); normalizedQuery.setRange(context.range); + normalizedQuery.setStartDate(context.queryStartDate); + normalizedQuery.setEndDate(context.queryEndDate); result.setQuery(normalizedQuery); result.setUpdatedAt(new Date()); return result; @@ -101,6 +117,7 @@ public class DashboardServiceImpl implements DashboardService { summary.setChatMessageTotal(0L); summary.setChatSessionTotal(0L); summary.setActiveAssistantTotal(0L); + summary.setChatActiveUserTotal(0L); return summary; } @@ -122,7 +139,14 @@ public class DashboardServiceImpl implements DashboardService { summary.setChatMessageTotal(0L); summary.setChatSessionTotal(0L); summary.setActiveAssistantTotal(0L); - return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>()); + summary.setChatActiveUserTotal(0L); + return new ChatDashboardPayload( + chatStatus, + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>() + ); } LocalDate startDate = context.startTime.toLocalDate(); @@ -132,20 +156,34 @@ public class DashboardServiceImpl implements DashboardService { summary.setChatMessageTotal(chatSummary.messageTotal()); summary.setChatSessionTotal(chatSummary.sessionTotal()); summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal()); + summary.setChatActiveUserTotal(chatSummary.chatActiveUserTotal()); - List rawTrends = "today".equals(context.range) + List rawTrends = useHourlyBuckets(context) ? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId) : chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId); - List trends = buildTrendItems(context.range, rawTrends); + List trends = buildTrendItems(context, rawTrends); List rawRanks = chatDashboardQueryService.queryAssistantUsageRanks( startDate, endDate, context.tenantFilterId, - DEFAULT_ASSISTANT_RANK_LIMIT + DEFAULT_ASSISTANT_TREND_LIMIT ); - List distribution = buildAssistantDistribution(rawRanks); - return new ChatDashboardPayload(chatStatus, trends, distribution); + List assistantTrends = buildAssistantTrendSeries( + context, + rawRanks + ); + List distribution = buildAssistantDistribution( + rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size())) + ); + List rawUserRanks = chatDashboardQueryService.queryActiveUserRanks( + startDate, + endDate, + context.tenantFilterId, + DEFAULT_USER_RANK_LIMIT + ); + List userRanks = buildUserRanks(rawUserRanks); + return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution, userRanks); } catch (Exception ex) { log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}", context.range, @@ -156,7 +194,14 @@ public class DashboardServiceImpl implements DashboardService { summary.setChatMessageTotal(0L); summary.setChatSessionTotal(0L); summary.setActiveAssistantTotal(0L); - return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>()); + summary.setChatActiveUserTotal(0L); + return new ChatDashboardPayload( + chatStatus, + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>() + ); } } @@ -167,8 +212,12 @@ public class DashboardServiceImpl implements DashboardService { * @param rawTrends 原始趋势 * @return 趋势项 */ - private List buildTrendItems(String range, List rawTrends) { - List buckets = buildBuckets(range); + private List buildTrendItems(DashboardQueryContext context, List rawTrends) { + List buckets = buildBuckets( + context.range, + context.startTime.toLocalDate(), + context.endTime.toLocalDate().minusDays(1) + ); Map trendMap = new HashMap<>(); for (ChatDashboardTrend rawTrend : rawTrends) { trendMap.put(rawTrend.bucketKey(), rawTrend); @@ -180,7 +229,7 @@ public class DashboardServiceImpl implements DashboardService { DashboardTrendItemVo item = new DashboardTrendItemVo(); item.setKey(bucket.key); item.setLabel(bucket.label); - item.setActiveUserTotal(0L); + item.setActiveUserTotal(trend == null ? 0L : trend.activeUserTotal()); item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal()); item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal()); items.add(item); @@ -201,10 +250,100 @@ public class DashboardServiceImpl implements DashboardService { item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString()); item.setAssistantId(rank.assistantId()); item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName())); + item.setUserTotal(rank.userTotal()); item.setMessageTotal(rank.messageTotal()); item.setSessionTotal(rank.sessionTotal()); item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal())); - item.setValue(rank.messageTotal()); + item.setAvgSessionPerUser(calculateAvg(rank.sessionTotal(), rank.userTotal())); + item.setValue(rank.sessionTotal()); + items.add(item); + } + return items; + } + + /** + * 构建智能体活跃趋势序列。 + * + * @param context 查询上下文 + * @param ranks 智能体排行 + * @return 趋势序列 + */ + private List buildAssistantTrendSeries(DashboardQueryContext context, + List ranks) { + if (ranks == null || ranks.isEmpty()) { + return new ArrayList<>(); + } + List buckets = buildBuckets( + context.range, + context.startTime.toLocalDate(), + context.endTime.toLocalDate().minusDays(1) + ); + Map rankMap = new LinkedHashMap<>(); + for (ChatAssistantUsageRank rank : ranks) { + rankMap.putIfAbsent(rank.assistantId(), rank); + } + List assistantIds = new ArrayList<>(rankMap.keySet()); + List rawAssistantTrends = useHourlyBuckets(context) + ? chatDashboardQueryService.queryAssistantHourlyTrends( + context.startTime, + context.endTime, + context.tenantFilterId, + assistantIds + ) + : chatDashboardQueryService.queryAssistantTrends( + context.startTime.toLocalDate(), + context.endTime.toLocalDate(), + context.tenantFilterId, + assistantIds + ); + + Map> trendMap = new HashMap<>(); + for (ChatAssistantSessionTrend rawTrend : rawAssistantTrends) { + trendMap.computeIfAbsent(rawTrend.assistantId(), key -> new HashMap<>()) + .put(rawTrend.bucketKey(), rawTrend); + } + + List seriesList = new ArrayList<>(rankMap.size()); + for (ChatAssistantUsageRank rank : rankMap.values()) { + BigInteger assistantId = rank.assistantId(); + DashboardAssistantTrendSeriesVo series = new DashboardAssistantTrendSeriesVo(); + series.setAssistantId(assistantId); + series.setLabel(resolveAssistantLabel(assistantId, rank.assistantName())); + series.setTotalSessionCount(rank.sessionTotal()); + + List points = new ArrayList<>(buckets.size()); + Map assistantTrendMap = + trendMap.getOrDefault(assistantId, new HashMap<>()); + for (TimeBucket bucket : buckets) { + ChatAssistantSessionTrend trend = assistantTrendMap.get(bucket.key); + DashboardAssistantTrendPointVo point = new DashboardAssistantTrendPointVo(); + point.setKey(bucket.key); + point.setLabel(bucket.label); + point.setSessionTotal(trend == null ? 0L : trend.sessionTotal()); + points.add(point); + } + series.setPoints(points); + seriesList.add(series); + } + return seriesList; + } + + /** + * 构建用户活跃排行。 + * + * @param ranks 原始排行数据 + * @return 页面排行项 + */ + private List buildUserRanks(List ranks) { + List items = new ArrayList<>(ranks.size()); + Map displayNameMap = resolveUserDisplayNameMap(ranks); + for (ChatActiveUserRank rank : ranks) { + DashboardUserRankItemVo item = new DashboardUserRankItemVo(); + item.setUserId(rank.userId()); + item.setLabel(resolveUserLabel(rank.userId(), rank.userAccount(), displayNameMap)); + item.setSessionTotal(rank.sessionTotal()); + item.setMessageTotal(rank.messageTotal()); + item.setAssistantTotal(rank.assistantTotal()); items.add(item); } return items; @@ -311,12 +450,28 @@ public class DashboardServiceImpl implements DashboardService { if ("today".equals(context.range)) { context.startTime = LocalDateTime.of(today, LocalTime.MIN); context.endTime = context.startTime.plusDays(1); + context.queryStartDate = today.toString(); + context.queryEndDate = today.toString(); } else if ("7d".equals(context.range)) { context.startTime = LocalDateTime.of(today.minusDays(6), LocalTime.MIN); context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN); - } else { + context.queryStartDate = today.minusDays(6).toString(); + context.queryEndDate = today.toString(); + } else if ("30d".equals(context.range)) { context.startTime = LocalDateTime.of(today.minusDays(29), LocalTime.MIN); context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN); + context.queryStartDate = today.minusDays(29).toString(); + context.queryEndDate = today.toString(); + } else { + LocalDate customStartDate = parseRequiredDate(query == null ? null : query.getStartDate(), "开始日期不能为空"); + LocalDate customEndDate = parseRequiredDate(query == null ? null : query.getEndDate(), "结束日期不能为空"); + if (customStartDate.isAfter(customEndDate)) { + throw new BusinessException("开始日期不能晚于结束日期"); + } + context.startTime = LocalDateTime.of(customStartDate, LocalTime.MIN); + context.endTime = LocalDateTime.of(customEndDate.plusDays(1), LocalTime.MIN); + context.queryStartDate = customStartDate.toString(); + context.queryEndDate = customEndDate.toString(); } context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId(); @@ -359,7 +514,7 @@ public class DashboardServiceImpl implements DashboardService { if (!StringUtils.hasText(range)) { return "7d"; } - if ("today".equals(range) || "7d".equals(range) || "30d".equals(range)) { + if ("today".equals(range) || "7d".equals(range) || "30d".equals(range) || "custom".equals(range)) { return range; } throw new BusinessException("不支持的时间范围: " + range); @@ -371,13 +526,19 @@ public class DashboardServiceImpl implements DashboardService { * @param range 时间范围 * @return 时间桶列表 */ - private List buildBuckets(String range) { + private List buildBuckets(String range, LocalDate customStartDate, LocalDate customEndDate) { List buckets = new ArrayList<>(); LocalDate today = LocalDate.now(DEFAULT_ZONE_ID); - if ("today".equals(range)) { + boolean hourlyBucket = "today".equals(range) + || ("custom".equals(range) + && customStartDate != null + && customEndDate != null + && customStartDate.equals(customEndDate)); + if (hourlyBucket) { DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"); DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00"); - LocalDateTime start = LocalDateTime.of(today, LocalTime.MIN); + LocalDate bucketDate = "today".equals(range) ? today : customStartDate; + LocalDateTime start = LocalDateTime.of(bucketDate, LocalTime.MIN); for (int hour = 0; hour < 24; hour++) { LocalDateTime current = start.plusHours(hour); buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter))); @@ -385,10 +546,20 @@ public class DashboardServiceImpl implements DashboardService { return buckets; } - int days = "7d".equals(range) ? 7 : 30; DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("MM-dd"); - LocalDate start = today.minusDays(days - 1L); + int days; + LocalDate start; + if ("7d".equals(range)) { + days = 7; + start = today.minusDays(6); + } else if ("30d".equals(range)) { + days = 30; + start = today.minusDays(29); + } else { + start = customStartDate; + days = (int) java.time.temporal.ChronoUnit.DAYS.between(customStartDate, customEndDate) + 1; + } for (int i = 0; i < days; i++) { LocalDate current = start.plusDays(i); buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter))); @@ -396,6 +567,38 @@ public class DashboardServiceImpl implements DashboardService { return buckets; } + /** + * 当前上下文是否按小时构建趋势。 + * + * @param context 查询上下文 + * @return true 表示按小时 + */ + private boolean useHourlyBuckets(DashboardQueryContext context) { + return "today".equals(context.range) + || ("custom".equals(context.range) + && context.startTime != null + && context.endTime != null + && context.startTime.toLocalDate().equals(context.endTime.toLocalDate().minusDays(1))); + } + + /** + * 解析必填日期参数。 + * + * @param dateText 日期文本 + * @param errorMessage 错误信息 + * @return 日期 + */ + private LocalDate parseRequiredDate(String dateText, String errorMessage) { + if (!StringUtils.hasText(dateText)) { + throw new BusinessException(errorMessage); + } + try { + return LocalDate.parse(dateText.trim()); + } catch (Exception ex) { + throw new BusinessException("日期格式不正确: " + dateText); + } + } + /** * 把 LocalDateTime 转换为 Date。 * @@ -436,6 +639,24 @@ public class DashboardServiceImpl implements DashboardService { return (double) messageTotal / (double) sessionTotal; } + /** + * 批量解析用户展示名称映射。 + * + * @param ranks 活跃排行 + * @return 名称映射 + */ + private Map resolveUserDisplayNameMap(List ranks) { + List userIds = ranks.stream() + .map(ChatActiveUserRank::userId) + .filter(java.util.Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (userIds.isEmpty()) { + return new HashMap<>(); + } + return sysAccountService.resolveDisplayNameMap(userIds); + } + /** * 解析智能体展示名称。 * @@ -450,6 +671,27 @@ public class DashboardServiceImpl implements DashboardService { return assistantId == null ? "智能体-未知" : "智能体-" + assistantId; } + /** + * 解析用户展示名称。 + * + * @param userId 用户 ID + * @param userAccount 聊天侧账号快照 + * @param displayNameMap 系统账号名称映射 + * @return 展示名称 + */ + private String resolveUserLabel(BigInteger userId, String userAccount, Map displayNameMap) { + if (userId != null) { + String displayName = displayNameMap.get(userId); + if (StringUtils.hasText(displayName) && !displayName.equals(userId.toString())) { + return displayName; + } + } + if (StringUtils.hasText(userAccount)) { + return userAccount.trim(); + } + return userId == null ? "用户-未知" : "用户-" + userId; + } + /** * 工作台查询上下文。 */ @@ -460,6 +702,8 @@ public class DashboardServiceImpl implements DashboardService { private boolean superAdmin; private LocalDateTime startTime; private LocalDateTime endTime; + private String queryStartDate; + private String queryEndDate; } /** @@ -481,7 +725,9 @@ public class DashboardServiceImpl implements DashboardService { private record ChatDashboardPayload( DashboardChatStatusVo chatStatus, List trends, - List distribution + List assistantTrends, + List distribution, + List userRanks ) { } } diff --git a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java index 620c9b8..c5a59f7 100644 --- a/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java +++ b/easyflow-api/easyflow-api-admin/src/test/java/tech/easyflow/admin/service/dashboard/impl/DashboardServiceImplTest.java @@ -2,13 +2,20 @@ package tech.easyflow.admin.service.dashboard.impl; import org.testng.Assert; import org.testng.annotations.Test; +import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo; import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo; +import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery; import tech.easyflow.admin.model.dashboard.DashboardSummaryVo; import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo; +import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo; +import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; +import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; import tech.easyflow.chatlog.service.ChatDashboardQueryService; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.system.service.SysAccountService; import java.lang.reflect.Constructor; import java.lang.reflect.Field; @@ -19,9 +26,13 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -56,6 +67,7 @@ public class DashboardServiceImplTest { Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L)); Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L)); Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L)); + Assert.assertEquals(summary.getChatActiveUserTotal(), Long.valueOf(0L)); } /** @@ -72,12 +84,19 @@ public class DashboardServiceImplTest { .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00")); when(chatDashboardQueryService.available()).thenReturn(true); when(chatDashboardQueryService.querySummary(any(), any(), any())) - .thenReturn(new ChatDashboardSummary(3L, 9L, 1L)); + .thenReturn(new ChatDashboardSummary(3L, 9L, 1L, 2L)); when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any())) - .thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L))); + .thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L, 2L))); when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) - .thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 3L, 9L))); + .thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L))); + when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any())) + .thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L))); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 3L, 9L, 1L))); + SysAccountService sysAccountService = mock(SysAccountService.class); + when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(2), "演示用户")); setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "sysAccountService", sysAccountService); Object context = newContext("today", BigInteger.valueOf(9)); DashboardSummaryVo summary = new DashboardSummaryVo(); @@ -85,7 +104,10 @@ public class DashboardServiceImplTest { Object chatStatus = readField(payload, "chatStatus"); List trends = (List) readField(payload, "trends"); + List assistantTrends = + (List) readField(payload, "assistantTrends"); List distribution = (List) readField(payload, "distribution"); + List userRanks = (List) readField(payload, "userRanks"); Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable"))); Assert.assertEquals(trends.size(), 24); @@ -94,13 +116,232 @@ public class DashboardServiceImplTest { Assert.assertEquals(trends.get(10).getLabel(), "10:00"); Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L)); Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L)); + Assert.assertEquals(trends.get(10).getActiveUserTotal(), Long.valueOf(2L)); Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L)); Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L)); Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L)); Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L)); Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L)); + Assert.assertEquals(summary.getChatActiveUserTotal(), Long.valueOf(2L)); + Assert.assertEquals(assistantTrends.size(), 1); + Assert.assertEquals(assistantTrends.get(0).getLabel(), "智能体-1"); + Assert.assertEquals(assistantTrends.get(0).getTotalSessionCount(), Long.valueOf(3L)); + Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 24); + Assert.assertEquals(assistantTrends.get(0).getPoints().get(10).getSessionTotal(), Long.valueOf(3L)); + Assert.assertEquals(assistantTrends.get(0).getPoints().get(11).getSessionTotal(), Long.valueOf(0L)); Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1"); Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D)); + Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D)); + Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户"); + Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L)); + } + + /** + * 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.querySummary(any(), any(), any())) + .thenReturn(new ChatDashboardSummary(1L, 1L, 1L, 1L)); + when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any())) + .thenReturn(List.of()); + when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of()); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L))); + SysAccountService sysAccountService = mock(SysAccountService.class); + when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(9), "9")); + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "sysAccountService", sysAccountService); + + Object context = newContext("today", BigInteger.ONE); + DashboardSummaryVo summary = new DashboardSummaryVo(); + Object payload = invokeBuildChatPayload(service, context, summary); + + List userRanks = (List) readField(payload, "userRanks"); + Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user"); + } + + /** + * 验证日趋势会保留 assistantId 为空的排行项,并补齐 7 天点位。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldBuildDailyAssistantTrendSeriesForRankedAssistants() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.querySummary(any(), any(), any())) + .thenReturn(new ChatDashboardSummary(4L, 12L, 2L, 3L)); + when(chatDashboardQueryService.queryTrends(any(), any(), any())) + .thenReturn(List.of()); + when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of( + new ChatAssistantUsageRank(BigInteger.ONE, "助手-A", 3L, 4L, 12L), + new ChatAssistantUsageRank(null, "未知助手", 1L, 2L, 4L) + )); + when(chatDashboardQueryService.queryAssistantTrends(any(), any(), any(), any())) + .thenReturn(List.of( + new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().minusDays(6).toString(), 2L), + new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L), + new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L) + )); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of()); + SysAccountService sysAccountService = mock(SysAccountService.class); + when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of()); + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "sysAccountService", sysAccountService); + + Object context = newContext("7d", BigInteger.ONE); + DashboardSummaryVo summary = new DashboardSummaryVo(); + Object payload = invokeBuildChatPayload(service, context, summary); + + List assistantTrends = + (List) readField(payload, "assistantTrends"); + Assert.assertEquals(assistantTrends.size(), 2); + Assert.assertEquals(assistantTrends.get(0).getLabel(), "助手-A"); + Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 7); + Assert.assertEquals(assistantTrends.get(0).getPoints().get(0).getSessionTotal(), Long.valueOf(2L)); + Assert.assertEquals(assistantTrends.get(0).getPoints().get(6).getSessionTotal(), Long.valueOf(4L)); + Assert.assertEquals(assistantTrends.get(0).getPoints().get(1).getSessionTotal(), Long.valueOf(0L)); + Assert.assertNull(assistantTrends.get(1).getAssistantId()); + Assert.assertEquals(assistantTrends.get(1).getLabel(), "未知助手"); + Assert.assertEquals(assistantTrends.get(1).getPoints().get(3).getSessionTotal(), Long.valueOf(2L)); + } + + /** + * 验证自定义单天范围按小时桶构建。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldBuildHourlyTrendForCustomSingleDayRange() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + LocalDate customDate = LocalDate.now().minusDays(2); + String currentHourKey = LocalDateTime.of(customDate, LocalTime.of(8, 0)) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00")); + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.querySummary(any(), any(), any())) + .thenReturn(new ChatDashboardSummary(2L, 6L, 1L, 1L)); + when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any())) + .thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L))); + when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of()); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of()); + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "sysAccountService", mock(SysAccountService.class)); + + Object context = newContext( + "custom", + BigInteger.ONE, + LocalDateTime.of(customDate, LocalTime.MIN), + LocalDateTime.of(customDate.plusDays(1), LocalTime.MIN) + ); + DashboardSummaryVo summary = new DashboardSummaryVo(); + Object payload = invokeBuildChatPayload(service, context, summary); + + List trends = (List) readField(payload, "trends"); + Assert.assertEquals(trends.size(), 24); + Assert.assertEquals(trends.get(8).getKey(), currentHourKey); + Assert.assertEquals(trends.get(8).getActiveUserTotal(), Long.valueOf(1L)); + Assert.assertEquals(trends.get(9).getChatSessionTotal(), Long.valueOf(0L)); + } + + /** + * 验证自定义多天范围按天桶构建,并保留查询日期。 + * + * @throws Exception 反射调用失败 + */ + @Test + public void shouldBuildDailyBucketsForCustomMultiDayRangeContext() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + DashboardOverviewQuery query = new DashboardOverviewQuery(); + query.setRange("custom"); + query.setStartDate("2026-05-01"); + query.setEndDate("2026-05-03"); + + Object context = invokeBuildContext(service, query); + Assert.assertEquals(readField(context, "range"), "custom"); + Assert.assertEquals( + readField(context, "startTime"), + LocalDateTime.of(LocalDate.of(2026, 5, 1), LocalTime.MIN) + ); + Assert.assertEquals( + readField(context, "endTime"), + LocalDateTime.of(LocalDate.of(2026, 5, 4), LocalTime.MIN) + ); + Assert.assertEquals(readField(context, "queryStartDate"), "2026-05-01"); + Assert.assertEquals(readField(context, "queryEndDate"), "2026-05-03"); + } + + /** + * 验证近 30 天趋势补齐完整 30 个桶,并按 Top 8 请求智能体活跃排行。 + * + * @throws Exception 反射调用失败 + */ + @Test + @SuppressWarnings("unchecked") + public void shouldBuildThirtyDayBucketsAndRequestTopEightAssistantRanks() throws Exception { + DashboardServiceImpl service = new DashboardServiceImpl(); + ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class); + LocalDate startDate = LocalDate.now().minusDays(29); + LocalDate endDate = LocalDate.now(); + when(chatDashboardQueryService.available()).thenReturn(true); + when(chatDashboardQueryService.querySummary(any(), any(), any())) + .thenReturn(new ChatDashboardSummary(10L, 20L, 8L, 4L)); + when(chatDashboardQueryService.queryTrends(any(), any(), any())) + .thenReturn(List.of( + new ChatDashboardTrend(startDate.toString(), 3L, 6L, 2L), + new ChatDashboardTrend(endDate.toString(), 7L, 14L, 4L) + )); + when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), eq(8))) + .thenReturn(IntStream.rangeClosed(1, 8) + .mapToObj(index -> new ChatAssistantUsageRank( + BigInteger.valueOf(index), + "助手-" + index, + index, + index * 2L, + index * 4L + )) + .toList()); + when(chatDashboardQueryService.queryAssistantTrends(any(), any(), any(), any())) + .thenReturn(List.of( + new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L), + new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L) + )); + when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class))) + .thenReturn(List.of()); + setField(service, "chatDashboardQueryService", chatDashboardQueryService); + setField(service, "sysAccountService", mock(SysAccountService.class)); + + Object context = newContext("30d", BigInteger.ONE); + DashboardSummaryVo summary = new DashboardSummaryVo(); + Object payload = invokeBuildChatPayload(service, context, summary); + + List trends = (List) readField(payload, "trends"); + List assistantTrends = + (List) readField(payload, "assistantTrends"); + Assert.assertEquals(trends.size(), 30); + Assert.assertEquals(trends.get(0).getKey(), startDate.toString()); + Assert.assertEquals(trends.get(0).getChatSessionTotal(), Long.valueOf(3L)); + Assert.assertEquals(trends.get(29).getKey(), endDate.toString()); + Assert.assertEquals(trends.get(29).getChatMessageTotal(), Long.valueOf(14L)); + Assert.assertEquals(trends.get(1).getChatSessionTotal(), Long.valueOf(0L)); + Assert.assertEquals(assistantTrends.size(), 8); + Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 30); + verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8)); } /** @@ -112,6 +353,28 @@ public class DashboardServiceImplTest { * @throws Exception 反射失败 */ private Object newContext(String range, BigInteger tenantId) throws Exception { + return newContext( + range, + tenantId, + LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN), + LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN) + ); + } + + /** + * 构造查询上下文。 + * + * @param range 时间范围 + * @param tenantId 租户 ID + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 查询上下文实例 + * @throws Exception 反射失败 + */ + private Object newContext(String range, + BigInteger tenantId, + LocalDateTime startTime, + LocalDateTime endTime) throws Exception { Class contextClass = Class.forName( "tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext" ); @@ -120,11 +383,29 @@ public class DashboardServiceImplTest { Object context = constructor.newInstance(); setField(context, "range", range); setField(context, "tenantFilterId", tenantId); - setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN)); - setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN)); + setField(context, "startTime", startTime); + setField(context, "endTime", endTime); return context; } + /** + * 调用私有上下文构建方法。 + * + * @param service service + * @param query 查询参数 + * @return 上下文 + * @throws Exception 反射失败 + */ + private Object invokeBuildContext(DashboardServiceImpl service, DashboardOverviewQuery query) throws Exception { + Method method = DashboardServiceImpl.class.getDeclaredMethod( + "buildContext", + LoginAccount.class, + DashboardOverviewQuery.class + ); + method.setAccessible(true); + return method.invoke(service, null, query); + } + /** * 调用私有聊天载荷组装方法。 * diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatActiveUserRank.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatActiveUserRank.java new file mode 100644 index 0000000..88e1798 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatActiveUserRank.java @@ -0,0 +1,19 @@ +package tech.easyflow.chatlog.domain.dto; + +import java.math.BigInteger; + +/** + * 聊天活跃用户排行项。 + * + * @param userId 用户 ID + * @param userAccount 用户账号快照 + * @param sessionTotal 会话总数 + * @param messageTotal 消息总数 + * @param assistantTotal 使用智能体数 + */ +public record ChatActiveUserRank(BigInteger userId, + String userAccount, + long sessionTotal, + long messageTotal, + long assistantTotal) { +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantSessionTrend.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantSessionTrend.java new file mode 100644 index 0000000..a376907 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantSessionTrend.java @@ -0,0 +1,17 @@ +package tech.easyflow.chatlog.domain.dto; + +import java.math.BigInteger; + +/** + * 智能体会话趋势项。 + * + * @param assistantId 智能体 ID + * @param assistantName 智能体名称 + * @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00 + * @param sessionTotal 会话总数 + */ +public record ChatAssistantSessionTrend(BigInteger assistantId, + String assistantName, + String bucketKey, + long sessionTotal) { +} diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java index b28e3a6..5e67e02 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatAssistantUsageRank.java @@ -7,11 +7,13 @@ import java.math.BigInteger; * * @param assistantId 智能体 ID * @param assistantName 智能体名称 + * @param userTotal 使用用户数 * @param sessionTotal 会话总数 * @param messageTotal 消息总数 */ public record ChatAssistantUsageRank(BigInteger assistantId, String assistantName, + long userTotal, long sessionTotal, long messageTotal) { } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java index 99e71a3..a573bea 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardSummary.java @@ -6,8 +6,12 @@ package tech.easyflow.chatlog.domain.dto; * @param sessionTotal 会话总数 * @param messageTotal 消息总数 * @param activeAssistantTotal 活跃智能体数 + * @param chatActiveUserTotal AI 活跃用户数 */ -public record ChatDashboardSummary(long sessionTotal, long messageTotal, long activeAssistantTotal) { +public record ChatDashboardSummary(long sessionTotal, + long messageTotal, + long activeAssistantTotal, + long chatActiveUserTotal) { /** * 创建空汇总结果。 @@ -15,6 +19,6 @@ public record ChatDashboardSummary(long sessionTotal, long messageTotal, long ac * @return 空汇总结果 */ public static ChatDashboardSummary empty() { - return new ChatDashboardSummary(0L, 0L, 0L); + return new ChatDashboardSummary(0L, 0L, 0L, 0L); } } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java index 476418f..7474ef9 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/domain/dto/ChatDashboardTrend.java @@ -6,6 +6,10 @@ package tech.easyflow.chatlog.domain.dto; * @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00 * @param sessionTotal 会话总数 * @param messageTotal 消息总数 + * @param activeUserTotal AI 活跃用户数 */ -public record ChatDashboardTrend(String bucketKey, long sessionTotal, long messageTotal) { +public record ChatDashboardTrend(String bucketKey, + long sessionTotal, + long messageTotal, + long activeUserTotal) { } diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java index a8be436..98e85a5 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepository.java @@ -4,6 +4,8 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; +import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; +import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; @@ -26,6 +28,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; @@ -222,21 +225,24 @@ public class ChatAnalyticalDBRepository { List args = new java.util.ArrayList<>(); StringBuilder sql = new StringBuilder(); sql.append("SELECT ") - .append("ifNull(sum(message_count), 0) AS message_total, ") - .append("ifNull(sum(session_count), 0) AS session_total, ") - .append("uniqExact(dimension_id) AS active_assistant_total ") - .append("FROM dws_chat_assistant_day ") - .append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)"); + .append("uniqExact(agg.dimension_id) AS session_total, ") + .append("ifNull(sum(agg.message_count), 0) AS message_total, ") + .append("uniqExact(agg.assistant_id) AS active_assistant_total, ") + .append("uniqExact(agg.user_id) AS active_user_total ") + .append("FROM dws_chat_session_day agg ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ") + .append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)"); args.add(startDate.toString()); args.add(endDate.toString()); - appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); ChatDashboardSummary summary = analyticalDBOperations.queryOne( sql.toString(), (rs, rowNum) -> new ChatDashboardSummary( rs.getLong("session_total"), rs.getLong("message_total"), - rs.getLong("active_assistant_total") + rs.getLong("active_assistant_total"), + rs.getLong("active_user_total") ), args.toArray() ); @@ -255,22 +261,25 @@ public class ChatAnalyticalDBRepository { assertAvailable(); List args = new java.util.ArrayList<>(); StringBuilder sql = new StringBuilder(); - sql.append("SELECT toString(stat_date) AS bucket_key, ") - .append("ifNull(sum(message_count), 0) AS message_total, ") - .append("ifNull(sum(session_count), 0) AS session_total ") - .append("FROM dws_chat_assistant_day ") - .append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)"); + sql.append("SELECT toString(agg.stat_date) AS bucket_key, ") + .append("uniqExact(agg.dimension_id) AS session_total, ") + .append("ifNull(sum(agg.message_count), 0) AS message_total, ") + .append("uniqExact(agg.user_id) AS active_user_total ") + .append("FROM dws_chat_session_day agg ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ") + .append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)"); args.add(startDate.toString()); args.add(endDate.toString()); - appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); - sql.append(" GROUP BY stat_date ORDER BY stat_date ASC"); + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); + sql.append(" GROUP BY agg.stat_date ORDER BY agg.stat_date ASC"); return analyticalDBOperations.query( sql.toString(), (rs, rowNum) -> new ChatDashboardTrend( rs.getString("bucket_key"), rs.getLong("session_total"), - rs.getLong("message_total") + rs.getLong("message_total"), + rs.getLong("active_user_total") ), args.toArray() ); @@ -292,7 +301,8 @@ public class ChatAnalyticalDBRepository { StringBuilder sql = new StringBuilder(); sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ") .append("count() AS message_total, ") - .append("uniqExact(l.session_id) AS session_total ") + .append("uniqExact(l.session_id) AS session_total, ") + .append("uniqExact(l.user_id) AS active_user_total ") .append("FROM ods_chat_log l ") .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ") .append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)"); @@ -306,7 +316,8 @@ public class ChatAnalyticalDBRepository { (rs, rowNum) -> new ChatDashboardTrend( rs.getString("bucket_key"), rs.getLong("session_total"), - rs.getLong("message_total") + rs.getLong("message_total"), + rs.getLong("active_user_total") ), args.toArray() ); @@ -329,17 +340,21 @@ public class ChatAnalyticalDBRepository { int safeLimit = Math.max(limit, 1); List args = new java.util.ArrayList<>(); StringBuilder sql = new StringBuilder(); - sql.append("SELECT agg.assistant_id, snapshot.assistant_name, agg.session_total, agg.message_total ") + sql.append("SELECT agg.assistant_id AS assistant_id, ") + .append("snapshot.assistant_name AS assistant_name, ") + .append("agg.user_total, agg.session_total, agg.message_total ") .append("FROM (") - .append("SELECT dimension_id AS assistant_id, ") - .append("ifNull(sum(session_count), 0) AS session_total, ") - .append("ifNull(sum(message_count), 0) AS message_total ") - .append("FROM dws_chat_assistant_day ") - .append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)"); + .append("SELECT agg.assistant_id, ") + .append("uniqExact(agg.user_id) AS user_total, ") + .append("uniqExact(agg.dimension_id) AS session_total, ") + .append("ifNull(sum(agg.message_count), 0) AS message_total ") + .append("FROM dws_chat_session_day agg ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ") + .append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)"); args.add(startDate.toString()); args.add(endDate.toString()); - appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); - sql.append(" GROUP BY dimension_id") + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); + sql.append(" GROUP BY agg.assistant_id") .append(") agg ") .append("LEFT JOIN (") .append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ") @@ -347,7 +362,7 @@ public class ChatAnalyticalDBRepository { appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); sql.append(" GROUP BY assistant_id") .append(") snapshot ON snapshot.assistant_id = agg.assistant_id ") - .append("ORDER BY agg.message_total DESC, agg.session_total DESC, agg.assistant_id ASC ") + .append("ORDER BY agg.session_total DESC, agg.user_total DESC, agg.message_total DESC, agg.assistant_id ASC ") .append("LIMIT ?"); args.add(safeLimit); @@ -356,6 +371,7 @@ public class ChatAnalyticalDBRepository { (rs, rowNum) -> new ChatAssistantUsageRank( bigInteger(rs.getObject("assistant_id")), rs.getString("assistant_name"), + rs.getLong("user_total"), rs.getLong("session_total"), rs.getLong("message_total") ), @@ -363,6 +379,215 @@ public class ChatAnalyticalDBRepository { ); } + /** + * 查询智能体日趋势。 + * + * @param startDate 开始日期,包含 + * @param endDate 结束日期,不包含 + * @param tenantId 租户 ID,空表示全局 + * @param assistantIds 智能体 ID 列表 + * @return 趋势列表 + */ + public List queryAssistantSessionTrends(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + List assistantIds) { + assertAvailable(); + if (assistantIds == null || assistantIds.isEmpty()) { + return Collections.emptyList(); + } + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT agg.assistant_id AS assistant_id, ") + .append("snapshot.assistant_name AS assistant_name, ") + .append("toString(agg.stat_date) AS bucket_key, ") + .append("uniqExact(agg.dimension_id) AS session_total ") + .append("FROM dws_chat_session_day agg ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ") + .append("LEFT JOIN (") + .append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ") + .append("FROM ods_chat_session FINAL WHERE is_deleted = 0 "); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + sql.append(" GROUP BY assistant_id") + .append(") snapshot ON snapshot.assistant_id = agg.assistant_id ") + .append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)"); + args.add(startDate.toString()); + args.add(endDate.toString()); + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); + appendAssistantIdFilter(sql, args, "agg.assistant_id", assistantIds); + sql.append(" GROUP BY agg.assistant_id, snapshot.assistant_name, agg.stat_date ") + .append("ORDER BY agg.stat_date ASC, agg.assistant_id ASC"); + + return analyticalDBOperations.query( + sql.toString(), + (rs, rowNum) -> new ChatAssistantSessionTrend( + bigInteger(rs.getObject("assistant_id")), + rs.getString("assistant_name"), + rs.getString("bucket_key"), + rs.getLong("session_total") + ), + args.toArray() + ); + } + + /** + * 查询智能体小时趋势。 + * + * @param startTime 开始时间,包含 + * @param endTime 结束时间,不包含 + * @param tenantId 租户 ID,空表示全局 + * @param assistantIds 智能体 ID 列表 + * @return 趋势列表 + */ + public List queryAssistantSessionHourlyTrends(LocalDateTime startTime, + LocalDateTime endTime, + BigInteger tenantId, + List assistantIds) { + assertAvailable(); + if (assistantIds == null || assistantIds.isEmpty()) { + return Collections.emptyList(); + } + List args = new ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT l.assistant_id AS assistant_id, ") + .append("snapshot.assistant_name AS assistant_name, ") + .append("formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ") + .append("uniqExact(l.session_id) AS session_total ") + .append("FROM ods_chat_log l ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ") + .append("LEFT JOIN (") + .append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ") + .append("FROM ods_chat_session FINAL WHERE is_deleted = 0 "); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + sql.append(" GROUP BY assistant_id") + .append(") snapshot ON snapshot.assistant_id = l.assistant_id ") + .append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)"); + args.add(CH_DATE_TIME_FORMATTER.format(startTime)); + args.add(CH_DATE_TIME_FORMATTER.format(endTime)); + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); + appendAssistantIdFilter(sql, args, "l.assistant_id", assistantIds); + sql.append(" GROUP BY l.assistant_id, snapshot.assistant_name, bucket_key ") + .append("ORDER BY bucket_key ASC, l.assistant_id ASC"); + + return analyticalDBOperations.query( + sql.toString(), + (rs, rowNum) -> new ChatAssistantSessionTrend( + bigInteger(rs.getObject("assistant_id")), + rs.getString("assistant_name"), + rs.getString("bucket_key"), + rs.getLong("session_total") + ), + args.toArray() + ); + } + + /** + * 查询活跃用户排行。 + * + * @param startDate 开始日期,包含 + * @param endDate 结束日期,不包含 + * @param tenantId 租户 ID,空表示全局 + * @param limit 返回条数 + * @return 排行列表 + */ + public List queryActiveUserRanks(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + int limit) { + assertAvailable(); + int safeLimit = Math.max(limit, 1); + List args = new java.util.ArrayList<>(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT agg.user_id AS user_id, ") + .append("snapshot.user_account AS user_account, ") + .append("agg.session_total, agg.message_total, agg.assistant_total ") + .append("FROM (") + .append("SELECT agg.user_id, ") + .append("uniqExact(agg.dimension_id) AS session_total, ") + .append("ifNull(sum(agg.message_count), 0) AS message_total, ") + .append("uniqExact(agg.assistant_id) AS assistant_total ") + .append("FROM dws_chat_session_day agg ") + .append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ") + .append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)"); + args.add(startDate.toString()); + args.add(endDate.toString()); + appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id"); + sql.append(" GROUP BY agg.user_id") + .append(") agg ") + .append("LEFT JOIN (") + .append("SELECT user_id, argMax(user_account, modified) AS user_account ") + .append("FROM ods_chat_session FINAL WHERE is_deleted = 0 "); + appendOptionalTenantFilter(sql, args, tenantId, "tenant_id"); + sql.append(" GROUP BY user_id") + .append(") snapshot ON snapshot.user_id = agg.user_id ") + .append("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC ") + .append("LIMIT ?"); + args.add(safeLimit); + + return analyticalDBOperations.query( + sql.toString(), + (rs, rowNum) -> new ChatActiveUserRank( + bigInteger(rs.getObject("user_id")), + rs.getString("user_account"), + rs.getLong("session_total"), + rs.getLong("message_total"), + rs.getLong("assistant_total") + ), + args.toArray() + ); + } + + /** + * 追加智能体 ID 过滤。 + * + * @param sql SQL 构造器 + * @param params 参数列表 + * @param columnName 列名 + * @param assistantIds 智能体 ID 列表 + */ + private void appendAssistantIdFilter(StringBuilder sql, + List params, + String columnName, + List assistantIds) { + if (assistantIds == null || assistantIds.isEmpty()) { + return; + } + List nonNullAssistantIds = new ArrayList<>(); + boolean containsNullAssistantId = false; + for (BigInteger assistantId : assistantIds) { + if (assistantId == null) { + containsNullAssistantId = true; + } else { + nonNullAssistantIds.add(assistantId); + } + } + if (nonNullAssistantIds.isEmpty() && !containsNullAssistantId) { + return; + } + + sql.append(" AND ("); + boolean hasPreviousCondition = false; + if (!nonNullAssistantIds.isEmpty()) { + sql.append(columnName).append(" IN ("); + for (int i = 0; i < nonNullAssistantIds.size(); i++) { + if (i > 0) { + sql.append(", "); + } + sql.append("?"); + params.add(nonNullAssistantIds.get(i)); + } + sql.append(")"); + hasPreviousCondition = true; + } + if (containsNullAssistantId) { + if (hasPreviousCondition) { + sql.append(" OR "); + } + sql.append(columnName).append(" IS NULL"); + } + sql.append(")"); + } + public void refreshDws(Set dates) { if (!enabled() || dates.isEmpty()) { return; diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java index c2065c6..8ea97c2 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/ChatDashboardQueryService.java @@ -1,5 +1,7 @@ package tech.easyflow.chatlog.service; +import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; +import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; @@ -58,6 +60,48 @@ public interface ChatDashboardQueryService { BigInteger tenantId, int limit); + /** + * 查询智能体日趋势。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @param assistantIds 智能体 ID 列表 + * @return 智能体日趋势 + */ + List queryAssistantTrends(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + List assistantIds); + + /** + * 查询智能体小时趋势。 + * + * @param startTime 开始时间,包含 + * @param endTime 结束时间,不包含 + * @param tenantId 租户 ID,空表示全局 + * @param assistantIds 智能体 ID 列表 + * @return 智能体小时趋势 + */ + List queryAssistantHourlyTrends(LocalDateTime startTime, + LocalDateTime endTime, + BigInteger tenantId, + List assistantIds); + + /** + * 查询活跃用户排行。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @param limit 返回条数 + * @return 活跃用户排行 + */ + List queryActiveUserRanks(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + int limit); + /** * 当前分析库是否可用。 * diff --git a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java index 7f14704..851a058 100644 --- a/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java +++ b/easyflow-modules/easyflow-module-chatlog/src/main/java/tech/easyflow/chatlog/service/impl/ChatDashboardQueryServiceImpl.java @@ -1,6 +1,8 @@ package tech.easyflow.chatlog.service.impl; import org.springframework.stereotype.Service; +import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank; +import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend; import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank; import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend; @@ -93,6 +95,66 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit); } + /** + * 查询智能体日趋势。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @param assistantIds 智能体 ID 列表 + * @return 智能体日趋势 + */ + @Override + public List queryAssistantTrends(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + List assistantIds) { + if (!available() || assistantIds == null || assistantIds.isEmpty()) { + return Collections.emptyList(); + } + return analyticalDBRepository.queryAssistantSessionTrends(startDate, endDate, tenantId, assistantIds); + } + + /** + * 查询智能体小时趋势。 + * + * @param startTime 开始时间,包含 + * @param endTime 结束时间,不包含 + * @param tenantId 租户 ID,空表示全局 + * @param assistantIds 智能体 ID 列表 + * @return 智能体小时趋势 + */ + @Override + public List queryAssistantHourlyTrends(LocalDateTime startTime, + LocalDateTime endTime, + BigInteger tenantId, + List assistantIds) { + if (!available() || assistantIds == null || assistantIds.isEmpty()) { + return Collections.emptyList(); + } + return analyticalDBRepository.queryAssistantSessionHourlyTrends(startTime, endTime, tenantId, assistantIds); + } + + /** + * 查询活跃用户排行。 + * + * @param startDate 开始日期,包含当天 + * @param endDate 结束日期,不包含当天 + * @param tenantId 租户 ID,空表示全局 + * @param limit 返回条数 + * @return 活跃用户排行 + */ + @Override + public List queryActiveUserRanks(LocalDate startDate, + LocalDate endDate, + BigInteger tenantId, + int limit) { + if (!available()) { + return Collections.emptyList(); + } + return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit); + } + /** * 当前分析库是否可用。 * diff --git a/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java new file mode 100644 index 0000000..1c48ba4 --- /dev/null +++ b/easyflow-modules/easyflow-module-chatlog/src/test/java/tech/easyflow/chatlog/repository/analyticaldb/ChatAnalyticalDBRepositoryTest.java @@ -0,0 +1,188 @@ +package tech.easyflow.chatlog.repository.analyticaldb; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter; +import org.springframework.jdbc.core.RowMapper; +import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary; +import tech.easyflow.chatlog.support.ChatJsonSupport; +import tech.easyflow.common.analyticaldb.config.AnalyticalDBFlywayProperties; +import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations; +import tech.easyflow.common.analyticaldb.page.AnalyticalDBPageRequest; +import tech.easyflow.common.analyticaldb.page.AnalyticalDBPageResult; +import tech.easyflow.common.analyticaldb.support.AnalyticalDBHealthSupport; + +import java.math.BigInteger; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * {@link ChatAnalyticalDBRepository} 测试。 + */ +public class ChatAnalyticalDBRepositoryTest { + + /** + * 验证工作台汇总使用跨天去重的 session 口径。 + */ + @Test + public void shouldUseDistinctSessionSqlForDashboardSummary() { + RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations(); + operations.queryOneResult = new ChatDashboardSummary(2L, 5L, 1L, 1L); + + ChatAnalyticalDBRepository repository = newRepository(operations); + repository.queryDashboardSummary(LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 8), BigInteger.ONE); + + Assert.assertNotNull(operations.lastQueryOneSql); + Assert.assertTrue(operations.lastQueryOneSql.contains("FROM dws_chat_session_day agg")); + Assert.assertTrue(operations.lastQueryOneSql.contains("uniqExact(agg.dimension_id) AS session_total")); + Assert.assertTrue(operations.lastQueryOneSql.contains("uniqExact(agg.user_id) AS active_user_total")); + } + + /** + * 验证智能体使用榜按去重会话数排序,并同时统计用户数。 + */ + @Test + public void shouldUseDistinctSessionSqlForAssistantUsageRanks() { + RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations(); + + ChatAnalyticalDBRepository repository = newRepository(operations); + repository.queryAssistantUsageRanks(LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 8), BigInteger.ONE, 5); + + Assert.assertNotNull(operations.lastQuerySql); + Assert.assertTrue(operations.lastQuerySql.contains("FROM dws_chat_session_day agg")); + Assert.assertTrue(operations.lastQuerySql.contains("uniqExact(agg.user_id) AS user_total")); + Assert.assertTrue(operations.lastQuerySql.contains("uniqExact(agg.dimension_id) AS session_total")); + Assert.assertTrue(operations.lastQuerySql.contains( + "ORDER BY agg.session_total DESC, agg.user_total DESC, agg.message_total DESC, agg.assistant_id ASC" + )); + Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id AS assistant_id")); + Assert.assertTrue(operations.lastQuerySql.contains("snapshot.assistant_name AS assistant_name")); + } + + /** + * 验证智能体趋势查询显式返回 assistant_id 别名,避免 ClickHouse JDBC 无法按列名映射。 + */ + @Test + public void shouldAliasAssistantIdForAssistantTrendQueries() { + RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations(); + + ChatAnalyticalDBRepository repository = newRepository(operations); + repository.queryAssistantSessionTrends( + LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 8), + BigInteger.ONE, + List.of(BigInteger.ONE, BigInteger.TWO) + ); + + Assert.assertNotNull(operations.lastQuerySql); + Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id AS assistant_id")); + Assert.assertTrue(operations.lastQuerySql.contains("snapshot.assistant_name AS assistant_name")); + Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id IN (?, ?)")); + } + + /** + * 验证智能体趋势查询在 Top 列表包含空 assistant_id 时会补上 IS NULL 条件。 + */ + @Test + public void shouldSupportNullAssistantIdInAssistantTrendQueries() { + RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations(); + + ChatAnalyticalDBRepository repository = newRepository(operations); + repository.queryAssistantSessionTrends( + LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 8), + BigInteger.ONE, + Arrays.asList(BigInteger.ONE, null) + ); + + Assert.assertNotNull(operations.lastQuerySql); + Assert.assertTrue(operations.lastQuerySql.contains("(agg.assistant_id IN (?) OR agg.assistant_id IS NULL)")); + } + + /** + * 构造仓储实例。 + * + * @param operations 分析库操作桩 + * @return 仓储实例 + */ + private ChatAnalyticalDBRepository newRepository(RecordingAnalyticalDBOperations operations) { + StaticListableBeanFactory beanFactory = new StaticListableBeanFactory(); + beanFactory.addBean("analyticalDBOperations", operations); + ObjectProvider provider = beanFactory.getBeanProvider(AnalyticalDBOperations.class); + AnalyticalDBHealthSupport healthSupport = + new AnalyticalDBHealthSupport(provider, new AnalyticalDBFlywayProperties()); + ChatJsonSupport jsonSupport = new ChatJsonSupport(new ObjectMapper()); + return new ChatAnalyticalDBRepository(provider, healthSupport, jsonSupport); + } + + /** + * 记录 SQL 的分析库桩实现。 + */ + private static class RecordingAnalyticalDBOperations implements AnalyticalDBOperations { + + private String lastQueryOneSql; + private String lastQuerySql; + private ChatDashboardSummary queryOneResult; + + @Override + public boolean available() { + return true; + } + + @Override + public void assertAvailable() { + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + this.lastQuerySql = sql; + return Collections.emptyList(); + } + + @Override + public T queryOne(String sql, Class requiredType, Object... args) { + this.lastQueryOneSql = sql; + return null; + } + + @SuppressWarnings("unchecked") + @Override + public T queryOne(String sql, RowMapper rowMapper, Object... args) { + this.lastQueryOneSql = sql; + return (T) queryOneResult; + } + + @Override + public List queryForList(String sql, Class elementType, Object... args) { + return Collections.emptyList(); + } + + @Override + public int update(String sql, Object... args) { + return 0; + } + + @Override + public int[][] batchUpdate(String sql, + List items, + int batchSize, + ParameterizedPreparedStatementSetter setter) { + return new int[0][]; + } + + @Override + public AnalyticalDBPageResult page(String countSql, + Object[] countArgs, + String dataSql, + Object[] dataArgs, + AnalyticalDBPageRequest pageRequest, + RowMapper rowMapper) { + return null; + } + } +} diff --git a/easyflow-ui-admin/app/src/api/dashboard.ts b/easyflow-ui-admin/app/src/api/dashboard.ts index 378c53f..ff6cdf4 100644 --- a/easyflow-ui-admin/app/src/api/dashboard.ts +++ b/easyflow-ui-admin/app/src/api/dashboard.ts @@ -1,15 +1,18 @@ import { requestClient } from '#/api/request'; -export type DashboardRange = '7d' | '30d' | 'today'; +export type DashboardRange = '7d' | '30d' | 'custom' | 'today'; export interface DashboardOverviewQuery { + endDate?: string; range?: DashboardRange; + startDate?: string; } export interface DashboardSummary { activeUserTotal: number; activeAssistantTotal: number; botTotal: number; + chatActiveUserTotal: number; chatMessageTotal: number; chatSessionTotal: number; knowledgeBaseTotal: number; @@ -30,10 +33,24 @@ export interface DashboardTrendItem { label: string; } +export interface DashboardAssistantTrendPoint { + key: string; + label: string; + sessionTotal: number; +} + +export interface DashboardAssistantTrendSeries { + assistantId?: number | string; + label: string; + points: DashboardAssistantTrendPoint[]; + totalSessionCount: number; +} + export interface DashboardDistributionItem { activeUserTotal: number; assistantId?: number | string; avgMessagePerSession?: number; + avgSessionPerUser?: number; botTotal: number; key: string; knowledgeBaseTotal: number; @@ -45,13 +62,23 @@ export interface DashboardDistributionItem { workflowTotal: number; } +export interface DashboardUserRankItem { + assistantTotal: number; + label: string; + messageTotal: number; + sessionTotal: number; + userId?: number | string; +} + export interface DashboardOverviewResponse { + assistantTrends: DashboardAssistantTrendSeries[]; chatStatus: DashboardChatStatus; distribution: DashboardDistributionItem[]; query: DashboardOverviewQuery; summary: DashboardSummary; trends: DashboardTrendItem[]; updatedAt: string; + userRanks: DashboardUserRankItem[]; } export async function getDashboardOverview(params: DashboardOverviewQuery) { diff --git a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue index 4104fdf..f44ce23 100644 --- a/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue +++ b/easyflow-ui-admin/app/src/views/dashboard/workspace/index.vue @@ -2,12 +2,14 @@ import type { EchartsUIType } from '@easyflow/plugins/echarts'; import type { + DashboardAssistantTrendSeries, DashboardDistributionItem, DashboardOverviewQuery, DashboardOverviewResponse, DashboardRange, DashboardSummary, DashboardTrendItem, + DashboardUserRankItem, } from '#/api/dashboard'; import { @@ -17,6 +19,7 @@ import { onMounted, reactive, ref, + watch, } from 'vue'; import { AnalysisChartCard } from '@easyflow/common-ui'; @@ -25,10 +28,26 @@ import { useUserStore } from '@easyflow/stores'; import { convertToRgb } from '@easyflow/utils'; import { RefreshRight } from '@element-plus/icons-vue'; -import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus'; +import { + ElButton, + ElDatePicker, + ElEmpty, + ElPopover, + ElRadioButton, + ElRadioGroup, +} from 'element-plus'; import { getDashboardOverview } from '#/api/dashboard'; +type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive'; + +interface AssistantTrendSelectionItem { + assistantKey: string; + color: string; + isSelected: boolean; + series: DashboardAssistantTrendSeries; +} + interface SummaryCardItem { available?: boolean; label: string; @@ -39,16 +58,23 @@ let greetingTimer: null | ReturnType = null; const userStore = useUserStore(); const now = ref(new Date()); -const filters = reactive>({ +const filters = reactive({ range: '7d', }); +const customDateRange = ref([]); const overview = ref(null); const isLoading = ref(false); const errorMessage = ref(''); +const trendMode = ref('usage'); +const selectedAssistantTrendKeys = ref([]); const trendChartRef = ref(); -const { renderEcharts: renderTrendEcharts } = useEcharts(trendChartRef); +const { + getChartInstance: getTrendChartInstance, + renderEcharts: renderTrendEcharts, + resize: resizeTrendChart, +} = useEcharts(trendChartRef); const rangeOptions: Array<{ label: string; value: DashboardRange }> = [ { label: '今日', value: 'today' }, @@ -56,10 +82,17 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [ { label: '近 30 天', value: '30d' }, ]; +const trendModeOptions: Array<{ label: string; value: DashboardTrendMode }> = [ + { label: '使用量趋势', value: 'usage' }, + { label: '用户活跃', value: 'userActive' }, + { label: '智能体活跃', value: 'assistantActive' }, +]; + const emptySummary: DashboardSummary = { activeAssistantTotal: 0, activeUserTotal: 0, botTotal: 0, + chatActiveUserTotal: 0, chatMessageTotal: 0, chatSessionTotal: 0, knowledgeBaseTotal: 0, @@ -68,36 +101,67 @@ const emptySummary: DashboardSummary = { }; const summary = computed(() => overview.value?.summary ?? emptySummary); -const trends = computed(() => overview.value?.trends ?? []); +const trends = computed( + () => overview.value?.trends ?? [], +); +const assistantTrends = computed( + () => overview.value?.assistantTrends ?? [], +); const distribution = computed( () => overview.value?.distribution ?? [], ); -const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false); +const userRanks = computed( + () => overview.value?.userRanks ?? [], +); +const chatAvailable = computed( + () => overview.value?.chatStatus?.available !== false, +); const chatStatusMessage = computed( () => overview.value?.chatStatus?.message || '聊天数据不可用', ); +const isUsageTrend = computed(() => trendMode.value === 'usage'); +const isUserActiveTrend = computed(() => trendMode.value === 'userActive'); +const isAssistantActiveTrend = computed( + () => trendMode.value === 'assistantActive', +); const summaryCards = computed(() => [ - { label: '助手数量', value: formatCount(summary.value.botTotal) }, - { label: '工作流数量', value: formatCount(summary.value.workflowTotal) }, { - label: '知识库数量', - value: formatCount(summary.value.knowledgeBaseTotal), + available: chatAvailable.value, + label: 'AI活跃用户', + value: formatOptionalCount( + summary.value.chatActiveUserTotal, + chatAvailable.value, + ), }, { available: chatAvailable.value, - label: '聊天消息总数', - value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value), + label: '活跃智能体', + value: formatOptionalCount( + summary.value.activeAssistantTotal, + chatAvailable.value, + ), }, { available: chatAvailable.value, label: '聊天会话总数', - value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value), + value: formatOptionalCount( + summary.value.chatSessionTotal, + chatAvailable.value, + ), }, { available: chatAvailable.value, - label: '活跃智能体数', - value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value), + label: '聊天消息总数', + value: formatOptionalCount( + summary.value.chatMessageTotal, + chatAvailable.value, + ), + }, + { label: '智能体总数', value: formatCount(summary.value.botTotal) }, + { + label: '知识库总数', + value: formatCount(summary.value.knowledgeBaseTotal), }, ]); @@ -134,15 +198,99 @@ const greetingTitle = computed( () => `${greetingText.value},${displayName.value}`, ); +const trendDescription = computed(() => { + if (isUsageTrend.value) { + return '消息与会话趋势'; + } + if (isUserActiveTrend.value) { + return '用户活跃趋势'; + } + return '智能体活跃趋势'; +}); + +const selectedAssistantTrendSet = computed( + () => new Set(selectedAssistantTrendKeys.value), +); + +const assistantTrendColors = computed(() => + buildAssistantTrendPalette(assistantTrends.value.length), +); + +const assistantTrendSelectionItems = computed( + () => + assistantTrends.value.map((series, index) => { + const assistantKey = getAssistantTrendKey( + series.assistantId, + series.label, + ); + return { + assistantKey, + color: + assistantTrendColors.value[index] || getChartTokenColor('--primary'), + isSelected: selectedAssistantTrendSet.value.has(assistantKey), + series, + }; + }), +); + +const selectedAssistantTrends = computed(() => + assistantTrendSelectionItems.value.filter((item) => item.isSelected), +); + +const selectedAssistantTrendCount = computed( + () => selectedAssistantTrends.value.length, +); + +const selectedAssistantTrendPreviewItems = computed(() => + selectedAssistantTrends.value.slice(0, 3), +); + +const assistantTrendSelectorLabel = computed(() => { + const total = assistantTrendSelectionItems.value.length; + if (total === 0) { + return '无可选智能体'; + } + return `${selectedAssistantTrendCount.value}/${total} 已选`; +}); + +const assistantTrendSelectorSummary = computed(() => { + const count = selectedAssistantTrendCount.value; + if (count === 0) { + return '未选择智能体'; + } + if (count === 1) { + return selectedAssistantTrends.value[0]?.series.label || '已选择 1 个'; + } + return `${selectedAssistantTrends.value[0]?.series.label || '已选择'} 等 ${count} 个`; +}); + +const showAssistantTrendNoData = computed( + () => + isAssistantActiveTrend.value && + assistantTrendSelectionItems.value.length === 0, +); + +const showAssistantTrendEmptySelection = computed( + () => + isAssistantActiveTrend.value && + assistantTrendSelectionItems.value.length > 0 && + selectedAssistantTrends.value.length === 0, +); + +const showTrendChart = computed(() => chatAvailable.value); + async function loadOverview() { isLoading.value = true; errorMessage.value = ''; try { const data = await getDashboardOverview({ + endDate: filters.range === 'custom' ? filters.endDate : undefined, range: filters.range, + startDate: filters.range === 'custom' ? filters.startDate : undefined, }); overview.value = data; + resetAssistantTrendSelection(); await renderCharts(); } catch (error) { overview.value = null; @@ -155,24 +303,197 @@ async function loadOverview() { async function renderCharts() { await nextTick(); - if (!chatAvailable.value) { + if (!showTrendChart.value) { return; } renderTrendChart(); } function renderTrendChart() { - const xAxisData = trends.value.map((item) => item.label); - const messageData = trends.value.map((item) => item.chatMessageTotal); - const sessionData = trends.value.map((item) => item.chatSessionTotal); const primaryColor = getChartTokenColor('--primary'); const successColor = getChartTokenColor('--success'); + const warningColor = getChartTokenColor('--warning'); + const destructiveColor = getChartTokenColor('--destructive'); const axisColor = getChartTokenColor('--border'); const tooltipLineColor = getChartTokenColor('--accent'); const textColor = getChartTokenColor('--foreground'); + if (isAssistantActiveTrend.value) { + const xAxisData = + assistantTrendSelectionItems.value[0]?.series.points.map( + (point) => point.label, + ) ?? []; + const assistantSeries = assistantTrendSelectionItems.value.map((item) => ({ + data: item.series.points.map((point) => point.sessionTotal), + emphasis: { + focus: 'series' as const, + }, + itemStyle: { + borderColor: item.color, + borderWidth: 2, + color: '#ffffff', + }, + lineStyle: { + color: item.color, + width: 3, + }, + name: item.series.label, + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 8, + type: 'line' as const, + })); + + renderTrendEcharts({ + color: assistantTrendSelectionItems.value.map((item) => item.color), + grid: { + bottom: 18, + containLabel: true, + left: 12, + right: 12, + top: 12, + }, + legend: { + data: assistantTrendSelectionItems.value.map( + (item) => item.series.label, + ), + selected: buildAssistantTrendSelectedMap(), + show: false, + }, + tooltip: { + axisPointer: { + lineStyle: { + color: tooltipLineColor, + width: 1, + }, + type: 'line', + }, + trigger: 'axis', + }, + xAxis: { + axisLine: { + lineStyle: { + color: axisColor, + }, + }, + axisTick: { + show: false, + }, + boundaryGap: false, + data: xAxisData, + type: 'category', + }, + yAxis: { + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + splitLine: { + lineStyle: { + color: axisColor, + type: 'dashed', + }, + }, + type: 'value', + }, + series: assistantSeries, + }); + return; + } + + const xAxisData = trends.value.map((item) => item.label); + const messageData = trends.value.map((item) => item.chatMessageTotal); + const sessionData = trends.value.map((item) => item.chatSessionTotal); + const activeUserData = trends.value.map((item) => item.activeUserTotal); + + const usageSeries = [ + { + data: messageData, + emphasis: { + focus: 'none' as const, + itemStyle: { + borderColor: '#ffffff', + borderWidth: 3, + color: primaryColor, + }, + scale: true, + }, + itemStyle: { + borderColor: primaryColor, + borderWidth: 2, + color: '#ffffff', + }, + lineStyle: { + color: primaryColor, + width: 3, + }, + name: '消息数', + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 9, + type: 'line' as const, + }, + { + data: sessionData, + emphasis: { + focus: 'none' as const, + itemStyle: { + borderColor: '#ffffff', + borderWidth: 3, + color: successColor, + }, + scale: true, + }, + itemStyle: { + borderColor: successColor, + borderWidth: 2, + color: '#ffffff', + }, + lineStyle: { + color: successColor, + width: 3, + }, + name: '会话数', + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 9, + type: 'line' as const, + }, + ]; + + const activeSeries = [ + { + data: activeUserData, + emphasis: { + focus: 'none' as const, + }, + itemStyle: { + borderColor: primaryColor, + borderWidth: 2, + color: '#ffffff', + }, + lineStyle: { + color: primaryColor, + width: 3, + }, + name: '活跃用户数', + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 9, + type: 'line' as const, + }, + ]; + renderTrendEcharts({ - color: [primaryColor, successColor], + color: isUsageTrend.value + ? [primaryColor, successColor] + : [primaryColor, warningColor, destructiveColor], grid: { bottom: 18, containLabel: true, @@ -231,62 +552,7 @@ function renderTrendChart() { }, type: 'value', }, - series: [ - { - data: messageData, - emphasis: { - focus: 'none', - itemStyle: { - borderColor: '#ffffff', - borderWidth: 3, - color: primaryColor, - }, - scale: true, - }, - itemStyle: { - borderColor: primaryColor, - borderWidth: 2, - color: '#ffffff', - }, - lineStyle: { - color: primaryColor, - width: 3, - }, - name: '消息数', - smooth: true, - showSymbol: false, - symbol: 'circle', - symbolSize: 9, - type: 'line', - }, - { - data: sessionData, - emphasis: { - focus: 'none', - itemStyle: { - borderColor: '#ffffff', - borderWidth: 3, - color: successColor, - }, - scale: true, - }, - itemStyle: { - borderColor: successColor, - borderWidth: 2, - color: '#ffffff', - }, - lineStyle: { - color: successColor, - width: 3, - }, - name: '会话数', - smooth: true, - showSymbol: false, - symbol: 'circle', - symbolSize: 9, - type: 'line', - }, - ], + series: isUsageTrend.value ? usageSeries : activeSeries, }); } @@ -295,9 +561,130 @@ function handleRangeChange(value: boolean | number | string | undefined) { return; } filters.range = value; + customDateRange.value = []; + filters.startDate = undefined; + filters.endDate = undefined; void loadOverview(); } +function handleCustomRangeChange(value: string[] | undefined) { + if (!value || value.length !== 2 || !value[0] || !value[1]) { + customDateRange.value = []; + filters.range = '7d'; + filters.startDate = undefined; + filters.endDate = undefined; + void loadOverview(); + return; + } + filters.range = 'custom'; + filters.startDate = value[0]; + filters.endDate = value[1]; + void loadOverview(); +} + +function handleTrendModeChange(value: boolean | number | string | undefined) { + if ( + value !== 'usage' && + value !== 'userActive' && + value !== 'assistantActive' + ) { + return; + } + trendMode.value = value; +} + +function resetAssistantTrendSelection() { + selectedAssistantTrendKeys.value = assistantTrends.value.map((series) => + getAssistantTrendKey(series.assistantId, series.label), + ); +} + +function toggleAssistantTrend(assistantKey: string) { + const currentItem = assistantTrendSelectionItems.value.find( + (item) => item.assistantKey === assistantKey, + ); + if (!currentItem) { + return; + } + const selectedKeys = new Set(selectedAssistantTrendKeys.value); + let actionType: 'legendSelect' | 'legendUnSelect' = 'legendSelect'; + if (selectedKeys.has(assistantKey)) { + selectedKeys.delete(assistantKey); + actionType = 'legendUnSelect'; + } else { + selectedKeys.add(assistantKey); + } + selectedAssistantTrendKeys.value = [...selectedKeys]; + if (!isAssistantActiveTrend.value) { + return; + } + void nextTick(() => { + const chartInstance = getTrendChartInstance(); + if (!chartInstance) { + return; + } + chartInstance.dispatchAction({ + name: currentItem.series.label, + type: actionType, + }); + }); +} + +function selectAllAssistantTrends() { + selectedAssistantTrendKeys.value = assistantTrendSelectionItems.value.map( + (item) => item.assistantKey, + ); + if (!isAssistantActiveTrend.value) { + return; + } + void nextTick(() => { + renderTrendChart(); + resizeTrendChart(); + }); +} + +function clearAssistantTrendSelection() { + const previousItems = [...selectedAssistantTrends.value]; + selectedAssistantTrendKeys.value = []; + if (!isAssistantActiveTrend.value) { + return; + } + void nextTick(() => { + const chartInstance = getTrendChartInstance(); + if (!chartInstance) { + return; + } + for (const item of previousItems) { + chartInstance.dispatchAction({ + name: item.series.label, + type: 'legendUnSelect', + }); + } + }); +} + +function buildAssistantTrendSelectedMap() { + return Object.fromEntries( + assistantTrendSelectionItems.value.map((item) => [ + item.series.label, + item.isSelected, + ]), + ); +} + +watch( + [trendMode, chatAvailable, trends, assistantTrends], + () => { + if (!showTrendChart.value) { + return; + } + void renderCharts(); + }, + { + flush: 'post', + }, +); + function formatCount(value?: number) { return Number(value || 0).toLocaleString('zh-CN'); } @@ -307,13 +694,54 @@ function formatOptionalCount(value: number | undefined, available: boolean) { } function getChartTokenColor(variableName: string) { + return convertToRgb(`hsl(${getChartTokenHsl(variableName)})`); +} + +function getChartTokenHsl(variableName: string) { if (typeof window === 'undefined') { - return '#3b82f6'; + return '211 100% 50%'; } const value = getComputedStyle(document.documentElement) .getPropertyValue(variableName) .trim(); - return value ? convertToRgb(`hsl(${value})`) : '#3b82f6'; + return value || '211 100% 50%'; +} + +function buildAssistantTrendPalette(total: number) { + const palette = [ + getChartTokenColor('--primary'), + getChartTokenColor('--success'), + getChartTokenColor('--warning'), + getChartTokenColor('--destructive'), + ]; + if (total <= palette.length) { + return palette.slice(0, total); + } + const primaryHue = extractHue(getChartTokenHsl('--primary')) ?? 211; + const derivedHues = [46, 92, 138, 184, 230, 276].map( + (offset) => `hsl(${normalizeHue(primaryHue + offset)} 68% 52%)`, + ); + return [...palette, ...derivedHues].slice(0, total); +} + +function getAssistantTrendKey( + assistantId: number | string | undefined, + label: string, +) { + return assistantId === undefined + ? `label:${label}` + : `assistant:${assistantId}`; +} + +function extractHue(hslValue: string) { + const [hue] = hslValue.split(/\s+/); + const parsed = Number.parseFloat(hue || ''); + return Number.isFinite(parsed) ? parsed : null; +} + +function normalizeHue(value: number) { + const normalized = value % 360; + return normalized < 0 ? normalized + 360 : normalized; } function formatAvg(value?: number) { @@ -363,6 +791,16 @@ onBeforeUnmount(() => {
+ { class="grid gap-4 md:grid-cols-2 xl:grid-cols-3" >
@@ -436,14 +874,134 @@ onBeforeUnmount(() => {
-
- +
+
@@ -451,9 +1009,9 @@ onBeforeUnmount(() => {
- +
@@ -496,6 +1054,66 @@ onBeforeUnmount(() => {
+ +
+ + +
+ +
+
+