feat: 增强管理端工作台用户活跃榜能力

- 新增用户活跃榜接口、筛选与导出能力

- 支持按智能体过滤排行榜并补充前后端测试
This commit is contained in:
2026-05-10 17:06:22 +08:00
parent 8d07b306e5
commit 516d43ce7d
14 changed files with 868 additions and 126 deletions

View File

@@ -1,15 +1,25 @@
package tech.easyflow.admin.controller.dashboard;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
/**
* 管理端工作台统计接口。
*/
@@ -28,4 +38,26 @@ public class DashboardController {
public Result<DashboardOverviewVo> overview(DashboardOverviewQuery query) {
return Result.ok(dashboardService.getOverview(SaTokenUtil.getLoginAccount(), query));
}
@GetMapping("/user-ranks")
@SaCheckPermission("/api/v1/dashboard/query")
public Result<List<DashboardUserRankItemVo>> userRanks(DashboardUserRankQuery query) {
return Result.ok(dashboardService.getUserRanks(SaTokenUtil.getLoginAccount(), query));
}
@GetMapping(value = "/user-ranks/export", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
@SaCheckPermission("/api/v1/dashboard/query")
public void exportUserRanks(DashboardUserRankQuery query, HttpServletResponse response) throws Exception {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String fileName = URLEncoder.encode("dashboard_user_ranks_" + timestamp, "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
dashboardService.exportUserRanks(
SaTokenUtil.getLoginAccount(),
query,
response.getOutputStream()
);
}
}

View File

@@ -18,8 +18,6 @@ public class DashboardOverviewVo {
private List<DashboardDistributionItemVo> distribution;
private List<DashboardUserRankItemVo> userRanks;
private DashboardOverviewQuery query;
private Date updatedAt;
@@ -64,14 +62,6 @@ public class DashboardOverviewVo {
this.assistantTrends = assistantTrends;
}
public List<DashboardUserRankItemVo> getUserRanks() {
return userRanks;
}
public void setUserRanks(List<DashboardUserRankItemVo> userRanks) {
this.userRanks = userRanks;
}
public DashboardOverviewQuery getQuery() {
return query;
}

View File

@@ -9,14 +9,25 @@ public class DashboardUserRankItemVo {
private BigInteger userId;
/**
* 最终展示名称。
*/
private String label;
/**
* 登录账号。
*/
private String loginName;
/**
* 昵称。
*/
private String nickname;
private Long sessionTotal;
private Long messageTotal;
private Long assistantTotal;
public BigInteger getUserId() {
return userId;
}
@@ -33,6 +44,22 @@ public class DashboardUserRankItemVo {
this.label = label;
}
public String getLoginName() {
return loginName;
}
public void setLoginName(String loginName) {
this.loginName = loginName;
}
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
}
public Long getSessionTotal() {
return sessionTotal;
}
@@ -48,12 +75,4 @@ public class DashboardUserRankItemVo {
public void setMessageTotal(Long messageTotal) {
this.messageTotal = messageTotal;
}
public Long getAssistantTotal() {
return assistantTotal;
}
public void setAssistantTotal(Long assistantTotal) {
this.assistantTotal = assistantTotal;
}
}

View File

@@ -0,0 +1,46 @@
package tech.easyflow.admin.model.dashboard;
import java.math.BigInteger;
/**
* 用户活跃榜查询参数。
*/
public class DashboardUserRankQuery {
private String range;
private String startDate;
private String endDate;
private BigInteger assistantId;
public String getRange() {
return range;
}
public void setRange(String range) {
this.range = range;
}
public String getStartDate() {
return startDate;
}
public void setStartDate(String startDate) {
this.startDate = startDate;
}
public String getEndDate() {
return endDate;
}
public void setEndDate(String endDate) {
this.endDate = endDate;
}
public BigInteger getAssistantId() {
return assistantId;
}
public void setAssistantId(BigInteger assistantId) {
this.assistantId = assistantId;
}
}

View File

@@ -2,12 +2,42 @@ package tech.easyflow.admin.service.dashboard;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.common.entity.LoginAccount;
import java.io.OutputStream;
import java.util.List;
/**
* 工作台统计服务。
*/
public interface DashboardService {
/**
* 获取工作台总览。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @return 工作台总览
*/
DashboardOverviewVo getOverview(LoginAccount loginAccount, DashboardOverviewQuery query);
/**
* 获取用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @return 用户活跃榜
*/
List<DashboardUserRankItemVo> getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query);
/**
* 导出用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @param outputStream 输出流
*/
void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream);
}

View File

@@ -1,11 +1,15 @@
package tech.easyflow.admin.service.dashboard.impl;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.write.style.column.SimpleColumnWidthStyleStrategy;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.row.Db;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
@@ -15,6 +19,7 @@ import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
@@ -25,13 +30,16 @@ import tech.easyflow.chatlog.service.ChatDashboardQueryService;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService;
import tech.easyflow.system.service.SysRoleService;
import javax.annotation.Resource;
import java.io.OutputStream;
import java.math.BigInteger;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -58,6 +66,7 @@ public class DashboardServiceImpl implements DashboardService {
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;
private static final int USER_RANK_EXPORT_COLUMN_WIDTH = 20;
@Resource
private SysAccountRoleService sysAccountRoleService;
@@ -71,6 +80,12 @@ public class DashboardServiceImpl implements DashboardService {
@Resource
private ChatDashboardQueryService chatDashboardQueryService;
@Resource
private BotService botService;
@Resource
private CategoryPermissionService categoryPermissionService;
/**
* 获取工作台总览信息。
*
@@ -90,7 +105,6 @@ public class DashboardServiceImpl implements DashboardService {
result.setTrends(chatPayload.trends);
result.setAssistantTrends(chatPayload.assistantTrends);
result.setDistribution(chatPayload.distribution);
result.setUserRanks(chatPayload.userRanks);
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
normalizedQuery.setRange(context.range);
@@ -101,6 +115,37 @@ public class DashboardServiceImpl implements DashboardService {
return result;
}
/**
* 获取用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @return 用户活跃榜
*/
@Override
public List<DashboardUserRankItemVo> getUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query) {
DashboardQueryContext context = buildContext(loginAccount, query);
return queryUserRanks(context, DEFAULT_USER_RANK_LIMIT);
}
/**
* 导出用户活跃榜。
*
* @param loginAccount 当前登录账号
* @param query 查询条件
* @param outputStream 输出流
*/
@Override
public void exportUserRanks(LoginAccount loginAccount, DashboardUserRankQuery query, OutputStream outputStream) {
DashboardQueryContext context = buildContext(loginAccount, query);
List<DashboardUserRankItemVo> userRanks = queryUserRanks(context, null);
EasyExcel.write(outputStream)
.head(buildUserRankExportHead())
.registerWriteHandler(new SimpleColumnWidthStyleStrategy(USER_RANK_EXPORT_COLUMN_WIDTH))
.sheet("用户活跃榜")
.doWrite(buildUserRankExportRows(userRanks));
}
/**
* 构建顶部汇总卡片。
*
@@ -144,7 +189,6 @@ public class DashboardServiceImpl implements DashboardService {
chatStatus,
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>()
);
}
@@ -176,14 +220,7 @@ public class DashboardServiceImpl implements DashboardService {
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);
return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution);
} catch (Exception ex) {
log.warn("加载工作台聊天统计失败已降级为不可用状态range={}, tenantId={}",
context.range,
@@ -199,7 +236,6 @@ public class DashboardServiceImpl implements DashboardService {
chatStatus,
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>()
);
}
@@ -328,6 +364,27 @@ public class DashboardServiceImpl implements DashboardService {
return seriesList;
}
/**
* 查询用户活跃排行。
*
* @param context 查询上下文
* @param limit 返回条数,空表示全部
* @return 用户活跃排行
*/
private List<DashboardUserRankItemVo> queryUserRanks(DashboardQueryContext context, Integer limit) {
if (!chatDashboardQueryService.available()) {
return new ArrayList<>();
}
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks(
context.startTime.toLocalDate(),
context.endTime.toLocalDate(),
context.tenantFilterId,
context.assistantId,
limit
);
return buildUserRanks(rawUserRanks);
}
/**
* 构建用户活跃排行。
*
@@ -335,20 +392,62 @@ public class DashboardServiceImpl implements DashboardService {
* @return 页面排行项
*/
private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
if (ranks == null || ranks.isEmpty()) {
return new ArrayList<>();
}
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
Map<BigInteger, String> displayNameMap = resolveUserDisplayNameMap(ranks);
Map<BigInteger, AccountIdentitySnapshot> identityMap = resolveAccountIdentityMap(ranks);
for (ChatActiveUserRank rank : ranks) {
ResolvedUserIdentity identity = resolveUserIdentity(
rank.userId(),
rank.userAccount(),
identityMap.get(rank.userId())
);
DashboardUserRankItemVo item = new DashboardUserRankItemVo();
item.setUserId(rank.userId());
item.setLabel(resolveUserLabel(rank.userId(), rank.userAccount(), displayNameMap));
item.setLabel(identity.label);
item.setLoginName(identity.loginName);
item.setNickname(identity.nickname);
item.setSessionTotal(rank.sessionTotal());
item.setMessageTotal(rank.messageTotal());
item.setAssistantTotal(rank.assistantTotal());
items.add(item);
}
return items;
}
/**
* 构建导出表头。
*
* @return 表头
*/
private List<List<String>> buildUserRankExportHead() {
List<List<String>> head = new ArrayList<>(4);
head.add(List.of("登录账号"));
head.add(List.of("昵称"));
head.add(List.of("会话数"));
head.add(List.of("消息数"));
return head;
}
/**
* 构建导出数据行。
*
* @param userRanks 用户排行
* @return 数据行
*/
private List<List<String>> buildUserRankExportRows(List<DashboardUserRankItemVo> userRanks) {
List<List<String>> rows = new ArrayList<>(userRanks.size());
for (DashboardUserRankItemVo item : userRanks) {
List<String> row = new ArrayList<>(4);
row.add(defaultIfBlank(item.getLoginName()));
row.add(defaultIfBlank(item.getNickname()));
row.add(String.valueOf(item.getSessionTotal() == null ? 0L : item.getSessionTotal()));
row.add(String.valueOf(item.getMessageTotal() == null ? 0L : item.getMessageTotal()));
rows.add(row);
}
return rows;
}
/**
* 按租户统计平台资源数量。
*
@@ -442,8 +541,47 @@ public class DashboardServiceImpl implements DashboardService {
* @return 查询上下文
*/
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardOverviewQuery query) {
return buildContext(
loginAccount,
query == null ? null : query.getRange(),
query == null ? null : query.getStartDate(),
query == null ? null : query.getEndDate()
);
}
/**
* 构建用户榜查询上下文。
*
* @param loginAccount 当前登录账号
* @param query 用户榜查询
* @return 查询上下文
*/
private DashboardQueryContext buildContext(LoginAccount loginAccount, DashboardUserRankQuery query) {
DashboardQueryContext context = buildContext(
loginAccount,
query == null ? null : query.getRange(),
query == null ? null : query.getStartDate(),
query == null ? null : query.getEndDate()
);
context.assistantId = validateAssistantId(loginAccount, query == null ? null : query.getAssistantId());
return context;
}
/**
* 构建基础查询上下文。
*
* @param loginAccount 当前登录账号
* @param range 时间范围
* @param startDate 开始日期
* @param endDate 结束日期
* @return 查询上下文
*/
private DashboardQueryContext buildContext(LoginAccount loginAccount,
String range,
String startDate,
String endDate) {
DashboardQueryContext context = new DashboardQueryContext();
context.range = normalizeRange(query == null ? null : query.getRange());
context.range = normalizeRange(range);
context.superAdmin = isSuperAdmin(loginAccount);
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
@@ -463,8 +601,8 @@ public class DashboardServiceImpl implements DashboardService {
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(), "结束日期不能为空");
LocalDate customStartDate = parseRequiredDate(startDate, "开始日期不能为空");
LocalDate customEndDate = parseRequiredDate(endDate, "结束日期不能为空");
if (customStartDate.isAfter(customEndDate)) {
throw new BusinessException("开始日期不能晚于结束日期");
}
@@ -645,7 +783,7 @@ public class DashboardServiceImpl implements DashboardService {
* @param ranks 活跃排行
* @return 名称映射
*/
private Map<BigInteger, String> resolveUserDisplayNameMap(List<ChatActiveUserRank> ranks) {
private Map<BigInteger, AccountIdentitySnapshot> resolveAccountIdentityMap(List<ChatActiveUserRank> ranks) {
List<BigInteger> userIds = ranks.stream()
.map(ChatActiveUserRank::userId)
.filter(java.util.Objects::nonNull)
@@ -654,7 +792,24 @@ public class DashboardServiceImpl implements DashboardService {
if (userIds.isEmpty()) {
return new HashMap<>();
}
return sysAccountService.resolveDisplayNameMap(userIds);
QueryWrapper queryWrapper = QueryWrapper.create()
.select(SysAccount::getId, SysAccount::getLoginName, SysAccount::getNickname)
.in(SysAccount::getId, userIds);
List<SysAccount> accounts = sysAccountService.list(queryWrapper);
Map<BigInteger, AccountIdentitySnapshot> identityMap = new HashMap<>(userIds.size());
if (accounts == null) {
return identityMap;
}
for (SysAccount account : accounts) {
if (account == null || account.getId() == null) {
continue;
}
identityMap.put(
account.getId(),
new AccountIdentitySnapshot(trimToNull(account.getLoginName()), trimToNull(account.getNickname()))
);
}
return identityMap;
}
/**
@@ -676,20 +831,75 @@ public class DashboardServiceImpl implements DashboardService {
*
* @param userId 用户 ID
* @param userAccount 聊天侧账号快照
* @param displayNameMap 系统账号名称映射
* @return 展示名称
* @param snapshot 系统账号快照
* @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;
private ResolvedUserIdentity resolveUserIdentity(BigInteger userId,
String userAccount,
AccountIdentitySnapshot snapshot) {
String loginName = snapshot == null ? null : snapshot.loginName;
String nickname = snapshot == null ? null : snapshot.nickname;
String trimmedUserAccount = trimToNull(userAccount);
if (StringUtils.hasText(loginName)) {
if (StringUtils.hasText(nickname)) {
return new ResolvedUserIdentity(loginName, nickname, loginName + "" + nickname + "");
}
return new ResolvedUserIdentity(loginName, nickname, loginName);
}
if (StringUtils.hasText(userAccount)) {
return userAccount.trim();
if (StringUtils.hasText(trimmedUserAccount)) {
return new ResolvedUserIdentity(trimmedUserAccount, nickname, trimmedUserAccount);
}
return userId == null ? "用户-未知" : "用户-" + userId;
return new ResolvedUserIdentity(null, nickname, userId == null ? "用户-未知" : "用户-" + userId);
}
/**
* 校验智能体筛选条件。
*
* @param loginAccount 当前登录账号
* @param assistantId 智能体 ID
* @return 规范化后的智能体 ID
*/
private BigInteger validateAssistantId(LoginAccount loginAccount, BigInteger assistantId) {
if (assistantId == null) {
return null;
}
Bot bot = botService.getById(assistantId);
if (bot == null || !Integer.valueOf(1).equals(bot.getStatus())) {
throw new BusinessException("聊天助手不存在或未启用");
}
boolean visible = categoryPermissionService.canAccessCategory(
loginAccount,
"BOT",
bot.getCreatedBy(),
bot.getCategoryId()
);
if (!visible) {
throw new BusinessException("聊天助手不存在或未启用");
}
return assistantId;
}
/**
* 归一化文本。
*
* @param value 原始文本
* @return 去空白后的文本
*/
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
/**
* 把空文本转为空串。
*
* @param value 原始文本
* @return 输出文本
*/
private String defaultIfBlank(String value) {
return StringUtils.hasText(value) ? value.trim() : "";
}
/**
@@ -699,6 +909,7 @@ public class DashboardServiceImpl implements DashboardService {
private String range;
private BigInteger tenantFilterId;
private BigInteger deptFilterId;
private BigInteger assistantId;
private boolean superAdmin;
private LocalDateTime startTime;
private LocalDateTime endTime;
@@ -719,6 +930,34 @@ public class DashboardServiceImpl implements DashboardService {
}
}
/**
* 系统账号身份快照。
*/
private static class AccountIdentitySnapshot {
private final String loginName;
private final String nickname;
private AccountIdentitySnapshot(String loginName, String nickname) {
this.loginName = loginName;
this.nickname = nickname;
}
}
/**
* 用户展示身份。
*/
private static class ResolvedUserIdentity {
private final String loginName;
private final String nickname;
private final String label;
private ResolvedUserIdentity(String loginName, String nickname, String label) {
this.loginName = loginName;
this.nickname = nickname;
this.label = label;
}
}
/**
* 聊天统计页面载荷。
*/
@@ -726,8 +965,7 @@ public class DashboardServiceImpl implements DashboardService {
DashboardChatStatusVo chatStatus,
List<DashboardTrendItemVo> trends,
List<DashboardAssistantTrendSeriesVo> assistantTrends,
List<DashboardDistributionItemVo> distribution,
List<DashboardUserRankItemVo> userRanks
List<DashboardDistributionItemVo> distribution
) {
}
}

View File

@@ -1,13 +1,18 @@
package tech.easyflow.admin.service.dashboard.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.service.BotService;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankQuery;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
@@ -15,8 +20,15 @@ import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService;
import tech.easyflow.system.service.SysRoleService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
@@ -25,13 +37,15 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -71,7 +85,7 @@ public class DashboardServiceImplTest {
}
/**
* 验证 today 返回 24 个小时点位,且排行名称与均值回退正确
* 验证 today 返回 24 个小时点位,且 overview 不再触发用户榜查询
*
* @throws Exception 反射调用失败
*/
@@ -91,12 +105,7 @@ public class DashboardServiceImplTest {
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L)));
when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any()))
.thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L)));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 3L, 9L, 1L)));
SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(2), "演示用户"));
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("today", BigInteger.valueOf(9));
DashboardSummaryVo summary = new DashboardSummaryVo();
@@ -107,7 +116,6 @@ public class DashboardServiceImplTest {
List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
Assert.assertEquals(trends.size(), 24);
@@ -132,40 +140,31 @@ public class DashboardServiceImplTest {
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D));
Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户");
Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L));
verify(chatDashboardQueryService, never()).queryActiveUserRanks(any(), any(), any(), any(), any());
}
/**
* 验证当系统账号名称仅回退为纯 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)))
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(), any()))
.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"));
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(9L, "", "仅昵称")));
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("today", BigInteger.ONE);
DashboardSummaryVo summary = new DashboardSummaryVo();
Object payload = invokeBuildChatPayload(service, context, summary);
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
List<DashboardUserRankItemVo> userRanks = service.getUserRanks(new LoginAccount(), query);
Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user");
Assert.assertEquals(userRanks.get(0).getLoginName(), "chat-user");
Assert.assertEquals(userRanks.get(0).getNickname(), "仅昵称");
}
/**
@@ -194,12 +193,7 @@ public class DashboardServiceImplTest {
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();
@@ -238,10 +232,7 @@ public class DashboardServiceImplTest {
.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",
@@ -321,10 +312,7 @@ public class DashboardServiceImplTest {
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();
@@ -344,6 +332,150 @@ public class DashboardServiceImplTest {
verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8));
}
/**
* 验证用户榜筛选会透传 assistantId。
*/
@Test
public void shouldQueryUserRanksWithAssistantFilter() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
BotService botService = mock(BotService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
SysAccountService sysAccountService = mock(SysAccountService.class);
SysAccountRoleService sysAccountRoleService = mock(SysAccountRoleService.class);
SysRoleService sysRoleService = mock(SysRoleService.class);
Bot bot = new Bot();
bot.setId(BigInteger.TEN);
bot.setStatus(1);
bot.setCreatedBy(BigInteger.ONE);
bot.setCategoryId(BigInteger.valueOf(8));
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 2L, 4L, 1L)));
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
.thenReturn(true);
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(2L, "demo-user", "演示用户")));
when(sysAccountRoleService.list(any(QueryWrapper.class))).thenReturn(Collections.emptyList());
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "botService", botService);
setField(service, "categoryPermissionService", categoryPermissionService);
setField(service, "sysAccountService", sysAccountService);
setField(service, "sysAccountRoleService", sysAccountRoleService);
setField(service, "sysRoleService", sysRoleService);
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setId(BigInteger.valueOf(12));
loginAccount.setTenantId(BigInteger.valueOf(33));
List<DashboardUserRankItemVo> userRanks = service.getUserRanks(loginAccount, query);
Assert.assertEquals(userRanks.size(), 1);
Assert.assertEquals(userRanks.get(0).getLabel(), "demo-user演示用户");
verify(chatDashboardQueryService).queryActiveUserRanks(any(), any(), any(), eq(BigInteger.TEN), eq(5));
}
/**
* 验证未启用智能体会被拒绝。
*/
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
public void shouldRejectDisabledAssistantFilter() {
DashboardServiceImpl service = new DashboardServiceImpl();
BotService botService = mock(BotService.class);
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
Bot bot = new Bot();
bot.setId(BigInteger.TEN);
bot.setStatus(0);
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
when(chatDashboardQueryService.available()).thenReturn(true);
setFieldSilently(service, "botService", botService);
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
setFieldSilently(service, "categoryPermissionService", mock(CategoryPermissionService.class));
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
service.getUserRanks(new LoginAccount(), query);
}
/**
* 验证当前作用域不可见的智能体会被拒绝。
*/
@Test(expectedExceptions = BusinessException.class, expectedExceptionsMessageRegExp = "聊天助手不存在或未启用")
public void shouldRejectInvisibleAssistantFilter() {
DashboardServiceImpl service = new DashboardServiceImpl();
BotService botService = mock(BotService.class);
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
CategoryPermissionService categoryPermissionService = mock(CategoryPermissionService.class);
Bot bot = new Bot();
bot.setId(BigInteger.TEN);
bot.setStatus(1);
bot.setCreatedBy(BigInteger.ONE);
bot.setCategoryId(BigInteger.valueOf(8));
when(botService.getById(BigInteger.TEN)).thenReturn(bot);
when(chatDashboardQueryService.available()).thenReturn(true);
when(categoryPermissionService.canAccessCategory(any(LoginAccount.class), eq("BOT"), eq(BigInteger.ONE), eq(BigInteger.valueOf(8))))
.thenReturn(false);
setFieldSilently(service, "botService", botService);
setFieldSilently(service, "chatDashboardQueryService", chatDashboardQueryService);
setFieldSilently(service, "categoryPermissionService", categoryPermissionService);
setFieldSilently(service, "sysAccountService", mock(SysAccountService.class));
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
query.setAssistantId(BigInteger.TEN);
service.getUserRanks(new LoginAccount(), query);
}
/**
* 验证导出结果包含表头与账号昵称列。
*/
@Test
public void shouldExportDashboardUserRanksWithSeparatedIdentityColumns() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
SysAccountService sysAccountService = mock(SysAccountService.class);
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), isNull(), isNull()))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(7), "export-user", 3L, 6L, 1L)));
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(buildSysAccount(7L, "export-user", "导出演示")));
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
DashboardUserRankQuery query = new DashboardUserRankQuery();
query.setRange("7d");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
service.exportUserRanks(new LoginAccount(), query, outputStream);
try (org.apache.poi.ss.usermodel.Workbook workbook =
WorkbookFactory.create(new ByteArrayInputStream(outputStream.toByteArray()))) {
org.apache.poi.ss.usermodel.Sheet sheet = workbook.getSheetAt(0);
Assert.assertEquals(sheet.getRow(0).getCell(0).getStringCellValue(), "登录账号");
Assert.assertEquals(sheet.getRow(0).getCell(1).getStringCellValue(), "昵称");
Assert.assertEquals(sheet.getRow(0).getCell(2).getStringCellValue(), "会话数");
Assert.assertEquals(sheet.getRow(0).getCell(3).getStringCellValue(), "消息数");
Assert.assertEquals(sheet.getRow(1).getCell(0).getStringCellValue(), "export-user");
Assert.assertEquals(sheet.getRow(1).getCell(1).getStringCellValue(), "导出演示");
Assert.assertEquals(sheet.getRow(1).getCell(2).getStringCellValue(), "3");
Assert.assertEquals(sheet.getRow(1).getCell(3).getStringCellValue(), "6");
}
}
/**
* 构造查询上下文。
*
@@ -475,4 +607,20 @@ public class DashboardServiceImplTest {
}
throw new IllegalArgumentException("未找到字段: " + fieldName);
}
private void setFieldSilently(Object target, String fieldName, Object value) {
try {
setField(target, fieldName, value);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private SysAccount buildSysAccount(long id, String loginName, String nickname) {
SysAccount account = new SysAccount();
account.setId(BigInteger.valueOf(id));
account.setLoginName(loginName);
account.setNickname(nickname);
return account;
}
}

View File

@@ -487,15 +487,17 @@ public class ChatAnalyticalDBRepository {
* @param startDate 开始日期,包含
* @param endDate 结束日期,不包含
* @param tenantId 租户 ID空表示全局
* @param assistantId 智能体 ID空表示全部
* @param limit 返回条数
* @return 排行列表
*/
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
BigInteger assistantId,
Integer limit) {
assertAvailable();
int safeLimit = Math.max(limit, 1);
Integer safeLimit = limit == null ? null : Math.max(limit, 1);
List<Object> args = new java.util.ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT agg.user_id AS user_id, ")
@@ -511,6 +513,10 @@ public class ChatAnalyticalDBRepository {
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
args.add(startDate.toString());
args.add(endDate.toString());
if (assistantId != null) {
sql.append(" AND agg.assistant_id = ?");
args.add(assistantId);
}
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
sql.append(" GROUP BY agg.user_id")
.append(") agg ")
@@ -520,9 +526,11 @@ public class ChatAnalyticalDBRepository {
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);
.append("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC ");
if (safeLimit != null) {
sql.append("LIMIT ?");
args.add(safeLimit);
}
return analyticalDBOperations.query(
sql.toString(),

View File

@@ -94,13 +94,15 @@ public interface ChatDashboardQueryService {
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param assistantId 智能体 ID空表示全部
* @param limit 返回条数
* @return 活跃用户排行
*/
List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit);
BigInteger assistantId,
Integer limit);
/**
* 当前分析库是否可用。

View File

@@ -141,6 +141,7 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param assistantId 智能体 ID空表示全部
* @param limit 返回条数
* @return 活跃用户排行
*/
@@ -148,11 +149,12 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
BigInteger assistantId,
Integer limit) {
if (!available()) {
return Collections.emptyList();
}
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit);
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, assistantId, limit);
}
/**

View File

@@ -104,6 +104,28 @@ public class ChatAnalyticalDBRepositoryTest {
Assert.assertTrue(operations.lastQuerySql.contains("(agg.assistant_id IN (?) OR agg.assistant_id IS NULL)"));
}
/**
* 验证用户活跃榜支持按智能体过滤,且导出场景可不带 limit。
*/
@Test
public void shouldSupportAssistantFilterAndUnlimitedUserRanks() {
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
ChatAnalyticalDBRepository repository = newRepository(operations);
repository.queryActiveUserRanks(
LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 8),
BigInteger.ONE,
BigInteger.TEN,
null
);
Assert.assertNotNull(operations.lastQuerySql);
Assert.assertTrue(operations.lastQuerySql.contains("AND agg.assistant_id = ?"));
Assert.assertTrue(operations.lastQuerySql.contains("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC"));
Assert.assertFalse(operations.lastQuerySql.contains("LIMIT ?"));
}
/**
* 构造仓储实例。
*

View File

@@ -1,4 +1,4 @@
import { requestClient } from '#/api/request';
import { api, requestClient } from '#/api/request';
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
@@ -8,6 +8,10 @@ export interface DashboardOverviewQuery {
startDate?: string;
}
export interface DashboardUserRankQuery extends DashboardOverviewQuery {
assistantId?: string;
}
export interface DashboardSummary {
activeUserTotal: number;
activeAssistantTotal: number;
@@ -63,9 +67,10 @@ export interface DashboardDistributionItem {
}
export interface DashboardUserRankItem {
assistantTotal: number;
label: string;
loginName?: string;
messageTotal: number;
nickname?: string;
sessionTotal: number;
userId?: number | string;
}
@@ -78,7 +83,6 @@ export interface DashboardOverviewResponse {
summary: DashboardSummary;
trends: DashboardTrendItem[];
updatedAt: string;
userRanks: DashboardUserRankItem[];
}
export async function getDashboardOverview(params: DashboardOverviewQuery) {
@@ -89,3 +93,29 @@ export async function getDashboardOverview(params: DashboardOverviewQuery) {
},
);
}
export async function getDashboardUserRanks(params: DashboardUserRankQuery) {
return requestClient.get<DashboardUserRankItem[]>(
'/api/v1/dashboard/user-ranks',
{
params,
},
);
}
export async function exportDashboardUserRanks(params: DashboardUserRankQuery) {
return api.download(buildDashboardUrl('/api/v1/dashboard/user-ranks/export', params));
}
function buildDashboardUrl(url: string, params?: Record<string, any>) {
const base =
typeof window === 'undefined' ? 'http://localhost' : window.location.origin;
const target = new URL(url, base);
Object.entries(params || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
target.searchParams.set(key, String(value));
});
return `${target.pathname}${target.search}`;
}

View File

@@ -10,6 +10,7 @@ import type {
DashboardSummary,
DashboardTrendItem,
DashboardUserRankItem,
DashboardUserRankQuery,
} from '#/api/dashboard';
import {
@@ -25,19 +26,26 @@ import {
import { AnalysisChartCard } from '@easyflow/common-ui';
import { EchartsUI, useEcharts } from '@easyflow/plugins/echarts';
import { useUserStore } from '@easyflow/stores';
import { convertToRgb } from '@easyflow/utils';
import { convertToRgb, downloadFileFromBlob } from '@easyflow/utils';
import { RefreshRight } from '@element-plus/icons-vue';
import {
ElButton,
ElDatePicker,
ElEmpty,
ElOption,
ElPopover,
ElRadioButton,
ElRadioGroup,
ElSelect,
} from 'element-plus';
import { getDashboardOverview } from '#/api/dashboard';
import {
exportDashboardUserRanks,
getDashboardOverview,
getDashboardUserRanks,
} from '#/api/dashboard';
import { requestClient } from '#/api/request';
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
@@ -54,7 +62,13 @@ interface SummaryCardItem {
value: string;
}
interface AssistantOptionItem {
label: string;
value: string;
}
let greetingTimer: null | ReturnType<typeof setInterval> = null;
let userRankRequestId = 0;
const userStore = useUserStore();
const now = ref(new Date());
@@ -68,6 +82,15 @@ const isLoading = ref(false);
const errorMessage = ref('');
const trendMode = ref<DashboardTrendMode>('usage');
const selectedAssistantTrendKeys = ref<string[]>([]);
const assistantOptions = ref<AssistantOptionItem[]>([
{ label: '全部智能体', value: '' },
]);
const assistantOptionsLoading = ref(false);
const selectedAssistantId = ref('');
const userRankItems = ref<DashboardUserRankItem[]>([]);
const userRankLoading = ref(false);
const userRankExportLoading = ref(false);
const userRankErrorMessage = ref('');
const trendChartRef = ref<EchartsUIType>();
const {
@@ -110,9 +133,6 @@ const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
const distribution = computed<DashboardDistributionItem[]>(
() => overview.value?.distribution ?? [],
);
const userRanks = computed<DashboardUserRankItem[]>(
() => overview.value?.userRanks ?? [],
);
const chatAvailable = computed(
() => overview.value?.chatStatus?.available !== false,
);
@@ -278,22 +298,30 @@ const showAssistantTrendEmptySelection = computed(
);
const showTrendChart = computed(() => chatAvailable.value);
const showUserRankLoading = computed(
() => userRankLoading.value && userRankItems.value.length === 0,
);
async function loadOverview() {
isLoading.value = true;
errorMessage.value = '';
try {
const data = await getDashboardOverview({
endDate: filters.range === 'custom' ? filters.endDate : undefined,
range: filters.range,
startDate: filters.range === 'custom' ? filters.startDate : undefined,
});
const [data] = await Promise.all([
getDashboardOverview(buildOverviewQuery()),
loadAssistantOptions().catch(() => undefined),
]);
overview.value = data;
resetAssistantTrendSelection();
await renderCharts();
if (data.chatStatus?.available === false) {
resetUserRanks();
} else {
await loadUserRanks();
}
} catch (error) {
overview.value = null;
resetUserRanks();
errorMessage.value =
(error as Error)?.message || '工作台数据加载失败,请稍后重试。';
} finally {
@@ -301,6 +329,110 @@ async function loadOverview() {
}
}
async function loadAssistantOptions() {
assistantOptionsLoading.value = true;
try {
const bots = await requestClient.get<
Array<{ id?: number | string; title?: string }>
>('/api/v1/bot/list', {
params: { status: 1 },
});
const nextOptions: AssistantOptionItem[] = [
{ label: '全部智能体', value: '' },
...(bots || []).map((item) => ({
label: item.title?.trim() || '未命名智能体',
value: item.id === undefined || item.id === null ? '' : String(item.id),
})),
];
assistantOptions.value = nextOptions;
if (!nextOptions.some((item) => item.value === selectedAssistantId.value)) {
selectedAssistantId.value = '';
}
} catch (error) {
assistantOptions.value = [{ label: '全部智能体', value: '' }];
selectedAssistantId.value = '';
throw error;
} finally {
assistantOptionsLoading.value = false;
}
}
async function loadUserRanks() {
if (!overview.value || chatAvailable.value === false) {
resetUserRanks();
return;
}
const currentRequestId = ++userRankRequestId;
userRankLoading.value = true;
userRankErrorMessage.value = '';
userRankItems.value = [];
try {
const data = await getDashboardUserRanks(buildUserRankQuery());
if (currentRequestId !== userRankRequestId) {
return;
}
userRankItems.value = data || [];
} catch (error) {
if (currentRequestId !== userRankRequestId) {
return;
}
userRankItems.value = [];
userRankErrorMessage.value =
(error as Error)?.message || '用户活跃榜加载失败,请稍后重试。';
} finally {
if (currentRequestId === userRankRequestId) {
userRankLoading.value = false;
}
}
}
function resetUserRanks() {
userRankRequestId += 1;
userRankItems.value = [];
userRankLoading.value = false;
userRankErrorMessage.value = '';
}
function buildOverviewQuery(): DashboardOverviewQuery {
return {
endDate: filters.range === 'custom' ? filters.endDate : undefined,
range: filters.range,
startDate: filters.range === 'custom' ? filters.startDate : undefined,
};
}
function buildUserRankQuery(): DashboardUserRankQuery {
return {
...buildOverviewQuery(),
assistantId: selectedAssistantId.value || undefined,
};
}
function handleUserRankAssistantChange(
value: boolean | number | string | undefined,
) {
selectedAssistantId.value =
value === undefined || value === null ? '' : String(value);
void loadUserRanks();
}
async function handleUserRankExport() {
if (userRankExportLoading.value) {
return;
}
userRankExportLoading.value = true;
try {
const blob = await exportDashboardUserRanks(buildUserRankQuery());
downloadFileFromBlob({
fileName: 'dashboard_user_ranks.xlsx',
source: blob,
});
} finally {
userRankExportLoading.value = false;
}
}
async function renderCharts() {
await nextTick();
if (!showTrendChart.value) {
@@ -1057,10 +1189,58 @@ onBeforeUnmount(() => {
<section>
<AnalysisChartCard title="用户活跃榜">
<template #extra v-if="chatAvailable">
<div class="flex w-full items-center justify-end gap-2 sm:w-auto">
<ElSelect
:model-value="selectedAssistantId"
class="w-[220px]"
:clearable="false"
filterable
placeholder="筛选智能体"
:loading="assistantOptionsLoading"
@update:model-value="handleUserRankAssistantChange"
>
<ElOption
v-for="item in assistantOptions"
:key="item.value || item.label"
:label="item.label"
:value="item.value"
/>
</ElSelect>
<ElButton
:loading="userRankExportLoading"
:disabled="userRankLoading"
@click="handleUserRankExport"
>
导出
</ElButton>
</div>
</template>
<template v-if="chatAvailable">
<div v-if="userRanks.length > 0" class="space-y-3">
<div v-if="showUserRankLoading" class="space-y-3">
<div
v-for="(item, index) in userRanks"
v-for="item in 2"
:key="item"
class="border-border/60 bg-muted/25 h-[104px] animate-pulse rounded-2xl border"
></div>
</div>
<div v-else-if="userRankErrorMessage" class="flex min-h-[220px] items-center justify-center">
<ElEmpty description="用户活跃榜加载失败">
<template #default>
<div class="space-y-3 text-center">
<p class="text-muted-foreground text-sm">
{{ userRankErrorMessage }}
</p>
<ElButton type="primary" @click="loadUserRanks">
重新加载
</ElButton>
</div>
</template>
</ElEmpty>
</div>
<div v-else-if="userRankItems.length > 0" class="space-y-3">
<div
v-for="(item, index) in userRankItems"
: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"
>
@@ -1074,28 +1254,17 @@ onBeforeUnmount(() => {
<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="grid grid-cols-2 gap-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"
class="bg-muted/30 rounded-2xl px-4 py-3 text-right"
>
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.messageTotal) }}

View File

@@ -19,8 +19,14 @@ withDefaults(defineProps<Props>(), {});
<template>
<Card>
<CardHeader>
<CardHeader class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<CardTitle class="text-xl">{{ title }}</CardTitle>
<div
v-if="$slots.extra"
class="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto"
>
<slot name="extra"></slot>
</div>
</CardHeader>
<CardContent>
<slot></slot>