feat: 增强工作台趋势概览与聊天排行

- 支持用户活跃与智能体活跃趋势统计及自定义时间范围

- 增加用户活跃榜与智能体趋势数据结构及查询实现

- 同步补齐工作台页面展示与定向测试
This commit is contained in:
2026-05-06 19:22:09 +08:00
parent 5827ecde42
commit 31b0e21d3d
20 changed files with 2087 additions and 146 deletions

View File

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

View File

@@ -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<DashboardAssistantTrendPointVo> 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<DashboardAssistantTrendPointVo> getPoints() {
return points;
}
public void setPoints(List<DashboardAssistantTrendPointVo> points) {
this.points = points;
}
}

View File

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

View File

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

View File

@@ -14,8 +14,12 @@ public class DashboardOverviewVo {
private List<DashboardTrendItemVo> trends;
private List<DashboardAssistantTrendSeriesVo> assistantTrends;
private List<DashboardDistributionItemVo> distribution;
private List<DashboardUserRankItemVo> userRanks;
private DashboardOverviewQuery query;
private Date updatedAt;
@@ -52,6 +56,22 @@ public class DashboardOverviewVo {
this.distribution = distribution;
}
public List<DashboardAssistantTrendSeriesVo> getAssistantTrends() {
return assistantTrends;
}
public void setAssistantTrends(List<DashboardAssistantTrendSeriesVo> assistantTrends) {
this.assistantTrends = assistantTrends;
}
public List<DashboardUserRankItemVo> getUserRanks() {
return userRanks;
}
public void setUserRanks(List<DashboardUserRankItemVo> userRanks) {
this.userRanks = userRanks;
}
public DashboardOverviewQuery getQuery() {
return query;
}

View File

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

View File

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

View File

@@ -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<ChatDashboardTrend> rawTrends = "today".equals(context.range)
List<ChatDashboardTrend> rawTrends = useHourlyBuckets(context)
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
List<DashboardTrendItemVo> trends = buildTrendItems(context.range, rawTrends);
List<DashboardTrendItemVo> trends = buildTrendItems(context, rawTrends);
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
startDate,
endDate,
context.tenantFilterId,
DEFAULT_ASSISTANT_RANK_LIMIT
DEFAULT_ASSISTANT_TREND_LIMIT
);
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(rawRanks);
return new ChatDashboardPayload(chatStatus, trends, distribution);
List<DashboardAssistantTrendSeriesVo> assistantTrends = buildAssistantTrendSeries(
context,
rawRanks
);
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(
rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size()))
);
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks(
startDate,
endDate,
context.tenantFilterId,
DEFAULT_USER_RANK_LIMIT
);
List<DashboardUserRankItemVo> 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<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
List<TimeBucket> buckets = buildBuckets(range);
private List<DashboardTrendItemVo> buildTrendItems(DashboardQueryContext context, List<ChatDashboardTrend> rawTrends) {
List<TimeBucket> buckets = buildBuckets(
context.range,
context.startTime.toLocalDate(),
context.endTime.toLocalDate().minusDays(1)
);
Map<String, ChatDashboardTrend> 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<DashboardAssistantTrendSeriesVo> buildAssistantTrendSeries(DashboardQueryContext context,
List<ChatAssistantUsageRank> ranks) {
if (ranks == null || ranks.isEmpty()) {
return new ArrayList<>();
}
List<TimeBucket> buckets = buildBuckets(
context.range,
context.startTime.toLocalDate(),
context.endTime.toLocalDate().minusDays(1)
);
Map<BigInteger, ChatAssistantUsageRank> rankMap = new LinkedHashMap<>();
for (ChatAssistantUsageRank rank : ranks) {
rankMap.putIfAbsent(rank.assistantId(), rank);
}
List<BigInteger> assistantIds = new ArrayList<>(rankMap.keySet());
List<ChatAssistantSessionTrend> rawAssistantTrends = useHourlyBuckets(context)
? chatDashboardQueryService.queryAssistantHourlyTrends(
context.startTime,
context.endTime,
context.tenantFilterId,
assistantIds
)
: chatDashboardQueryService.queryAssistantTrends(
context.startTime.toLocalDate(),
context.endTime.toLocalDate(),
context.tenantFilterId,
assistantIds
);
Map<BigInteger, Map<String, ChatAssistantSessionTrend>> trendMap = new HashMap<>();
for (ChatAssistantSessionTrend rawTrend : rawAssistantTrends) {
trendMap.computeIfAbsent(rawTrend.assistantId(), key -> new HashMap<>())
.put(rawTrend.bucketKey(), rawTrend);
}
List<DashboardAssistantTrendSeriesVo> 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<DashboardAssistantTrendPointVo> points = new ArrayList<>(buckets.size());
Map<String, ChatAssistantSessionTrend> 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<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
Map<BigInteger, String> 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<TimeBucket> buildBuckets(String range) {
private List<TimeBucket> buildBuckets(String range, LocalDate customStartDate, LocalDate customEndDate) {
List<TimeBucket> 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<BigInteger, String> resolveUserDisplayNameMap(List<ChatActiveUserRank> ranks) {
List<BigInteger> 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<BigInteger, String> 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<DashboardTrendItemVo> trends,
List<DashboardDistributionItemVo> distribution
List<DashboardAssistantTrendSeriesVo> assistantTrends,
List<DashboardDistributionItemVo> distribution,
List<DashboardUserRankItemVo> userRanks
) {
}
}

View File

@@ -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<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) 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<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) 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<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) 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<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) 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<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) 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);
}
/**
* 调用私有聊天载荷组装方法。
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Object> 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<Object> 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<Object> 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<ChatAssistantSessionTrend> queryAssistantSessionTrends(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
List<BigInteger> assistantIds) {
assertAvailable();
if (assistantIds == null || assistantIds.isEmpty()) {
return Collections.emptyList();
}
List<Object> 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<ChatAssistantSessionTrend> queryAssistantSessionHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId,
List<BigInteger> assistantIds) {
assertAvailable();
if (assistantIds == null || assistantIds.isEmpty()) {
return Collections.emptyList();
}
List<Object> 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<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
assertAvailable();
int safeLimit = Math.max(limit, 1);
List<Object> 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<Object> params,
String columnName,
List<BigInteger> assistantIds) {
if (assistantIds == null || assistantIds.isEmpty()) {
return;
}
List<BigInteger> 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<LocalDate> dates) {
if (!enabled() || dates.isEmpty()) {
return;

View File

@@ -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<ChatAssistantSessionTrend> queryAssistantTrends(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
List<BigInteger> assistantIds);
/**
* 查询智能体小时趋势。
*
* @param startTime 开始时间,包含
* @param endTime 结束时间,不包含
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 智能体小时趋势
*/
List<ChatAssistantSessionTrend> queryAssistantHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId,
List<BigInteger> assistantIds);
/**
* 查询活跃用户排行。
*
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param limit 返回条数
* @return 活跃用户排行
*/
List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit);
/**
* 当前分析库是否可用。
*

View File

@@ -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<ChatAssistantSessionTrend> queryAssistantTrends(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
List<BigInteger> 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<ChatAssistantSessionTrend> queryAssistantHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId,
List<BigInteger> 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<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
if (!available()) {
return Collections.emptyList();
}
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit);
}
/**
* 当前分析库是否可用。
*

View File

@@ -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<AnalyticalDBOperations> 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 <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
this.lastQuerySql = sql;
return Collections.emptyList();
}
@Override
public <T> T queryOne(String sql, Class<T> requiredType, Object... args) {
this.lastQueryOneSql = sql;
return null;
}
@SuppressWarnings("unchecked")
@Override
public <T> T queryOne(String sql, RowMapper<T> rowMapper, Object... args) {
this.lastQueryOneSql = sql;
return (T) queryOneResult;
}
@Override
public <T> List<T> queryForList(String sql, Class<T> elementType, Object... args) {
return Collections.emptyList();
}
@Override
public int update(String sql, Object... args) {
return 0;
}
@Override
public <T> int[][] batchUpdate(String sql,
List<T> items,
int batchSize,
ParameterizedPreparedStatementSetter<T> setter) {
return new int[0][];
}
@Override
public <T> AnalyticalDBPageResult<T> page(String countSql,
Object[] countArgs,
String dataSql,
Object[] dataArgs,
AnalyticalDBPageRequest pageRequest,
RowMapper<T> rowMapper) {
return null;
}
}
}

View File

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

View File

@@ -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<typeof setInterval> = null;
const userStore = useUserStore();
const now = ref(new Date());
const filters = reactive<Required<DashboardOverviewQuery>>({
const filters = reactive<DashboardOverviewQuery>({
range: '7d',
});
const customDateRange = ref<string[]>([]);
const overview = ref<DashboardOverviewResponse | null>(null);
const isLoading = ref(false);
const errorMessage = ref('');
const trendMode = ref<DashboardTrendMode>('usage');
const selectedAssistantTrendKeys = ref<string[]>([]);
const trendChartRef = ref<EchartsUIType>();
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<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
const trends = computed<DashboardTrendItem[]>(
() => overview.value?.trends ?? [],
);
const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
() => overview.value?.assistantTrends ?? [],
);
const distribution = computed<DashboardDistributionItem[]>(
() => overview.value?.distribution ?? [],
);
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
const userRanks = computed<DashboardUserRankItem[]>(
() => 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<SummaryCardItem[]>(() => [
{ 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<AssistantTrendSelectionItem[]>(
() =>
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<AssistantTrendSelectionItem[]>(() =>
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: [primaryColor, successColor],
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: 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(() => {
<div
class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
>
<ElDatePicker
v-model="customDateRange"
class="w-full sm:w-[280px]"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleCustomRangeChange"
/>
<ElRadioGroup
:model-value="filters.range"
size="default"
@@ -392,7 +830,7 @@ onBeforeUnmount(() => {
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="item in 8"
v-for="item in 6"
:key="item"
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
></div>
@@ -436,24 +874,144 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<AnalysisChartCard title="聊天趋势">
<section
class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<AnalysisChartCard title="趋势概览">
<template v-if="chatAvailable">
<div class="space-y-2">
<p class="text-muted-foreground text-sm">
观察所选时间范围内消息数与会话数的趋势变化
</p>
<div class="space-y-4">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="text-muted-foreground text-sm">
{{ trendDescription }}
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<ElPopover
v-if="
isAssistantActiveTrend &&
assistantTrendSelectionItems.length > 0
"
placement="bottom-end"
trigger="click"
:width="340"
>
<template #reference>
<button
type="button"
class="border-border/60 bg-background/88 hover:border-border hover:bg-muted/20 text-foreground inline-flex h-9 max-w-[260px] items-center gap-2 rounded-2xl border px-3.5 text-sm transition-[border-color,background-color,box-shadow] duration-200"
>
<span
v-if="selectedAssistantTrendPreviewItems.length > 0"
class="flex shrink-0 items-center -space-x-1.5"
>
<span
v-for="item in selectedAssistantTrendPreviewItems"
:key="item.assistantKey"
class="border-background inline-block h-2.5 w-2.5 rounded-full border"
:style="{ backgroundColor: item.color }"
></span>
</span>
<span class="min-w-0 flex-1 truncate font-medium">
{{ assistantTrendSelectorSummary }}
</span>
<span
class="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium"
>
{{ assistantTrendSelectorLabel }}
</span>
</button>
</template>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold">智能体</div>
<div class="flex items-center gap-2">
<button
type="button"
class="text-primary text-xs font-medium"
@click="selectAllAssistantTrends"
>
全选
</button>
<button
type="button"
class="text-muted-foreground text-xs font-medium"
@click="clearAssistantTrendSelection"
>
清空
</button>
</div>
</div>
<div class="grid max-h-64 gap-2 overflow-y-auto">
<button
v-for="item in assistantTrendSelectionItems"
:key="item.assistantKey"
type="button"
class="border-border/60 bg-background hover:border-border flex items-center gap-3 rounded-2xl border px-3 py-2 text-left transition-colors"
:class="
item.isSelected
? 'border-primary/40 bg-primary/5'
: 'text-muted-foreground'
"
@click="toggleAssistantTrend(item.assistantKey)"
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:style="{ backgroundColor: item.color }"
></span>
<span
class="min-w-0 flex-1 truncate text-sm font-medium"
>
{{ item.series.label }}
</span>
<span class="text-muted-foreground shrink-0 text-xs">
{{ formatCount(item.series.totalSessionCount) }}
</span>
</button>
</div>
</div>
</ElPopover>
<ElRadioGroup
:model-value="trendMode"
size="small"
@update:model-value="handleTrendModeChange"
>
<ElRadioButton
v-for="item in trendModeOptions"
:key="item.value"
:value="item.value"
:label="item.label"
>
{{ item.label }}
</ElRadioButton>
</ElRadioGroup>
</div>
</div>
<div class="relative min-h-[360px]">
<div
v-if="showAssistantTrendNoData"
class="bg-background/92 absolute inset-0 z-10 flex items-center justify-center"
>
<ElEmpty description="暂无智能体活跃数据" />
</div>
<div
v-else-if="showAssistantTrendEmptySelection"
class="bg-background/92 absolute inset-0 z-10 flex items-center justify-center"
>
<ElEmpty description="请选择智能体" />
</div>
<EchartsUI ref="trendChartRef" height="360px" />
</div>
</div>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
<AnalysisChartCard title="智能体排行">
<AnalysisChartCard title="智能体使用榜">
<template v-if="chatAvailable">
<div v-if="distribution.length" class="space-y-3">
<div v-if="distribution.length > 0" class="space-y-3">
<div
v-for="(item, index) in distribution"
:key="item.key || item.label"
@@ -471,24 +1029,24 @@ onBeforeUnmount(() => {
{{ item.label }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
消息 {{ formatCount(item.messageTotal) }} · 会话
{{ formatCount(item.sessionTotal) }}
用户 {{ formatCount(item.userTotal) }} · 消息
{{ formatCount(item.messageTotal) }}
</div>
</div>
</div>
</div>
<div class="text-right">
<div class="text-foreground text-lg font-semibold">
{{ formatAvg(item.avgMessagePerSession) }}
{{ formatCount(item.sessionTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
平均每会话消息数
会话数 · 人均 {{ formatAvg(item.avgSessionPerUser) }}
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty description="暂无聊天排行数据" />
<ElEmpty description="暂无智能体使用数据" />
</div>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
@@ -496,6 +1054,66 @@ onBeforeUnmount(() => {
</div>
</AnalysisChartCard>
</section>
<section>
<AnalysisChartCard title="用户活跃榜">
<template v-if="chatAvailable">
<div v-if="userRanks.length > 0" class="space-y-3">
<div
v-for="(item, index) in userRanks"
:key="item.userId || item.label"
class="border-border/60 bg-card flex flex-col gap-4 rounded-2xl border px-4 py-4 md:flex-row md:items-center md:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<div
class="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
>
{{ index + 1 }}
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold">
{{ item.label }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
消息 {{ formatCount(item.messageTotal) }}
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.sessionTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">会话数</div>
</div>
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.assistantTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
使用智能体数
</div>
</div>
<div
class="bg-muted/30 col-span-2 rounded-2xl px-4 py-3 text-right md:col-span-1"
>
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.messageTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">消息数</div>
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[220px] items-center justify-center">
<ElEmpty description="暂无用户活跃数据" />
</div>
</template>
<div v-else class="flex min-h-[220px] items-center justify-center">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
</section>
</template>
</div>
</template>