feat: 增强管理员工作台聊天统计看板
- 工作台接入聊天消息总数、会话总数、活跃智能体数、趋势与 Top5 排行 - dashboard 接口新增 chatStatus 与聊天统计字段,分析库不可用时明确降级 - today 维度按小时聚合聊天趋势,并补充后端查询与测试覆盖
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
package tech.easyflow.admin.model.dashboard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天统计可用状态。
|
||||||
|
*/
|
||||||
|
public class DashboardChatStatusVo {
|
||||||
|
|
||||||
|
private Boolean available;
|
||||||
|
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
public Boolean getAvailable() {
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvailable(Boolean available) {
|
||||||
|
this.available = available;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package tech.easyflow.admin.model.dashboard;
|
package tech.easyflow.admin.model.dashboard;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作台分布/排行项。
|
* 工作台分布/排行项。
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +23,14 @@ public class DashboardDistributionItemVo {
|
|||||||
|
|
||||||
private Long knowledgeBaseTotal;
|
private Long knowledgeBaseTotal;
|
||||||
|
|
||||||
|
private BigInteger assistantId;
|
||||||
|
|
||||||
|
private Long messageTotal;
|
||||||
|
|
||||||
|
private Long sessionTotal;
|
||||||
|
|
||||||
|
private Double avgMessagePerSession;
|
||||||
|
|
||||||
public String getKey() {
|
public String getKey() {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
@@ -84,4 +94,36 @@ public class DashboardDistributionItemVo {
|
|||||||
public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) {
|
public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) {
|
||||||
this.knowledgeBaseTotal = knowledgeBaseTotal;
|
this.knowledgeBaseTotal = knowledgeBaseTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigInteger getAssistantId() {
|
||||||
|
return assistantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAssistantId(BigInteger assistantId) {
|
||||||
|
this.assistantId = assistantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMessageTotal() {
|
||||||
|
return messageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageTotal(Long messageTotal) {
|
||||||
|
this.messageTotal = messageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSessionTotal() {
|
||||||
|
return sessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionTotal(Long sessionTotal) {
|
||||||
|
this.sessionTotal = sessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getAvgMessagePerSession() {
|
||||||
|
return avgMessagePerSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvgMessagePerSession(Double avgMessagePerSession) {
|
||||||
|
this.avgMessagePerSession = avgMessagePerSession;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public class DashboardOverviewVo {
|
|||||||
|
|
||||||
private DashboardSummaryVo summary;
|
private DashboardSummaryVo summary;
|
||||||
|
|
||||||
|
private DashboardChatStatusVo chatStatus;
|
||||||
|
|
||||||
private List<DashboardTrendItemVo> trends;
|
private List<DashboardTrendItemVo> trends;
|
||||||
|
|
||||||
private List<DashboardDistributionItemVo> distribution;
|
private List<DashboardDistributionItemVo> distribution;
|
||||||
@@ -34,6 +36,14 @@ public class DashboardOverviewVo {
|
|||||||
this.trends = trends;
|
this.trends = trends;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DashboardChatStatusVo getChatStatus() {
|
||||||
|
return chatStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatStatus(DashboardChatStatusVo chatStatus) {
|
||||||
|
this.chatStatus = chatStatus;
|
||||||
|
}
|
||||||
|
|
||||||
public List<DashboardDistributionItemVo> getDistribution() {
|
public List<DashboardDistributionItemVo> getDistribution() {
|
||||||
return distribution;
|
return distribution;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public class DashboardSummaryVo {
|
|||||||
|
|
||||||
private Long knowledgeBaseTotal;
|
private Long knowledgeBaseTotal;
|
||||||
|
|
||||||
|
private Long chatMessageTotal;
|
||||||
|
|
||||||
|
private Long chatSessionTotal;
|
||||||
|
|
||||||
|
private Long activeAssistantTotal;
|
||||||
|
|
||||||
public Long getUserTotal() {
|
public Long getUserTotal() {
|
||||||
return userTotal;
|
return userTotal;
|
||||||
}
|
}
|
||||||
@@ -54,4 +60,28 @@ public class DashboardSummaryVo {
|
|||||||
public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) {
|
public void setKnowledgeBaseTotal(Long knowledgeBaseTotal) {
|
||||||
this.knowledgeBaseTotal = knowledgeBaseTotal;
|
this.knowledgeBaseTotal = knowledgeBaseTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getChatMessageTotal() {
|
||||||
|
return chatMessageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatMessageTotal(Long chatMessageTotal) {
|
||||||
|
this.chatMessageTotal = chatMessageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getChatSessionTotal() {
|
||||||
|
return chatSessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatSessionTotal(Long chatSessionTotal) {
|
||||||
|
this.chatSessionTotal = chatSessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getActiveAssistantTotal() {
|
||||||
|
return activeAssistantTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActiveAssistantTotal(Long activeAssistantTotal) {
|
||||||
|
this.activeAssistantTotal = activeAssistantTotal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ public class DashboardTrendItemVo {
|
|||||||
|
|
||||||
private Long activeUserTotal;
|
private Long activeUserTotal;
|
||||||
|
|
||||||
|
private Long chatMessageTotal;
|
||||||
|
|
||||||
|
private Long chatSessionTotal;
|
||||||
|
|
||||||
public String getKey() {
|
public String getKey() {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
@@ -34,4 +38,20 @@ public class DashboardTrendItemVo {
|
|||||||
public void setActiveUserTotal(Long activeUserTotal) {
|
public void setActiveUserTotal(Long activeUserTotal) {
|
||||||
this.activeUserTotal = activeUserTotal;
|
this.activeUserTotal = activeUserTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getChatMessageTotal() {
|
||||||
|
return chatMessageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatMessageTotal(Long chatMessageTotal) {
|
||||||
|
this.chatMessageTotal = chatMessageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getChatSessionTotal() {
|
||||||
|
return chatSessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatSessionTotal(Long chatSessionTotal) {
|
||||||
|
this.chatSessionTotal = chatSessionTotal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
package tech.easyflow.admin.service.dashboard.impl;
|
package tech.easyflow.admin.service.dashboard.impl;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import com.mybatisflex.core.row.Db;
|
import com.mybatisflex.core.row.Db;
|
||||||
import com.mybatisflex.core.row.Row;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
||||||
import tech.easyflow.admin.model.dashboard.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.service.dashboard.DashboardService;
|
import tech.easyflow.admin.service.dashboard.DashboardService;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
|
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||||
import tech.easyflow.common.constant.Constants;
|
import tech.easyflow.common.constant.Constants;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
@@ -40,24 +45,38 @@ import java.util.stream.Collectors;
|
|||||||
@Service
|
@Service
|
||||||
public class DashboardServiceImpl implements DashboardService {
|
public class DashboardServiceImpl implements DashboardService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DashboardServiceImpl.class);
|
||||||
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 int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountRoleService sysAccountRoleService;
|
private SysAccountRoleService sysAccountRoleService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysRoleService sysRoleService;
|
private SysRoleService sysRoleService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ChatDashboardQueryService chatDashboardQueryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取工作台总览信息。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param query 查询条件
|
||||||
|
* @return 工作台总览
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
public DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
||||||
DashboardQueryContext context = buildContext(loginAccount, query);
|
DashboardQueryContext context = buildContext(loginAccount, query);
|
||||||
|
|
||||||
DashboardSummaryVo summary = buildSummary(context);
|
DashboardSummaryVo summary = buildSummary(context);
|
||||||
List<DashboardTrendItemVo> trends = buildTrends(context);
|
ChatDashboardPayload chatPayload = buildChatPayload(context, summary);
|
||||||
List<DashboardDistributionItemVo> distribution = buildDistribution(context, summary);
|
|
||||||
|
|
||||||
DashboardOverviewVo result = new DashboardOverviewVo();
|
DashboardOverviewVo result = new DashboardOverviewVo();
|
||||||
result.setSummary(summary);
|
result.setSummary(summary);
|
||||||
result.setTrends(trends);
|
result.setChatStatus(chatPayload.chatStatus);
|
||||||
result.setDistribution(distribution);
|
result.setTrends(chatPayload.trends);
|
||||||
|
result.setDistribution(chatPayload.distribution);
|
||||||
|
|
||||||
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
||||||
normalizedQuery.setRange(context.range);
|
normalizedQuery.setRange(context.range);
|
||||||
@@ -66,6 +85,12 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建顶部汇总卡片。
|
||||||
|
*
|
||||||
|
* @param context 查询上下文
|
||||||
|
* @return 汇总结果
|
||||||
|
*/
|
||||||
private DashboardSummaryVo buildSummary(DashboardQueryContext context) {
|
private DashboardSummaryVo buildSummary(DashboardQueryContext context) {
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
|
summary.setUserTotal(countScopedTable("tb_sys_account", "a", true, context));
|
||||||
@@ -73,72 +98,127 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context));
|
summary.setBotTotal(countScopedTable("tb_bot", "b", false, context));
|
||||||
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
|
summary.setWorkflowTotal(countScopedTable("tb_workflow", "w", false, context));
|
||||||
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
|
summary.setKnowledgeBaseTotal(countScopedTable("tb_document_collection", "d", false, context));
|
||||||
|
summary.setChatMessageTotal(0L);
|
||||||
|
summary.setChatSessionTotal(0L);
|
||||||
|
summary.setActiveAssistantTotal(0L);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DashboardTrendItemVo> buildTrends(DashboardQueryContext context) {
|
/**
|
||||||
List<TimeBucket> buckets = buildBuckets(context.range);
|
* 构建聊天统计载荷。
|
||||||
String bucketFormat = "today".equals(context.range) ? "%Y-%m-%d %H:00:00" : "%Y-%m-%d";
|
*
|
||||||
|
* @param context 查询上下文
|
||||||
|
* @param summary 汇总结果
|
||||||
|
* @return 聊天统计载荷
|
||||||
|
*/
|
||||||
|
private ChatDashboardPayload buildChatPayload(DashboardQueryContext context, DashboardSummaryVo summary) {
|
||||||
|
DashboardChatStatusVo chatStatus = new DashboardChatStatusVo();
|
||||||
|
chatStatus.setAvailable(Boolean.TRUE);
|
||||||
|
chatStatus.setMessage("");
|
||||||
|
|
||||||
Map<String, Long> activeUserMap = queryActiveUserTrend(context, bucketFormat);
|
if (!chatDashboardQueryService.available()) {
|
||||||
|
chatStatus.setAvailable(Boolean.FALSE);
|
||||||
|
chatStatus.setMessage(CHAT_UNAVAILABLE_MESSAGE);
|
||||||
|
summary.setChatMessageTotal(0L);
|
||||||
|
summary.setChatSessionTotal(0L);
|
||||||
|
summary.setActiveAssistantTotal(0L);
|
||||||
|
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDate startDate = context.startTime.toLocalDate();
|
||||||
|
LocalDate endDate = context.endTime.toLocalDate();
|
||||||
|
try {
|
||||||
|
ChatDashboardSummary chatSummary = chatDashboardQueryService.querySummary(startDate, endDate, context.tenantFilterId);
|
||||||
|
summary.setChatMessageTotal(chatSummary.messageTotal());
|
||||||
|
summary.setChatSessionTotal(chatSummary.sessionTotal());
|
||||||
|
summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal());
|
||||||
|
|
||||||
|
List<ChatDashboardTrend> rawTrends = "today".equals(context.range)
|
||||||
|
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
|
||||||
|
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
|
||||||
|
List<DashboardTrendItemVo> trends = buildTrendItems(context.range, rawTrends);
|
||||||
|
|
||||||
|
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
context.tenantFilterId,
|
||||||
|
DEFAULT_ASSISTANT_RANK_LIMIT
|
||||||
|
);
|
||||||
|
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(rawRanks);
|
||||||
|
return new ChatDashboardPayload(chatStatus, trends, distribution);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
||||||
|
context.range,
|
||||||
|
context.tenantFilterId,
|
||||||
|
ex);
|
||||||
|
chatStatus.setAvailable(Boolean.FALSE);
|
||||||
|
chatStatus.setMessage(CHAT_UNAVAILABLE_MESSAGE);
|
||||||
|
summary.setChatMessageTotal(0L);
|
||||||
|
summary.setChatSessionTotal(0L);
|
||||||
|
summary.setActiveAssistantTotal(0L);
|
||||||
|
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建聊天趋势项,缺失日期补 0。
|
||||||
|
*
|
||||||
|
* @param range 时间范围
|
||||||
|
* @param rawTrends 原始趋势
|
||||||
|
* @return 趋势项
|
||||||
|
*/
|
||||||
|
private List<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
|
||||||
|
List<TimeBucket> buckets = buildBuckets(range);
|
||||||
|
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
|
||||||
|
for (ChatDashboardTrend rawTrend : rawTrends) {
|
||||||
|
trendMap.put(rawTrend.bucketKey(), rawTrend);
|
||||||
|
}
|
||||||
|
|
||||||
List<DashboardTrendItemVo> items = new ArrayList<>(buckets.size());
|
List<DashboardTrendItemVo> items = new ArrayList<>(buckets.size());
|
||||||
for (TimeBucket bucket : buckets) {
|
for (TimeBucket bucket : buckets) {
|
||||||
long activeUserTotal = activeUserMap.getOrDefault(bucket.key, 0L);
|
ChatDashboardTrend trend = trendMap.get(bucket.key);
|
||||||
|
|
||||||
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(activeUserTotal);
|
item.setActiveUserTotal(0L);
|
||||||
|
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
|
||||||
|
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
||||||
items.add(item);
|
items.add(item);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DashboardDistributionItemVo> buildDistribution(DashboardQueryContext context, DashboardSummaryVo summary) {
|
/**
|
||||||
return buildResourceDistribution(summary);
|
* 构建智能体使用排行。
|
||||||
}
|
*
|
||||||
|
* @param ranks 原始排行数据
|
||||||
private List<DashboardDistributionItemVo> buildResourceDistribution(DashboardSummaryVo summary) {
|
* @return 页面排行项
|
||||||
List<DashboardDistributionItemVo> items = new ArrayList<>();
|
*/
|
||||||
items.add(buildPlatformItem("userTotal", "用户总量", summary.getUserTotal()));
|
private List<DashboardDistributionItemVo> buildAssistantDistribution(List<ChatAssistantUsageRank> ranks) {
|
||||||
items.add(buildPlatformItem("activeUserTotal", "活跃用户", summary.getActiveUserTotal()));
|
List<DashboardDistributionItemVo> items = new ArrayList<>(ranks.size());
|
||||||
items.add(buildPlatformItem("botTotal", "助手数量", summary.getBotTotal()));
|
for (ChatAssistantUsageRank rank : ranks) {
|
||||||
items.add(buildPlatformItem("workflowTotal", "工作流数量", summary.getWorkflowTotal()));
|
DashboardDistributionItemVo item = new DashboardDistributionItemVo();
|
||||||
items.add(buildPlatformItem("knowledgeBaseTotal", "知识库数量", summary.getKnowledgeBaseTotal()));
|
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
|
||||||
|
item.setAssistantId(rank.assistantId());
|
||||||
|
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
|
||||||
|
item.setMessageTotal(rank.messageTotal());
|
||||||
|
item.setSessionTotal(rank.sessionTotal());
|
||||||
|
item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal()));
|
||||||
|
item.setValue(rank.messageTotal());
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DashboardDistributionItemVo buildPlatformItem(String key, String label, Long value) {
|
/**
|
||||||
DashboardDistributionItemVo item = new DashboardDistributionItemVo();
|
* 按租户统计平台资源数量。
|
||||||
item.setKey(key);
|
*
|
||||||
item.setLabel(label);
|
* @param tableName 表名
|
||||||
item.setValue(defaultLong(value));
|
* @param alias 别名
|
||||||
return item;
|
* @param containsLogicDelete 是否包含逻辑删除条件
|
||||||
}
|
* @param context 查询上下文
|
||||||
|
* @return 统计值
|
||||||
private Map<String, Long> queryActiveUserTrend(DashboardQueryContext context, String bucketFormat) {
|
*/
|
||||||
StringBuilder sql = new StringBuilder();
|
|
||||||
List<Object> params = new ArrayList<>();
|
|
||||||
|
|
||||||
sql.append("SELECT DATE_FORMAT(l.created, '").append(bucketFormat).append("') AS bucket_key, ")
|
|
||||||
.append("COUNT(DISTINCT l.account_id) AS total ")
|
|
||||||
.append("FROM tb_sys_log l ")
|
|
||||||
.append("INNER JOIN tb_sys_account a ON a.id = l.account_id AND a.is_deleted IS NULL ")
|
|
||||||
.append("WHERE l.created >= ? AND l.created < ? ");
|
|
||||||
params.add(toDate(context.startTime));
|
|
||||||
params.add(toDate(context.endTime));
|
|
||||||
appendOptionalTenantFilter(sql, params, context.tenantFilterId, "a.tenant_id");
|
|
||||||
appendOptionalDeptFilter(sql, params, context.deptFilterId, "a.dept_id");
|
|
||||||
sql.append("GROUP BY bucket_key ORDER BY bucket_key ASC");
|
|
||||||
|
|
||||||
Map<String, Long> data = new HashMap<>();
|
|
||||||
for (Row row : Db.selectListBySql(sql.toString(), params.toArray())) {
|
|
||||||
data.put(asString(row.get("bucket_key")), asLong(row.get("total")));
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private long countScopedTable(String tableName, String alias, boolean containsLogicDelete, DashboardQueryContext context) {
|
private long countScopedTable(String tableName, String alias, boolean containsLogicDelete, DashboardQueryContext context) {
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
List<Object> params = new ArrayList<>();
|
List<Object> params = new ArrayList<>();
|
||||||
@@ -152,6 +232,12 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return queryForLong(sql.toString(), params);
|
return queryForLong(sql.toString(), params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前时间范围内活跃用户数。
|
||||||
|
*
|
||||||
|
* @param context 查询上下文
|
||||||
|
* @return 活跃用户数
|
||||||
|
*/
|
||||||
private long countActiveUsers(DashboardQueryContext context) {
|
private long countActiveUsers(DashboardQueryContext context) {
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
List<Object> params = new ArrayList<>();
|
List<Object> params = new ArrayList<>();
|
||||||
@@ -167,11 +253,26 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return queryForLong(sql.toString(), params);
|
return queryForLong(sql.toString(), params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 count SQL 并返回 long 值。
|
||||||
|
*
|
||||||
|
* @param sql SQL
|
||||||
|
* @param params 参数
|
||||||
|
* @return long 值
|
||||||
|
*/
|
||||||
private long queryForLong(String sql, List<Object> params) {
|
private long queryForLong(String sql, List<Object> params) {
|
||||||
Object result = Db.selectObject(sql, params.toArray());
|
Object result = Db.selectObject(sql, params.toArray());
|
||||||
return asLong(result);
|
return asLong(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加租户过滤。
|
||||||
|
*
|
||||||
|
* @param sql SQL 构造器
|
||||||
|
* @param params 参数列表
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param columnName 列名
|
||||||
|
*/
|
||||||
private void appendOptionalTenantFilter(StringBuilder sql, List<Object> params, BigInteger tenantId, String columnName) {
|
private void appendOptionalTenantFilter(StringBuilder sql, List<Object> params, BigInteger tenantId, String columnName) {
|
||||||
if (tenantId != null) {
|
if (tenantId != null) {
|
||||||
sql.append(" AND ").append(columnName).append(" = ? ");
|
sql.append(" AND ").append(columnName).append(" = ? ");
|
||||||
@@ -179,6 +280,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加部门过滤。
|
||||||
|
*
|
||||||
|
* @param sql SQL 构造器
|
||||||
|
* @param params 参数列表
|
||||||
|
* @param deptId 部门 ID
|
||||||
|
* @param columnName 列名
|
||||||
|
*/
|
||||||
private void appendOptionalDeptFilter(StringBuilder sql, List<Object> params, BigInteger deptId, String columnName) {
|
private void appendOptionalDeptFilter(StringBuilder sql, List<Object> params, BigInteger deptId, String columnName) {
|
||||||
if (deptId != null) {
|
if (deptId != null) {
|
||||||
sql.append(" AND ").append(columnName).append(" = ? ");
|
sql.append(" AND ").append(columnName).append(" = ? ");
|
||||||
@@ -186,6 +295,13 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建查询上下文。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @param query 查询条件
|
||||||
|
* @return 查询上下文
|
||||||
|
*/
|
||||||
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) {
|
||||||
DashboardQueryContext context = new DashboardQueryContext();
|
DashboardQueryContext context = new DashboardQueryContext();
|
||||||
context.range = normalizeRange(query == null ? null : query.getRange());
|
context.range = normalizeRange(query == null ? null : query.getRange());
|
||||||
@@ -203,11 +319,16 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.tenantFilterId = context.superAdmin ? null : loginAccount.getTenantId();
|
context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId();
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前登录账号是否为超管。
|
||||||
|
*
|
||||||
|
* @param loginAccount 当前登录账号
|
||||||
|
* @return true 表示超管
|
||||||
|
*/
|
||||||
private boolean isSuperAdmin(LoginAccount loginAccount) {
|
private boolean isSuperAdmin(LoginAccount loginAccount) {
|
||||||
if (loginAccount == null || loginAccount.getId() == null) {
|
if (loginAccount == null || loginAccount.getId() == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -228,6 +349,12 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return sysRoleService.count(roleWrapper) > 0;
|
return sysRoleService.count(roleWrapper) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化时间范围参数。
|
||||||
|
*
|
||||||
|
* @param range 原始时间范围
|
||||||
|
* @return 规范化后的时间范围
|
||||||
|
*/
|
||||||
private String normalizeRange(String range) {
|
private String normalizeRange(String range) {
|
||||||
if (!StringUtils.hasText(range)) {
|
if (!StringUtils.hasText(range)) {
|
||||||
return "7d";
|
return "7d";
|
||||||
@@ -238,10 +365,15 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
throw new BusinessException("不支持的时间范围: " + range);
|
throw new BusinessException("不支持的时间范围: " + range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建时间桶。
|
||||||
|
*
|
||||||
|
* @param range 时间范围
|
||||||
|
* @return 时间桶列表
|
||||||
|
*/
|
||||||
private List<TimeBucket> buildBuckets(String range) {
|
private List<TimeBucket> buildBuckets(String range) {
|
||||||
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)) {
|
if ("today".equals(range)) {
|
||||||
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");
|
||||||
@@ -264,18 +396,22 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 LocalDateTime 转换为 Date。
|
||||||
|
*
|
||||||
|
* @param dateTime 时间
|
||||||
|
* @return Date
|
||||||
|
*/
|
||||||
private Date toDate(LocalDateTime dateTime) {
|
private Date toDate(LocalDateTime dateTime) {
|
||||||
return Date.from(dateTime.atZone(DEFAULT_ZONE_ID).toInstant());
|
return Date.from(dateTime.atZone(DEFAULT_ZONE_ID).toInstant());
|
||||||
}
|
}
|
||||||
|
|
||||||
private long defaultLong(Long value) {
|
/**
|
||||||
return value == null ? 0L : value;
|
* 解析对象为 long 值。
|
||||||
}
|
*
|
||||||
|
* @param value 原始对象
|
||||||
private String asString(Object value) {
|
* @return long 值
|
||||||
return value == null ? "" : String.valueOf(value);
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
private long asLong(Object value) {
|
private long asLong(Object value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return 0L;
|
return 0L;
|
||||||
@@ -286,6 +422,37 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return Long.parseLong(String.valueOf(value));
|
return Long.parseLong(String.valueOf(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算平均每会话消息数。
|
||||||
|
*
|
||||||
|
* @param messageTotal 消息总数
|
||||||
|
* @param sessionTotal 会话总数
|
||||||
|
* @return 平均值
|
||||||
|
*/
|
||||||
|
private double calculateAvg(long messageTotal, long sessionTotal) {
|
||||||
|
if (sessionTotal <= 0) {
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
return (double) messageTotal / (double) sessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析智能体展示名称。
|
||||||
|
*
|
||||||
|
* @param assistantId 智能体 ID
|
||||||
|
* @param assistantName 智能体名称
|
||||||
|
* @return 展示名称
|
||||||
|
*/
|
||||||
|
private String resolveAssistantLabel(BigInteger assistantId, String assistantName) {
|
||||||
|
if (StringUtils.hasText(assistantName)) {
|
||||||
|
return assistantName.trim();
|
||||||
|
}
|
||||||
|
return assistantId == null ? "智能体-未知" : "智能体-" + assistantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台查询上下文。
|
||||||
|
*/
|
||||||
private static class DashboardQueryContext {
|
private static class DashboardQueryContext {
|
||||||
private String range;
|
private String range;
|
||||||
private BigInteger tenantFilterId;
|
private BigInteger tenantFilterId;
|
||||||
@@ -295,6 +462,9 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间桶。
|
||||||
|
*/
|
||||||
private static class TimeBucket {
|
private static class TimeBucket {
|
||||||
private final String key;
|
private final String key;
|
||||||
private final String label;
|
private final String label;
|
||||||
@@ -304,4 +474,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
this.label = label;
|
this.label = label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天统计页面载荷。
|
||||||
|
*/
|
||||||
|
private record ChatDashboardPayload(
|
||||||
|
DashboardChatStatusVo chatStatus,
|
||||||
|
List<DashboardTrendItemVo> trends,
|
||||||
|
List<DashboardDistributionItemVo> distribution
|
||||||
|
) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package tech.easyflow.admin.service.dashboard.impl;
|
||||||
|
|
||||||
|
import org.testng.Assert;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
|
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link DashboardServiceImpl} 测试。
|
||||||
|
*/
|
||||||
|
public class DashboardServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证分析库不可用时返回明确不可用状态,且趋势与排行为空。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReturnUnavailableChatPayloadWhenAnalyticalDbIsDisabled() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
|
when(chatDashboardQueryService.available()).thenReturn(false);
|
||||||
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
|
||||||
|
Object context = newContext("7d", null);
|
||||||
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
|
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||||
|
|
||||||
|
Object chatStatus = readField(payload, "chatStatus");
|
||||||
|
List<?> trends = (List<?>) readField(payload, "trends");
|
||||||
|
List<?> distribution = (List<?>) readField(payload, "distribution");
|
||||||
|
|
||||||
|
Assert.assertFalse(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||||
|
Assert.assertEquals(invokeGetter(chatStatus, "getMessage"), "聊天数据不可用");
|
||||||
|
Assert.assertTrue(trends.isEmpty());
|
||||||
|
Assert.assertTrue(distribution.isEmpty());
|
||||||
|
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 today 返回 24 个小时点位,且排行名称与均值回退正确。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldBuildHourlyTrendForToday() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
|
String currentHourKey = LocalDateTime.of(LocalDate.now(), LocalTime.of(10, 0))
|
||||||
|
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
|
||||||
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
|
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||||
|
.thenReturn(new ChatDashboardSummary(3L, 9L, 1L));
|
||||||
|
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
|
||||||
|
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L)));
|
||||||
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 3L, 9L)));
|
||||||
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
|
||||||
|
Object context = newContext("today", BigInteger.valueOf(9));
|
||||||
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
|
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||||
|
|
||||||
|
Object chatStatus = readField(payload, "chatStatus");
|
||||||
|
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
||||||
|
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
||||||
|
|
||||||
|
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||||
|
Assert.assertEquals(trends.size(), 24);
|
||||||
|
Assert.assertEquals(trends.get(0).getLabel(), "00:00");
|
||||||
|
Assert.assertEquals(trends.get(10).getKey(), currentHourKey);
|
||||||
|
Assert.assertEquals(trends.get(10).getLabel(), "10:00");
|
||||||
|
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
|
||||||
|
Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L));
|
||||||
|
Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
|
||||||
|
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
|
||||||
|
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L));
|
||||||
|
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
|
||||||
|
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造查询上下文。
|
||||||
|
*
|
||||||
|
* @param range 时间范围
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @return 查询上下文实例
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private Object newContext(String range, BigInteger tenantId) throws Exception {
|
||||||
|
Class<?> contextClass = Class.forName(
|
||||||
|
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
|
||||||
|
);
|
||||||
|
Constructor<?> constructor = contextClass.getDeclaredConstructor();
|
||||||
|
constructor.setAccessible(true);
|
||||||
|
Object context = constructor.newInstance();
|
||||||
|
setField(context, "range", range);
|
||||||
|
setField(context, "tenantFilterId", tenantId);
|
||||||
|
setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN));
|
||||||
|
setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN));
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用私有聊天载荷组装方法。
|
||||||
|
*
|
||||||
|
* @param service service
|
||||||
|
* @param context 上下文
|
||||||
|
* @param summary 汇总对象
|
||||||
|
* @return 载荷
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private Object invokeBuildChatPayload(DashboardServiceImpl service, Object context, DashboardSummaryVo summary)
|
||||||
|
throws Exception {
|
||||||
|
Method method = DashboardServiceImpl.class.getDeclaredMethod(
|
||||||
|
"buildChatPayload",
|
||||||
|
context.getClass(),
|
||||||
|
DashboardSummaryVo.class
|
||||||
|
);
|
||||||
|
method.setAccessible(true);
|
||||||
|
return method.invoke(service, context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取对象字段。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param fieldName 字段名
|
||||||
|
* @return 字段值
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private Object readField(Object target, String fieldName) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.get(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 getter。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param methodName 方法名
|
||||||
|
* @return 返回值
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private Object invokeGetter(Object target, String methodName) throws Exception {
|
||||||
|
Method method = target.getClass().getMethod(methodName);
|
||||||
|
return method.invoke(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过反射设置字段值。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param fieldName 字段名
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Class<?> current = target.getClass();
|
||||||
|
while (current != null) {
|
||||||
|
try {
|
||||||
|
Field field = current.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
return;
|
||||||
|
} catch (NoSuchFieldException ignored) {
|
||||||
|
current = current.getSuperclass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("未找到字段: " + fieldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package tech.easyflow.chatlog.domain.dto;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天助手使用排行项。
|
||||||
|
*
|
||||||
|
* @param assistantId 智能体 ID
|
||||||
|
* @param assistantName 智能体名称
|
||||||
|
* @param sessionTotal 会话总数
|
||||||
|
* @param messageTotal 消息总数
|
||||||
|
*/
|
||||||
|
public record ChatAssistantUsageRank(BigInteger assistantId,
|
||||||
|
String assistantName,
|
||||||
|
long sessionTotal,
|
||||||
|
long messageTotal) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tech.easyflow.chatlog.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天工作台汇总统计。
|
||||||
|
*
|
||||||
|
* @param sessionTotal 会话总数
|
||||||
|
* @param messageTotal 消息总数
|
||||||
|
* @param activeAssistantTotal 活跃智能体数
|
||||||
|
*/
|
||||||
|
public record ChatDashboardSummary(long sessionTotal, long messageTotal, long activeAssistantTotal) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建空汇总结果。
|
||||||
|
*
|
||||||
|
* @return 空汇总结果
|
||||||
|
*/
|
||||||
|
public static ChatDashboardSummary empty() {
|
||||||
|
return new ChatDashboardSummary(0L, 0L, 0L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package tech.easyflow.chatlog.domain.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天工作台趋势项。
|
||||||
|
*
|
||||||
|
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
||||||
|
* @param sessionTotal 会话总数
|
||||||
|
* @param messageTotal 消息总数
|
||||||
|
*/
|
||||||
|
public record ChatDashboardTrend(String bucketKey, long sessionTotal, long messageTotal) {
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ package tech.easyflow.chatlog.repository.analyticaldb;
|
|||||||
import org.springframework.beans.factory.ObjectProvider;
|
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 tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||||
@@ -19,6 +23,7 @@ import java.math.BigInteger;
|
|||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
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.Collections;
|
import java.util.Collections;
|
||||||
@@ -204,6 +209,160 @@ public class ChatAnalyticalDBRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天工作台汇总统计。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含
|
||||||
|
* @param endDate 结束日期,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天汇总统计
|
||||||
|
*/
|
||||||
|
public ChatDashboardSummary queryDashboardSummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||||
|
assertAvailable();
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("SELECT ")
|
||||||
|
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
||||||
|
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
||||||
|
.append("uniqExact(dimension_id) AS active_assistant_total ")
|
||||||
|
.append("FROM dws_chat_assistant_day ")
|
||||||
|
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
||||||
|
args.add(startDate.toString());
|
||||||
|
args.add(endDate.toString());
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
|
|
||||||
|
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatDashboardSummary(
|
||||||
|
rs.getLong("session_total"),
|
||||||
|
rs.getLong("message_total"),
|
||||||
|
rs.getLong("active_assistant_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
return summary == null ? ChatDashboardSummary.empty() : summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天工作台日趋势。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含
|
||||||
|
* @param endDate 结束日期,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 日趋势列表
|
||||||
|
*/
|
||||||
|
public List<ChatDashboardTrend> queryDashboardTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||||
|
assertAvailable();
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("SELECT toString(stat_date) AS bucket_key, ")
|
||||||
|
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
||||||
|
.append("ifNull(sum(session_count), 0) AS session_total ")
|
||||||
|
.append("FROM dws_chat_assistant_day ")
|
||||||
|
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
||||||
|
args.add(startDate.toString());
|
||||||
|
args.add(endDate.toString());
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
|
sql.append(" GROUP BY stat_date ORDER BY stat_date ASC");
|
||||||
|
|
||||||
|
return analyticalDBOperations.query(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatDashboardTrend(
|
||||||
|
rs.getString("bucket_key"),
|
||||||
|
rs.getLong("session_total"),
|
||||||
|
rs.getLong("message_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天工作台小时趋势。
|
||||||
|
*
|
||||||
|
* @param startTime 开始时间,包含
|
||||||
|
* @param endTime 结束时间,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 小时趋势列表
|
||||||
|
*/
|
||||||
|
public List<ChatDashboardTrend> queryDashboardHourlyTrends(LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime,
|
||||||
|
BigInteger tenantId) {
|
||||||
|
assertAvailable();
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
||||||
|
.append("count() AS message_total, ")
|
||||||
|
.append("uniqExact(l.session_id) AS session_total ")
|
||||||
|
.append("FROM ods_chat_log l ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
|
||||||
|
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
|
||||||
|
args.add(CH_DATE_TIME_FORMATTER.format(startTime));
|
||||||
|
args.add(CH_DATE_TIME_FORMATTER.format(endTime));
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
|
sql.append(" GROUP BY bucket_key ORDER BY bucket_key ASC");
|
||||||
|
|
||||||
|
return analyticalDBOperations.query(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatDashboardTrend(
|
||||||
|
rs.getString("bucket_key"),
|
||||||
|
rs.getLong("session_total"),
|
||||||
|
rs.getLong("message_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天助手使用排行。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含
|
||||||
|
* @param endDate 结束日期,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 排行列表
|
||||||
|
*/
|
||||||
|
public List<ChatAssistantUsageRank> queryAssistantUsageRanks(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.assistant_id, snapshot.assistant_name, agg.session_total, agg.message_total ")
|
||||||
|
.append("FROM (")
|
||||||
|
.append("SELECT dimension_id AS assistant_id, ")
|
||||||
|
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
||||||
|
.append("ifNull(sum(message_count), 0) AS message_total ")
|
||||||
|
.append("FROM dws_chat_assistant_day ")
|
||||||
|
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
||||||
|
args.add(startDate.toString());
|
||||||
|
args.add(endDate.toString());
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
|
sql.append(" GROUP BY dimension_id")
|
||||||
|
.append(") agg ")
|
||||||
|
.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("ORDER BY agg.message_total DESC, agg.session_total DESC, agg.assistant_id ASC ")
|
||||||
|
.append("LIMIT ?");
|
||||||
|
args.add(safeLimit);
|
||||||
|
|
||||||
|
return analyticalDBOperations.query(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatAssistantUsageRank(
|
||||||
|
bigInteger(rs.getObject("assistant_id")),
|
||||||
|
rs.getString("assistant_name"),
|
||||||
|
rs.getLong("session_total"),
|
||||||
|
rs.getLong("message_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public void refreshDws(Set<LocalDate> dates) {
|
public void refreshDws(Set<LocalDate> dates) {
|
||||||
if (!enabled() || dates.isEmpty()) {
|
if (!enabled() || dates.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -212,8 +371,15 @@ public class ChatAnalyticalDBRepository {
|
|||||||
String dateLiteral = date.toString();
|
String dateLiteral = date.toString();
|
||||||
analyticalDBOperations.update("ALTER TABLE dws_chat_assistant_day DELETE WHERE stat_date = toDate(?)", dateLiteral);
|
analyticalDBOperations.update("ALTER TABLE dws_chat_assistant_day DELETE WHERE stat_date = toDate(?)", dateLiteral);
|
||||||
analyticalDBOperations.update("INSERT INTO dws_chat_assistant_day " +
|
analyticalDBOperations.update("INSERT INTO dws_chat_assistant_day " +
|
||||||
"SELECT toDate(created) AS stat_date, assistant_id AS dimension_id, uniqExact(session_id) AS session_count, count() AS message_count " +
|
"SELECT toDate(l.created) AS stat_date, " +
|
||||||
"FROM ods_chat_log WHERE toDate(created) = toDate(?) GROUP BY stat_date, dimension_id", dateLiteral);
|
"l.assistant_id AS dimension_id, " +
|
||||||
|
"ifNull(s.tenant_id, toUInt64(0)) AS tenant_id, " +
|
||||||
|
"uniqExact(l.session_id) AS session_count, " +
|
||||||
|
"count() AS message_count " +
|
||||||
|
"FROM ods_chat_log l " +
|
||||||
|
"LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id " +
|
||||||
|
"WHERE toDate(l.created) = toDate(?) " +
|
||||||
|
"GROUP BY stat_date, dimension_id, tenant_id", dateLiteral);
|
||||||
|
|
||||||
analyticalDBOperations.update("ALTER TABLE dws_chat_user_day DELETE WHERE stat_date = toDate(?)", dateLiteral);
|
analyticalDBOperations.update("ALTER TABLE dws_chat_user_day DELETE WHERE stat_date = toDate(?)", dateLiteral);
|
||||||
analyticalDBOperations.update("INSERT INTO dws_chat_user_day " +
|
analyticalDBOperations.update("INSERT INTO dws_chat_user_day " +
|
||||||
@@ -313,6 +479,22 @@ public class ChatAnalyticalDBRepository {
|
|||||||
return new BigInteger(String.valueOf(value));
|
return new BigInteger(String.valueOf(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加可选租户过滤,超管场景可不传租户。
|
||||||
|
*
|
||||||
|
* @param sql SQL 构造器
|
||||||
|
* @param args 参数列表
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param columnName 列名
|
||||||
|
*/
|
||||||
|
private void appendOptionalTenantFilter(StringBuilder sql, List<Object> args, BigInteger tenantId, String columnName) {
|
||||||
|
if (tenantId == null || !StringUtils.hasText(columnName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sql.append(" AND ").append(columnName).append(" = ?");
|
||||||
|
args.add(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
private void appendSessionFilters(ChatSessionFilterQuery query, StringBuilder sql, List<Object> args) {
|
private void appendSessionFilters(ChatSessionFilterQuery query, StringBuilder sql, List<Object> args) {
|
||||||
if (query == null) {
|
if (query == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package tech.easyflow.chatlog.service;
|
||||||
|
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天工作台统计查询服务。
|
||||||
|
*/
|
||||||
|
public interface ChatDashboardQueryService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天汇总指标。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天汇总指标
|
||||||
|
*/
|
||||||
|
ChatDashboardSummary querySummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天日趋势。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天日趋势
|
||||||
|
*/
|
||||||
|
List<ChatDashboardTrend> queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天小时趋势。
|
||||||
|
*
|
||||||
|
* @param startTime 开始时间,包含
|
||||||
|
* @param endTime 结束时间,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天小时趋势
|
||||||
|
*/
|
||||||
|
List<ChatDashboardTrend> queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体使用排行。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 智能体使用排行
|
||||||
|
*/
|
||||||
|
List<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前分析库是否可用。
|
||||||
|
*
|
||||||
|
* @return true 表示可用
|
||||||
|
*/
|
||||||
|
boolean available();
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package tech.easyflow.chatlog.service.impl;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
|
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
||||||
|
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于分析库的聊天工作台统计查询实现。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService {
|
||||||
|
|
||||||
|
private final ChatAnalyticalDBRepository analyticalDBRepository;
|
||||||
|
|
||||||
|
public ChatDashboardQueryServiceImpl(ChatAnalyticalDBRepository analyticalDBRepository) {
|
||||||
|
this.analyticalDBRepository = analyticalDBRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天汇总指标。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天汇总指标
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ChatDashboardSummary querySummary(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||||
|
if (!available()) {
|
||||||
|
return ChatDashboardSummary.empty();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryDashboardSummary(startDate, endDate, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天日趋势。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天日趋势
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ChatDashboardTrend> queryTrends(LocalDate startDate, LocalDate endDate, BigInteger tenantId) {
|
||||||
|
if (!available()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryDashboardTrends(startDate, endDate, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询聊天小时趋势。
|
||||||
|
*
|
||||||
|
* @param startTime 开始时间,包含
|
||||||
|
* @param endTime 结束时间,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @return 聊天小时趋势
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ChatDashboardTrend> queryHourlyTrends(LocalDateTime startTime, LocalDateTime endTime, BigInteger tenantId) {
|
||||||
|
if (!available()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryDashboardHourlyTrends(startTime, endTime, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体使用排行。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 智能体使用排行
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ChatAssistantUsageRank> queryAssistantUsageRanks(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
int limit) {
|
||||||
|
if (!available()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前分析库是否可用。
|
||||||
|
*
|
||||||
|
* @return true 表示可用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean available() {
|
||||||
|
return analyticalDBRepository.enabled();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
ALTER TABLE dws_chat_assistant_day
|
||||||
|
ADD COLUMN IF NOT EXISTS `tenant_id` UInt64 DEFAULT 0 AFTER `dimension_id`;
|
||||||
|
|
||||||
|
ALTER TABLE dws_chat_assistant_day DELETE WHERE `tenant_id` = 0;
|
||||||
|
|
||||||
|
INSERT INTO dws_chat_assistant_day
|
||||||
|
SELECT
|
||||||
|
toDate(l.created) AS stat_date,
|
||||||
|
l.assistant_id AS dimension_id,
|
||||||
|
ifNull(s.tenant_id, toUInt64(0)) AS tenant_id,
|
||||||
|
uniqExact(l.session_id) AS session_count,
|
||||||
|
count() AS message_count
|
||||||
|
FROM ods_chat_log l
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT id, tenant_id
|
||||||
|
FROM ods_chat_session FINAL
|
||||||
|
) s ON s.id = l.session_id
|
||||||
|
GROUP BY stat_date, dimension_id, tenant_id;
|
||||||
@@ -8,30 +8,45 @@ export interface DashboardOverviewQuery {
|
|||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
|
activeAssistantTotal: number;
|
||||||
botTotal: number;
|
botTotal: number;
|
||||||
|
chatMessageTotal: number;
|
||||||
|
chatSessionTotal: number;
|
||||||
knowledgeBaseTotal: number;
|
knowledgeBaseTotal: number;
|
||||||
userTotal: number;
|
userTotal: number;
|
||||||
workflowTotal: number;
|
workflowTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardChatStatus {
|
||||||
|
available: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardTrendItem {
|
export interface DashboardTrendItem {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
|
chatMessageTotal: number;
|
||||||
|
chatSessionTotal: number;
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardDistributionItem {
|
export interface DashboardDistributionItem {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
|
assistantId?: number | string;
|
||||||
|
avgMessagePerSession?: number;
|
||||||
botTotal: number;
|
botTotal: number;
|
||||||
key: string;
|
key: string;
|
||||||
knowledgeBaseTotal: number;
|
knowledgeBaseTotal: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
messageTotal?: number;
|
||||||
|
sessionTotal?: number;
|
||||||
userTotal: number;
|
userTotal: number;
|
||||||
value: number;
|
value: number;
|
||||||
workflowTotal: number;
|
workflowTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardOverviewResponse {
|
export interface DashboardOverviewResponse {
|
||||||
|
chatStatus: DashboardChatStatus;
|
||||||
distribution: DashboardDistributionItem[];
|
distribution: DashboardDistributionItem[];
|
||||||
query: DashboardOverviewQuery;
|
query: DashboardOverviewQuery;
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DashboardDistributionItem,
|
||||||
DashboardOverviewQuery,
|
DashboardOverviewQuery,
|
||||||
DashboardOverviewResponse,
|
DashboardOverviewResponse,
|
||||||
DashboardRange,
|
DashboardRange,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||||
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
|
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
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, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
|
||||||
@@ -28,6 +30,7 @@ import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
|
|||||||
import { getDashboardOverview } from '#/api/dashboard';
|
import { getDashboardOverview } from '#/api/dashboard';
|
||||||
|
|
||||||
interface SummaryCardItem {
|
interface SummaryCardItem {
|
||||||
|
available?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
@@ -54,27 +57,48 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const emptySummary: DashboardSummary = {
|
const emptySummary: DashboardSummary = {
|
||||||
|
activeAssistantTotal: 0,
|
||||||
activeUserTotal: 0,
|
activeUserTotal: 0,
|
||||||
botTotal: 0,
|
botTotal: 0,
|
||||||
|
chatMessageTotal: 0,
|
||||||
|
chatSessionTotal: 0,
|
||||||
knowledgeBaseTotal: 0,
|
knowledgeBaseTotal: 0,
|
||||||
userTotal: 0,
|
userTotal: 0,
|
||||||
workflowTotal: 0,
|
workflowTotal: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
||||||
const trends = computed<DashboardTrendItem[]>(
|
const trends = computed<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
|
||||||
() => overview.value?.trends ?? [],
|
const distribution = computed<DashboardDistributionItem[]>(
|
||||||
|
() => overview.value?.distribution ?? [],
|
||||||
|
);
|
||||||
|
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
|
||||||
|
const chatStatusMessage = computed(
|
||||||
|
() => overview.value?.chatStatus?.message || '聊天数据不可用',
|
||||||
);
|
);
|
||||||
|
|
||||||
const summaryCards = computed<SummaryCardItem[]>(() => [
|
const summaryCards = computed<SummaryCardItem[]>(() => [
|
||||||
{ label: '用户总量', value: formatCount(summary.value.userTotal) },
|
|
||||||
{ label: '活跃用户', value: formatCount(summary.value.activeUserTotal) },
|
|
||||||
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
|
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
|
||||||
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
|
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
|
||||||
{
|
{
|
||||||
label: '知识库数量',
|
label: '知识库数量',
|
||||||
value: formatCount(summary.value.knowledgeBaseTotal),
|
value: formatCount(summary.value.knowledgeBaseTotal),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
available: chatAvailable.value,
|
||||||
|
label: '聊天消息总数',
|
||||||
|
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: chatAvailable.value,
|
||||||
|
label: '聊天会话总数',
|
||||||
|
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
available: chatAvailable.value,
|
||||||
|
label: '活跃智能体数',
|
||||||
|
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const updatedAtText = computed(() => {
|
const updatedAtText = computed(() => {
|
||||||
@@ -131,15 +155,24 @@ async function loadOverview() {
|
|||||||
|
|
||||||
async function renderCharts() {
|
async function renderCharts() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
if (!chatAvailable.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderTrendChart();
|
renderTrendChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTrendChart() {
|
function renderTrendChart() {
|
||||||
const xAxisData = trends.value.map((item) => item.label);
|
const xAxisData = trends.value.map((item) => item.label);
|
||||||
const activeUserData = trends.value.map((item) => item.activeUserTotal);
|
const messageData = trends.value.map((item) => item.chatMessageTotal);
|
||||||
|
const sessionData = trends.value.map((item) => item.chatSessionTotal);
|
||||||
|
const primaryColor = getChartTokenColor('--primary');
|
||||||
|
const successColor = getChartTokenColor('--success');
|
||||||
|
const axisColor = getChartTokenColor('--border');
|
||||||
|
const tooltipLineColor = getChartTokenColor('--accent');
|
||||||
|
const textColor = getChartTokenColor('--foreground');
|
||||||
|
|
||||||
renderTrendEcharts({
|
renderTrendEcharts({
|
||||||
color: ['hsl(var(--primary))'],
|
color: [primaryColor, successColor],
|
||||||
grid: {
|
grid: {
|
||||||
bottom: 18,
|
bottom: 18,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
@@ -148,21 +181,38 @@ function renderTrendChart() {
|
|||||||
top: 24,
|
top: 24,
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
itemGap: 18,
|
icon: 'circle',
|
||||||
top: 0,
|
itemGap: 24,
|
||||||
|
itemHeight: 10,
|
||||||
|
itemWidth: 10,
|
||||||
|
padding: [4, 12, 8, 12],
|
||||||
|
textStyle: {
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
top: 4,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
axisPointer: {
|
||||||
|
lineStyle: {
|
||||||
|
color: tooltipLineColor,
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
type: 'line',
|
||||||
|
},
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
axisLine: {
|
axisLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: 'hsl(var(--border))',
|
color: axisColor,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
|
boundaryGap: false,
|
||||||
data: xAxisData,
|
data: xAxisData,
|
||||||
type: 'category',
|
type: 'category',
|
||||||
},
|
},
|
||||||
@@ -175,7 +225,7 @@ function renderTrendChart() {
|
|||||||
},
|
},
|
||||||
splitLine: {
|
splitLine: {
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: 'hsl(var(--border))',
|
color: axisColor,
|
||||||
type: 'dashed',
|
type: 'dashed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -183,10 +233,57 @@ function renderTrendChart() {
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
data: activeUserData,
|
data: messageData,
|
||||||
name: '活跃用户',
|
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,
|
smooth: true,
|
||||||
symbolSize: 8,
|
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',
|
type: 'line',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -205,6 +302,28 @@ function formatCount(value?: number) {
|
|||||||
return Number(value || 0).toLocaleString('zh-CN');
|
return Number(value || 0).toLocaleString('zh-CN');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOptionalCount(value: number | undefined, available: boolean) {
|
||||||
|
return available ? formatCount(value) : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartTokenColor(variableName: string) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '#3b82f6';
|
||||||
|
}
|
||||||
|
const value = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(variableName)
|
||||||
|
.trim();
|
||||||
|
return value ? convertToRgb(`hsl(${value})`) : '#3b82f6';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAvg(value?: number) {
|
||||||
|
const safeValue = Number(value || 0);
|
||||||
|
return safeValue.toLocaleString('zh-CN', {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
minimumFractionDigits: safeValue > 0 && safeValue < 10 ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
@@ -270,7 +389,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="isLoading && !overview"
|
v-if="isLoading && !overview"
|
||||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in 8"
|
v-for="item in 8"
|
||||||
@@ -294,7 +413,7 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="item in summaryCards"
|
v-for="item in summaryCards"
|
||||||
:key="item.label"
|
:key="item.label"
|
||||||
@@ -308,16 +427,72 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
{{ item.value }}
|
{{ item.value }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="item.available === false"
|
||||||
|
class="text-muted-foreground mt-2 text-xs"
|
||||||
|
>
|
||||||
|
{{ chatStatusMessage }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||||
<AnalysisChartCard title="趋势变化">
|
<AnalysisChartCard title="聊天趋势">
|
||||||
<div class="space-y-2">
|
<template v-if="chatAvailable">
|
||||||
<p class="text-muted-foreground text-sm">
|
<div class="space-y-2">
|
||||||
观察活跃用户在所选时间范围内的变化趋势。
|
<p class="text-muted-foreground text-sm">
|
||||||
</p>
|
观察所选时间范围内消息数与会话数的趋势变化。
|
||||||
<EchartsUI ref="trendChartRef" height="360px" />
|
</p>
|
||||||
|
<EchartsUI ref="trendChartRef" height="360px" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
|
<ElEmpty :description="chatStatusMessage" />
|
||||||
|
</div>
|
||||||
|
</AnalysisChartCard>
|
||||||
|
|
||||||
|
<AnalysisChartCard title="智能体排行">
|
||||||
|
<template v-if="chatAvailable">
|
||||||
|
<div v-if="distribution.length" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in distribution"
|
||||||
|
:key="item.key || item.label"
|
||||||
|
class="border-border/60 bg-muted/20 flex items-start justify-between rounded-2xl border px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex 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) }} · 会话
|
||||||
|
{{ formatCount(item.sessionTotal) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-foreground text-lg font-semibold">
|
||||||
|
{{ formatAvg(item.avgMessagePerSession) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
|
平均每会话消息数
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
|
<ElEmpty description="暂无聊天排行数据" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
|
<ElEmpty :description="chatStatusMessage" />
|
||||||
</div>
|
</div>
|
||||||
</AnalysisChartCard>
|
</AnalysisChartCard>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user