feat: 增强工作台趋势概览与聊天排行
- 支持用户活跃与智能体活跃趋势统计及自定义时间范围 - 增加用户活跃榜与智能体趋势数据结构及查询实现 - 同步补齐工作台页面展示与定向测试
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ public class DashboardDistributionItemVo {
|
|||||||
|
|
||||||
private Long sessionTotal;
|
private Long sessionTotal;
|
||||||
|
|
||||||
|
private Double avgSessionPerUser;
|
||||||
|
|
||||||
private Double avgMessagePerSession;
|
private Double avgMessagePerSession;
|
||||||
|
|
||||||
public String getKey() {
|
public String getKey() {
|
||||||
@@ -119,6 +121,14 @@ public class DashboardDistributionItemVo {
|
|||||||
this.sessionTotal = sessionTotal;
|
this.sessionTotal = sessionTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Double getAvgSessionPerUser() {
|
||||||
|
return avgSessionPerUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvgSessionPerUser(Double avgSessionPerUser) {
|
||||||
|
this.avgSessionPerUser = avgSessionPerUser;
|
||||||
|
}
|
||||||
|
|
||||||
public Double getAvgMessagePerSession() {
|
public Double getAvgMessagePerSession() {
|
||||||
return avgMessagePerSession;
|
return avgMessagePerSession;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package tech.easyflow.admin.model.dashboard;
|
|||||||
public class DashboardOverviewQuery {
|
public class DashboardOverviewQuery {
|
||||||
|
|
||||||
private String range;
|
private String range;
|
||||||
|
private String startDate;
|
||||||
|
private String endDate;
|
||||||
|
|
||||||
public String getRange() {
|
public String getRange() {
|
||||||
return range;
|
return range;
|
||||||
@@ -14,4 +16,20 @@ public class DashboardOverviewQuery {
|
|||||||
public void setRange(String range) {
|
public void setRange(String range) {
|
||||||
this.range = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ public class DashboardOverviewVo {
|
|||||||
|
|
||||||
private List<DashboardTrendItemVo> trends;
|
private List<DashboardTrendItemVo> trends;
|
||||||
|
|
||||||
|
private List<DashboardAssistantTrendSeriesVo> assistantTrends;
|
||||||
|
|
||||||
private List<DashboardDistributionItemVo> distribution;
|
private List<DashboardDistributionItemVo> distribution;
|
||||||
|
|
||||||
|
private List<DashboardUserRankItemVo> userRanks;
|
||||||
|
|
||||||
private DashboardOverviewQuery query;
|
private DashboardOverviewQuery query;
|
||||||
|
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
@@ -52,6 +56,22 @@ public class DashboardOverviewVo {
|
|||||||
this.distribution = distribution;
|
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() {
|
public DashboardOverviewQuery getQuery() {
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public class DashboardSummaryVo {
|
|||||||
|
|
||||||
private Long activeAssistantTotal;
|
private Long activeAssistantTotal;
|
||||||
|
|
||||||
|
private Long chatActiveUserTotal;
|
||||||
|
|
||||||
public Long getUserTotal() {
|
public Long getUserTotal() {
|
||||||
return userTotal;
|
return userTotal;
|
||||||
}
|
}
|
||||||
@@ -84,4 +86,12 @@ public class DashboardSummaryVo {
|
|||||||
public void setActiveAssistantTotal(Long activeAssistantTotal) {
|
public void setActiveAssistantTotal(Long activeAssistantTotal) {
|
||||||
this.activeAssistantTotal = activeAssistantTotal;
|
this.activeAssistantTotal = activeAssistantTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getChatActiveUserTotal() {
|
||||||
|
return chatActiveUserTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatActiveUserTotal(Long chatActiveUserTotal) {
|
||||||
|
this.chatActiveUserTotal = chatActiveUserTotal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,17 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
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.DashboardDistributionItemVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
|
||||||
import tech.easyflow.admin.service.dashboard.DashboardService;
|
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.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
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.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.system.entity.SysAccountRole;
|
import tech.easyflow.system.entity.SysAccountRole;
|
||||||
import tech.easyflow.system.entity.SysRole;
|
import tech.easyflow.system.entity.SysRole;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
import tech.easyflow.system.service.SysAccountRoleService;
|
import tech.easyflow.system.service.SysAccountRoleService;
|
||||||
import tech.easyflow.system.service.SysRoleService;
|
import tech.easyflow.system.service.SysRoleService;
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
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 ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
|
||||||
private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用";
|
private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用";
|
||||||
private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
|
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
|
@Resource
|
||||||
private SysAccountRoleService sysAccountRoleService;
|
private SysAccountRoleService sysAccountRoleService;
|
||||||
@@ -56,6 +65,9 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
@Resource
|
@Resource
|
||||||
private SysRoleService sysRoleService;
|
private SysRoleService sysRoleService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private SysAccountService sysAccountService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ChatDashboardQueryService chatDashboardQueryService;
|
private ChatDashboardQueryService chatDashboardQueryService;
|
||||||
|
|
||||||
@@ -76,10 +88,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
result.setSummary(summary);
|
result.setSummary(summary);
|
||||||
result.setChatStatus(chatPayload.chatStatus);
|
result.setChatStatus(chatPayload.chatStatus);
|
||||||
result.setTrends(chatPayload.trends);
|
result.setTrends(chatPayload.trends);
|
||||||
|
result.setAssistantTrends(chatPayload.assistantTrends);
|
||||||
result.setDistribution(chatPayload.distribution);
|
result.setDistribution(chatPayload.distribution);
|
||||||
|
result.setUserRanks(chatPayload.userRanks);
|
||||||
|
|
||||||
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
||||||
normalizedQuery.setRange(context.range);
|
normalizedQuery.setRange(context.range);
|
||||||
|
normalizedQuery.setStartDate(context.queryStartDate);
|
||||||
|
normalizedQuery.setEndDate(context.queryEndDate);
|
||||||
result.setQuery(normalizedQuery);
|
result.setQuery(normalizedQuery);
|
||||||
result.setUpdatedAt(new Date());
|
result.setUpdatedAt(new Date());
|
||||||
return result;
|
return result;
|
||||||
@@ -101,6 +117,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(0L);
|
summary.setChatMessageTotal(0L);
|
||||||
summary.setChatSessionTotal(0L);
|
summary.setChatSessionTotal(0L);
|
||||||
summary.setActiveAssistantTotal(0L);
|
summary.setActiveAssistantTotal(0L);
|
||||||
|
summary.setChatActiveUserTotal(0L);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +139,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(0L);
|
summary.setChatMessageTotal(0L);
|
||||||
summary.setChatSessionTotal(0L);
|
summary.setChatSessionTotal(0L);
|
||||||
summary.setActiveAssistantTotal(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();
|
LocalDate startDate = context.startTime.toLocalDate();
|
||||||
@@ -132,20 +156,34 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(chatSummary.messageTotal());
|
summary.setChatMessageTotal(chatSummary.messageTotal());
|
||||||
summary.setChatSessionTotal(chatSummary.sessionTotal());
|
summary.setChatSessionTotal(chatSummary.sessionTotal());
|
||||||
summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal());
|
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.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
|
||||||
: chatDashboardQueryService.queryTrends(startDate, endDate, 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(
|
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
context.tenantFilterId,
|
context.tenantFilterId,
|
||||||
DEFAULT_ASSISTANT_RANK_LIMIT
|
DEFAULT_ASSISTANT_TREND_LIMIT
|
||||||
);
|
);
|
||||||
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(rawRanks);
|
List<DashboardAssistantTrendSeriesVo> assistantTrends = buildAssistantTrendSeries(
|
||||||
return new ChatDashboardPayload(chatStatus, trends, distribution);
|
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) {
|
} catch (Exception ex) {
|
||||||
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
||||||
context.range,
|
context.range,
|
||||||
@@ -156,7 +194,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(0L);
|
summary.setChatMessageTotal(0L);
|
||||||
summary.setChatSessionTotal(0L);
|
summary.setChatSessionTotal(0L);
|
||||||
summary.setActiveAssistantTotal(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 原始趋势
|
* @param rawTrends 原始趋势
|
||||||
* @return 趋势项
|
* @return 趋势项
|
||||||
*/
|
*/
|
||||||
private List<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
|
private List<DashboardTrendItemVo> buildTrendItems(DashboardQueryContext context, List<ChatDashboardTrend> rawTrends) {
|
||||||
List<TimeBucket> buckets = buildBuckets(range);
|
List<TimeBucket> buckets = buildBuckets(
|
||||||
|
context.range,
|
||||||
|
context.startTime.toLocalDate(),
|
||||||
|
context.endTime.toLocalDate().minusDays(1)
|
||||||
|
);
|
||||||
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
|
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
|
||||||
for (ChatDashboardTrend rawTrend : rawTrends) {
|
for (ChatDashboardTrend rawTrend : rawTrends) {
|
||||||
trendMap.put(rawTrend.bucketKey(), rawTrend);
|
trendMap.put(rawTrend.bucketKey(), rawTrend);
|
||||||
@@ -180,7 +229,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
DashboardTrendItemVo item = new DashboardTrendItemVo();
|
DashboardTrendItemVo item = new DashboardTrendItemVo();
|
||||||
item.setKey(bucket.key);
|
item.setKey(bucket.key);
|
||||||
item.setLabel(bucket.label);
|
item.setLabel(bucket.label);
|
||||||
item.setActiveUserTotal(0L);
|
item.setActiveUserTotal(trend == null ? 0L : trend.activeUserTotal());
|
||||||
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
|
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
|
||||||
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
||||||
items.add(item);
|
items.add(item);
|
||||||
@@ -201,10 +250,100 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
|
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
|
||||||
item.setAssistantId(rank.assistantId());
|
item.setAssistantId(rank.assistantId());
|
||||||
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
|
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
|
||||||
|
item.setUserTotal(rank.userTotal());
|
||||||
item.setMessageTotal(rank.messageTotal());
|
item.setMessageTotal(rank.messageTotal());
|
||||||
item.setSessionTotal(rank.sessionTotal());
|
item.setSessionTotal(rank.sessionTotal());
|
||||||
item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), 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);
|
items.add(item);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@@ -311,12 +450,28 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
if ("today".equals(context.range)) {
|
if ("today".equals(context.range)) {
|
||||||
context.startTime = LocalDateTime.of(today, LocalTime.MIN);
|
context.startTime = LocalDateTime.of(today, LocalTime.MIN);
|
||||||
context.endTime = context.startTime.plusDays(1);
|
context.endTime = context.startTime.plusDays(1);
|
||||||
|
context.queryStartDate = today.toString();
|
||||||
|
context.queryEndDate = today.toString();
|
||||||
} else if ("7d".equals(context.range)) {
|
} else if ("7d".equals(context.range)) {
|
||||||
context.startTime = LocalDateTime.of(today.minusDays(6), LocalTime.MIN);
|
context.startTime = LocalDateTime.of(today.minusDays(6), LocalTime.MIN);
|
||||||
context.endTime = LocalDateTime.of(today.plusDays(1), 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.startTime = LocalDateTime.of(today.minusDays(29), LocalTime.MIN);
|
||||||
context.endTime = LocalDateTime.of(today.plusDays(1), 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();
|
context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId();
|
||||||
@@ -359,7 +514,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
if (!StringUtils.hasText(range)) {
|
if (!StringUtils.hasText(range)) {
|
||||||
return "7d";
|
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;
|
return range;
|
||||||
}
|
}
|
||||||
throw new BusinessException("不支持的时间范围: " + range);
|
throw new BusinessException("不支持的时间范围: " + range);
|
||||||
@@ -371,13 +526,19 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
* @param range 时间范围
|
* @param range 时间范围
|
||||||
* @return 时间桶列表
|
* @return 时间桶列表
|
||||||
*/
|
*/
|
||||||
private List<TimeBucket> buildBuckets(String range) {
|
private List<TimeBucket> buildBuckets(String range, LocalDate customStartDate, LocalDate customEndDate) {
|
||||||
List<TimeBucket> buckets = new ArrayList<>();
|
List<TimeBucket> buckets = new ArrayList<>();
|
||||||
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
|
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 keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00");
|
||||||
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH: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++) {
|
for (int hour = 0; hour < 24; hour++) {
|
||||||
LocalDateTime current = start.plusHours(hour);
|
LocalDateTime current = start.plusHours(hour);
|
||||||
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
||||||
@@ -385,10 +546,20 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
int days = "7d".equals(range) ? 7 : 30;
|
|
||||||
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("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++) {
|
for (int i = 0; i < days; i++) {
|
||||||
LocalDate current = start.plusDays(i);
|
LocalDate current = start.plusDays(i);
|
||||||
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
||||||
@@ -396,6 +567,38 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return buckets;
|
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。
|
* 把 LocalDateTime 转换为 Date。
|
||||||
*
|
*
|
||||||
@@ -436,6 +639,24 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return (double) messageTotal / (double) sessionTotal;
|
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;
|
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 boolean superAdmin;
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
|
private String queryStartDate;
|
||||||
|
private String queryEndDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -481,7 +725,9 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
private record ChatDashboardPayload(
|
private record ChatDashboardPayload(
|
||||||
DashboardChatStatusVo chatStatus,
|
DashboardChatStatusVo chatStatus,
|
||||||
List<DashboardTrendItemVo> trends,
|
List<DashboardTrendItemVo> trends,
|
||||||
List<DashboardDistributionItemVo> distribution
|
List<DashboardAssistantTrendSeriesVo> assistantTrends,
|
||||||
|
List<DashboardDistributionItemVo> distribution,
|
||||||
|
List<DashboardUserRankItemVo> userRanks
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ package tech.easyflow.admin.service.dashboard.impl;
|
|||||||
|
|
||||||
import org.testng.Assert;
|
import org.testng.Assert;
|
||||||
import org.testng.annotations.Test;
|
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.DashboardDistributionItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
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.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
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.Constructor;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
@@ -19,9 +26,13 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +67,7 @@ public class DashboardServiceImplTest {
|
|||||||
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
|
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
|
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(summary.getActiveAssistantTotal(), 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"));
|
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
|
||||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
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()))
|
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)))
|
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, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
setField(service, "sysAccountService", sysAccountService);
|
||||||
|
|
||||||
Object context = newContext("today", BigInteger.valueOf(9));
|
Object context = newContext("today", BigInteger.valueOf(9));
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
@@ -85,7 +104,10 @@ public class DashboardServiceImplTest {
|
|||||||
|
|
||||||
Object chatStatus = readField(payload, "chatStatus");
|
Object chatStatus = readField(payload, "chatStatus");
|
||||||
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
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<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
||||||
|
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
|
||||||
|
|
||||||
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||||
Assert.assertEquals(trends.size(), 24);
|
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).getLabel(), "10:00");
|
||||||
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
|
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
|
||||||
Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L));
|
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).getChatMessageTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
|
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
|
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
|
||||||
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
|
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
|
||||||
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L));
|
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).getLabel(), "智能体-1");
|
||||||
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
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 反射失败
|
* @throws Exception 反射失败
|
||||||
*/
|
*/
|
||||||
private Object newContext(String range, BigInteger tenantId) 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(
|
Class<?> contextClass = Class.forName(
|
||||||
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
|
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
|
||||||
);
|
);
|
||||||
@@ -120,11 +383,29 @@ public class DashboardServiceImplTest {
|
|||||||
Object context = constructor.newInstance();
|
Object context = constructor.newInstance();
|
||||||
setField(context, "range", range);
|
setField(context, "range", range);
|
||||||
setField(context, "tenantFilterId", tenantId);
|
setField(context, "tenantFilterId", tenantId);
|
||||||
setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN));
|
setField(context, "startTime", startTime);
|
||||||
setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN));
|
setField(context, "endTime", endTime);
|
||||||
return context;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用私有聊天载荷组装方法。
|
* 调用私有聊天载荷组装方法。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import java.math.BigInteger;
|
|||||||
*
|
*
|
||||||
* @param assistantId 智能体 ID
|
* @param assistantId 智能体 ID
|
||||||
* @param assistantName 智能体名称
|
* @param assistantName 智能体名称
|
||||||
|
* @param userTotal 使用用户数
|
||||||
* @param sessionTotal 会话总数
|
* @param sessionTotal 会话总数
|
||||||
* @param messageTotal 消息总数
|
* @param messageTotal 消息总数
|
||||||
*/
|
*/
|
||||||
public record ChatAssistantUsageRank(BigInteger assistantId,
|
public record ChatAssistantUsageRank(BigInteger assistantId,
|
||||||
String assistantName,
|
String assistantName,
|
||||||
|
long userTotal,
|
||||||
long sessionTotal,
|
long sessionTotal,
|
||||||
long messageTotal) {
|
long messageTotal) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ package tech.easyflow.chatlog.domain.dto;
|
|||||||
* @param sessionTotal 会话总数
|
* @param sessionTotal 会话总数
|
||||||
* @param messageTotal 消息总数
|
* @param messageTotal 消息总数
|
||||||
* @param activeAssistantTotal 活跃智能体数
|
* @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 空汇总结果
|
* @return 空汇总结果
|
||||||
*/
|
*/
|
||||||
public static ChatDashboardSummary empty() {
|
public static ChatDashboardSummary empty() {
|
||||||
return new ChatDashboardSummary(0L, 0L, 0L);
|
return new ChatDashboardSummary(0L, 0L, 0L, 0L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ package tech.easyflow.chatlog.domain.dto;
|
|||||||
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
||||||
* @param sessionTotal 会话总数
|
* @param sessionTotal 会话总数
|
||||||
* @param messageTotal 消息总数
|
* @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) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import org.springframework.beans.factory.ObjectProvider;
|
|||||||
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
|
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.util.StringUtils;
|
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.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -26,6 +28,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -222,21 +225,24 @@ public class ChatAnalyticalDBRepository {
|
|||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT ")
|
sql.append("SELECT ")
|
||||||
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
|
||||||
.append("uniqExact(dimension_id) AS active_assistant_total ")
|
.append("uniqExact(agg.assistant_id) AS active_assistant_total, ")
|
||||||
.append("FROM dws_chat_assistant_day ")
|
.append("uniqExact(agg.user_id) AS active_user_total ")
|
||||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
.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(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
|
|
||||||
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
|
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
|
||||||
sql.toString(),
|
sql.toString(),
|
||||||
(rs, rowNum) -> new ChatDashboardSummary(
|
(rs, rowNum) -> new ChatDashboardSummary(
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total"),
|
rs.getLong("message_total"),
|
||||||
rs.getLong("active_assistant_total")
|
rs.getLong("active_assistant_total"),
|
||||||
|
rs.getLong("active_user_total")
|
||||||
),
|
),
|
||||||
args.toArray()
|
args.toArray()
|
||||||
);
|
);
|
||||||
@@ -255,22 +261,25 @@ public class ChatAnalyticalDBRepository {
|
|||||||
assertAvailable();
|
assertAvailable();
|
||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT toString(stat_date) AS bucket_key, ")
|
sql.append("SELECT toString(agg.stat_date) AS bucket_key, ")
|
||||||
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
.append("ifNull(sum(session_count), 0) AS session_total ")
|
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
|
||||||
.append("FROM dws_chat_assistant_day ")
|
.append("uniqExact(agg.user_id) AS active_user_total ")
|
||||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
.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(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
sql.append(" GROUP BY stat_date ORDER BY stat_date ASC");
|
sql.append(" GROUP BY agg.stat_date ORDER BY agg.stat_date ASC");
|
||||||
|
|
||||||
return analyticalDBOperations.query(
|
return analyticalDBOperations.query(
|
||||||
sql.toString(),
|
sql.toString(),
|
||||||
(rs, rowNum) -> new ChatDashboardTrend(
|
(rs, rowNum) -> new ChatDashboardTrend(
|
||||||
rs.getString("bucket_key"),
|
rs.getString("bucket_key"),
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total")
|
rs.getLong("message_total"),
|
||||||
|
rs.getLong("active_user_total")
|
||||||
),
|
),
|
||||||
args.toArray()
|
args.toArray()
|
||||||
);
|
);
|
||||||
@@ -292,7 +301,8 @@ public class ChatAnalyticalDBRepository {
|
|||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
||||||
.append("count() AS message_total, ")
|
.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("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 (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(?)");
|
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
|
||||||
@@ -306,7 +316,8 @@ public class ChatAnalyticalDBRepository {
|
|||||||
(rs, rowNum) -> new ChatDashboardTrend(
|
(rs, rowNum) -> new ChatDashboardTrend(
|
||||||
rs.getString("bucket_key"),
|
rs.getString("bucket_key"),
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total")
|
rs.getLong("message_total"),
|
||||||
|
rs.getLong("active_user_total")
|
||||||
),
|
),
|
||||||
args.toArray()
|
args.toArray()
|
||||||
);
|
);
|
||||||
@@ -329,17 +340,21 @@ public class ChatAnalyticalDBRepository {
|
|||||||
int safeLimit = Math.max(limit, 1);
|
int safeLimit = Math.max(limit, 1);
|
||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
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("FROM (")
|
||||||
.append("SELECT dimension_id AS assistant_id, ")
|
.append("SELECT agg.assistant_id, ")
|
||||||
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
.append("uniqExact(agg.user_id) AS user_total, ")
|
||||||
.append("ifNull(sum(message_count), 0) AS message_total ")
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
.append("FROM dws_chat_assistant_day ")
|
.append("ifNull(sum(agg.message_count), 0) AS message_total ")
|
||||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
.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(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
sql.append(" GROUP BY dimension_id")
|
sql.append(" GROUP BY agg.assistant_id")
|
||||||
.append(") agg ")
|
.append(") agg ")
|
||||||
.append("LEFT JOIN (")
|
.append("LEFT JOIN (")
|
||||||
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
||||||
@@ -347,7 +362,7 @@ public class ChatAnalyticalDBRepository {
|
|||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
sql.append(" GROUP BY assistant_id")
|
sql.append(" GROUP BY assistant_id")
|
||||||
.append(") snapshot ON snapshot.assistant_id = agg.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 ?");
|
.append("LIMIT ?");
|
||||||
args.add(safeLimit);
|
args.add(safeLimit);
|
||||||
|
|
||||||
@@ -356,6 +371,7 @@ public class ChatAnalyticalDBRepository {
|
|||||||
(rs, rowNum) -> new ChatAssistantUsageRank(
|
(rs, rowNum) -> new ChatAssistantUsageRank(
|
||||||
bigInteger(rs.getObject("assistant_id")),
|
bigInteger(rs.getObject("assistant_id")),
|
||||||
rs.getString("assistant_name"),
|
rs.getString("assistant_name"),
|
||||||
|
rs.getLong("user_total"),
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_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) {
|
public void refreshDws(Set<LocalDate> dates) {
|
||||||
if (!enabled() || dates.isEmpty()) {
|
if (!enabled() || dates.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package tech.easyflow.chatlog.service;
|
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.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -58,6 +60,48 @@ public interface ChatDashboardQueryService {
|
|||||||
BigInteger tenantId,
|
BigInteger tenantId,
|
||||||
int limit);
|
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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前分析库是否可用。
|
* 当前分析库是否可用。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.chatlog.service.impl;
|
package tech.easyflow.chatlog.service.impl;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
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.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -93,6 +95,66 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
|
|||||||
return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前分析库是否可用。
|
* 当前分析库是否可用。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export type DashboardRange = '7d' | '30d' | 'today';
|
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
|
||||||
|
|
||||||
export interface DashboardOverviewQuery {
|
export interface DashboardOverviewQuery {
|
||||||
|
endDate?: string;
|
||||||
range?: DashboardRange;
|
range?: DashboardRange;
|
||||||
|
startDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
activeAssistantTotal: number;
|
activeAssistantTotal: number;
|
||||||
botTotal: number;
|
botTotal: number;
|
||||||
|
chatActiveUserTotal: number;
|
||||||
chatMessageTotal: number;
|
chatMessageTotal: number;
|
||||||
chatSessionTotal: number;
|
chatSessionTotal: number;
|
||||||
knowledgeBaseTotal: number;
|
knowledgeBaseTotal: number;
|
||||||
@@ -30,10 +33,24 @@ export interface DashboardTrendItem {
|
|||||||
label: string;
|
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 {
|
export interface DashboardDistributionItem {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
assistantId?: number | string;
|
assistantId?: number | string;
|
||||||
avgMessagePerSession?: number;
|
avgMessagePerSession?: number;
|
||||||
|
avgSessionPerUser?: number;
|
||||||
botTotal: number;
|
botTotal: number;
|
||||||
key: string;
|
key: string;
|
||||||
knowledgeBaseTotal: number;
|
knowledgeBaseTotal: number;
|
||||||
@@ -45,13 +62,23 @@ export interface DashboardDistributionItem {
|
|||||||
workflowTotal: number;
|
workflowTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserRankItem {
|
||||||
|
assistantTotal: number;
|
||||||
|
label: string;
|
||||||
|
messageTotal: number;
|
||||||
|
sessionTotal: number;
|
||||||
|
userId?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardOverviewResponse {
|
export interface DashboardOverviewResponse {
|
||||||
|
assistantTrends: DashboardAssistantTrendSeries[];
|
||||||
chatStatus: DashboardChatStatus;
|
chatStatus: DashboardChatStatus;
|
||||||
distribution: DashboardDistributionItem[];
|
distribution: DashboardDistributionItem[];
|
||||||
query: DashboardOverviewQuery;
|
query: DashboardOverviewQuery;
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
trends: DashboardTrendItem[];
|
trends: DashboardTrendItem[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
userRanks: DashboardUserRankItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardOverview(params: DashboardOverviewQuery) {
|
export async function getDashboardOverview(params: DashboardOverviewQuery) {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DashboardAssistantTrendSeries,
|
||||||
DashboardDistributionItem,
|
DashboardDistributionItem,
|
||||||
DashboardOverviewQuery,
|
DashboardOverviewQuery,
|
||||||
DashboardOverviewResponse,
|
DashboardOverviewResponse,
|
||||||
DashboardRange,
|
DashboardRange,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
DashboardTrendItem,
|
DashboardTrendItem,
|
||||||
|
DashboardUserRankItem,
|
||||||
} from '#/api/dashboard';
|
} from '#/api/dashboard';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
onMounted,
|
onMounted,
|
||||||
reactive,
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||||
@@ -25,10 +28,26 @@ import { useUserStore } from '@easyflow/stores';
|
|||||||
import { convertToRgb } from '@easyflow/utils';
|
import { convertToRgb } from '@easyflow/utils';
|
||||||
|
|
||||||
import { RefreshRight } from '@element-plus/icons-vue';
|
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';
|
import { getDashboardOverview } from '#/api/dashboard';
|
||||||
|
|
||||||
|
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
|
||||||
|
|
||||||
|
interface AssistantTrendSelectionItem {
|
||||||
|
assistantKey: string;
|
||||||
|
color: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
series: DashboardAssistantTrendSeries;
|
||||||
|
}
|
||||||
|
|
||||||
interface SummaryCardItem {
|
interface SummaryCardItem {
|
||||||
available?: boolean;
|
available?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -39,16 +58,23 @@ let greetingTimer: null | ReturnType<typeof setInterval> = null;
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const now = ref(new Date());
|
const now = ref(new Date());
|
||||||
|
|
||||||
const filters = reactive<Required<DashboardOverviewQuery>>({
|
const filters = reactive<DashboardOverviewQuery>({
|
||||||
range: '7d',
|
range: '7d',
|
||||||
});
|
});
|
||||||
|
const customDateRange = ref<string[]>([]);
|
||||||
|
|
||||||
const overview = ref<DashboardOverviewResponse | null>(null);
|
const overview = ref<DashboardOverviewResponse | null>(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const trendMode = ref<DashboardTrendMode>('usage');
|
||||||
|
const selectedAssistantTrendKeys = ref<string[]>([]);
|
||||||
|
|
||||||
const trendChartRef = ref<EchartsUIType>();
|
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 }> = [
|
const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
||||||
{ label: '今日', value: 'today' },
|
{ label: '今日', value: 'today' },
|
||||||
@@ -56,10 +82,17 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
|||||||
{ label: '近 30 天', value: '30d' },
|
{ label: '近 30 天', value: '30d' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const trendModeOptions: Array<{ label: string; value: DashboardTrendMode }> = [
|
||||||
|
{ label: '使用量趋势', value: 'usage' },
|
||||||
|
{ label: '用户活跃', value: 'userActive' },
|
||||||
|
{ label: '智能体活跃', value: 'assistantActive' },
|
||||||
|
];
|
||||||
|
|
||||||
const emptySummary: DashboardSummary = {
|
const emptySummary: DashboardSummary = {
|
||||||
activeAssistantTotal: 0,
|
activeAssistantTotal: 0,
|
||||||
activeUserTotal: 0,
|
activeUserTotal: 0,
|
||||||
botTotal: 0,
|
botTotal: 0,
|
||||||
|
chatActiveUserTotal: 0,
|
||||||
chatMessageTotal: 0,
|
chatMessageTotal: 0,
|
||||||
chatSessionTotal: 0,
|
chatSessionTotal: 0,
|
||||||
knowledgeBaseTotal: 0,
|
knowledgeBaseTotal: 0,
|
||||||
@@ -68,36 +101,67 @@ const emptySummary: DashboardSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
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[]>(
|
const distribution = computed<DashboardDistributionItem[]>(
|
||||||
() => overview.value?.distribution ?? [],
|
() => 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(
|
const chatStatusMessage = computed(
|
||||||
() => overview.value?.chatStatus?.message || '聊天数据不可用',
|
() => 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[]>(() => [
|
const summaryCards = computed<SummaryCardItem[]>(() => [
|
||||||
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
|
|
||||||
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
|
|
||||||
{
|
{
|
||||||
label: '知识库数量',
|
available: chatAvailable.value,
|
||||||
value: formatCount(summary.value.knowledgeBaseTotal),
|
label: 'AI活跃用户',
|
||||||
|
value: formatOptionalCount(
|
||||||
|
summary.value.chatActiveUserTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
available: chatAvailable.value,
|
available: chatAvailable.value,
|
||||||
label: '聊天消息总数',
|
label: '活跃智能体',
|
||||||
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
|
value: formatOptionalCount(
|
||||||
|
summary.value.activeAssistantTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
available: chatAvailable.value,
|
available: chatAvailable.value,
|
||||||
label: '聊天会话总数',
|
label: '聊天会话总数',
|
||||||
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
|
value: formatOptionalCount(
|
||||||
|
summary.value.chatSessionTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
available: chatAvailable.value,
|
available: chatAvailable.value,
|
||||||
label: '活跃智能体数',
|
label: '聊天消息总数',
|
||||||
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
|
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}`,
|
() => `${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() {
|
async function loadOverview() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getDashboardOverview({
|
const data = await getDashboardOverview({
|
||||||
|
endDate: filters.range === 'custom' ? filters.endDate : undefined,
|
||||||
range: filters.range,
|
range: filters.range,
|
||||||
|
startDate: filters.range === 'custom' ? filters.startDate : undefined,
|
||||||
});
|
});
|
||||||
overview.value = data;
|
overview.value = data;
|
||||||
|
resetAssistantTrendSelection();
|
||||||
await renderCharts();
|
await renderCharts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
overview.value = null;
|
overview.value = null;
|
||||||
@@ -155,24 +303,197 @@ async function loadOverview() {
|
|||||||
|
|
||||||
async function renderCharts() {
|
async function renderCharts() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (!chatAvailable.value) {
|
if (!showTrendChart.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderTrendChart();
|
renderTrendChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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 primaryColor = getChartTokenColor('--primary');
|
||||||
const successColor = getChartTokenColor('--success');
|
const successColor = getChartTokenColor('--success');
|
||||||
|
const warningColor = getChartTokenColor('--warning');
|
||||||
|
const destructiveColor = getChartTokenColor('--destructive');
|
||||||
const axisColor = getChartTokenColor('--border');
|
const axisColor = getChartTokenColor('--border');
|
||||||
const tooltipLineColor = getChartTokenColor('--accent');
|
const tooltipLineColor = getChartTokenColor('--accent');
|
||||||
const textColor = getChartTokenColor('--foreground');
|
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({
|
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: {
|
grid: {
|
||||||
bottom: 18,
|
bottom: 18,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
@@ -231,62 +552,7 @@ function renderTrendChart() {
|
|||||||
},
|
},
|
||||||
type: 'value',
|
type: 'value',
|
||||||
},
|
},
|
||||||
series: [
|
series: isUsageTrend.value ? usageSeries : activeSeries,
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +561,130 @@ function handleRangeChange(value: boolean | number | string | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filters.range = value;
|
filters.range = value;
|
||||||
|
customDateRange.value = [];
|
||||||
|
filters.startDate = undefined;
|
||||||
|
filters.endDate = undefined;
|
||||||
void loadOverview();
|
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) {
|
function formatCount(value?: number) {
|
||||||
return Number(value || 0).toLocaleString('zh-CN');
|
return Number(value || 0).toLocaleString('zh-CN');
|
||||||
}
|
}
|
||||||
@@ -307,13 +694,54 @@ function formatOptionalCount(value: number | undefined, available: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getChartTokenColor(variableName: string) {
|
function getChartTokenColor(variableName: string) {
|
||||||
|
return convertToRgb(`hsl(${getChartTokenHsl(variableName)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartTokenHsl(variableName: string) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return '#3b82f6';
|
return '211 100% 50%';
|
||||||
}
|
}
|
||||||
const value = getComputedStyle(document.documentElement)
|
const value = getComputedStyle(document.documentElement)
|
||||||
.getPropertyValue(variableName)
|
.getPropertyValue(variableName)
|
||||||
.trim();
|
.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) {
|
function formatAvg(value?: number) {
|
||||||
@@ -363,6 +791,16 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
|
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
|
<ElRadioGroup
|
||||||
:model-value="filters.range"
|
:model-value="filters.range"
|
||||||
size="default"
|
size="default"
|
||||||
@@ -392,7 +830,7 @@ onBeforeUnmount(() => {
|
|||||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in 8"
|
v-for="item in 6"
|
||||||
:key="item"
|
:key="item"
|
||||||
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
|
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
|
||||||
></div>
|
></div>
|
||||||
@@ -436,24 +874,144 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
<section
|
||||||
<AnalysisChartCard title="聊天趋势">
|
class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
|
||||||
|
>
|
||||||
|
<AnalysisChartCard title="趋势概览">
|
||||||
<template v-if="chatAvailable">
|
<template v-if="chatAvailable">
|
||||||
<div class="space-y-2">
|
<div class="space-y-4">
|
||||||
<p class="text-muted-foreground text-sm">
|
<div
|
||||||
观察所选时间范围内消息数与会话数的趋势变化。
|
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
</p>
|
>
|
||||||
|
<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" />
|
<EchartsUI ref="trendChartRef" height="360px" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
<ElEmpty :description="chatStatusMessage" />
|
<ElEmpty :description="chatStatusMessage" />
|
||||||
</div>
|
</div>
|
||||||
</AnalysisChartCard>
|
</AnalysisChartCard>
|
||||||
|
|
||||||
<AnalysisChartCard title="智能体排行">
|
<AnalysisChartCard title="智能体使用榜">
|
||||||
<template v-if="chatAvailable">
|
<template v-if="chatAvailable">
|
||||||
<div v-if="distribution.length" class="space-y-3">
|
<div v-if="distribution.length > 0" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in distribution"
|
v-for="(item, index) in distribution"
|
||||||
:key="item.key || item.label"
|
:key="item.key || item.label"
|
||||||
@@ -471,24 +1029,24 @@ onBeforeUnmount(() => {
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground mt-1 text-xs">
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
消息 {{ formatCount(item.messageTotal) }} · 会话
|
用户 {{ formatCount(item.userTotal) }} · 消息
|
||||||
{{ formatCount(item.sessionTotal) }}
|
{{ formatCount(item.messageTotal) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-foreground text-lg font-semibold">
|
<div class="text-foreground text-lg font-semibold">
|
||||||
{{ formatAvg(item.avgMessagePerSession) }}
|
{{ formatCount(item.sessionTotal) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground mt-1 text-xs">
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
平均每会话消息数
|
会话数 · 人均 {{ formatAvg(item.avgSessionPerUser) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
<ElEmpty description="暂无聊天排行数据" />
|
<ElEmpty description="暂无智能体使用数据" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
@@ -496,6 +1054,66 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</AnalysisChartCard>
|
</AnalysisChartCard>
|
||||||
</section>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user