Compare commits

...

3 Commits

Author SHA1 Message Date
8d07b306e5 build: 清理 tinyflow-ui 构建告警
- 收口 Svelte props 响应式引用与 custom element props 声明

- 清理局部未使用样式与类型告警并保持现有业务语义
2026-05-06 19:22:33 +08:00
ba70fec9a5 fix: 优化公开聊天页登录态恢复与鉴权引导
- 支持复用现有登录态并恢复 refresh token

- 未认证访问时补充跳转登录提示与引导文案
2026-05-06 19:22:21 +08:00
31b0e21d3d feat: 增强工作台趋势概览与聊天排行
- 支持用户活跃与智能体活跃趋势统计及自定义时间范围

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

- 同步补齐工作台页面展示与定向测试
2026-05-06 19:22:09 +08:00
57 changed files with 2539 additions and 328 deletions

View File

@@ -0,0 +1,37 @@
package tech.easyflow.admin.model.dashboard;
/**
* 工作台智能体趋势点位。
*/
public class DashboardAssistantTrendPointVo {
private String key;
private String label;
private Long sessionTotal;
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getSessionTotal() {
return sessionTotal;
}
public void setSessionTotal(Long sessionTotal) {
this.sessionTotal = sessionTotal;
}
}

View File

@@ -0,0 +1,50 @@
package tech.easyflow.admin.model.dashboard;
import java.math.BigInteger;
import java.util.List;
/**
* 工作台智能体趋势序列。
*/
public class DashboardAssistantTrendSeriesVo {
private BigInteger assistantId;
private String label;
private Long totalSessionCount;
private List<DashboardAssistantTrendPointVo> points;
public BigInteger getAssistantId() {
return assistantId;
}
public void setAssistantId(BigInteger assistantId) {
this.assistantId = assistantId;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getTotalSessionCount() {
return totalSessionCount;
}
public void setTotalSessionCount(Long totalSessionCount) {
this.totalSessionCount = totalSessionCount;
}
public List<DashboardAssistantTrendPointVo> getPoints() {
return points;
}
public void setPoints(List<DashboardAssistantTrendPointVo> points) {
this.points = points;
}
}

View File

@@ -29,6 +29,8 @@ public class DashboardDistributionItemVo {
private Long sessionTotal;
private Double avgSessionPerUser;
private Double avgMessagePerSession;
public String getKey() {
@@ -119,6 +121,14 @@ public class DashboardDistributionItemVo {
this.sessionTotal = sessionTotal;
}
public Double getAvgSessionPerUser() {
return avgSessionPerUser;
}
public void setAvgSessionPerUser(Double avgSessionPerUser) {
this.avgSessionPerUser = avgSessionPerUser;
}
public Double getAvgMessagePerSession() {
return avgMessagePerSession;
}

View File

@@ -6,6 +6,8 @@ package tech.easyflow.admin.model.dashboard;
public class DashboardOverviewQuery {
private String range;
private String startDate;
private String endDate;
public String getRange() {
return range;
@@ -14,4 +16,20 @@ public class DashboardOverviewQuery {
public void setRange(String range) {
this.range = range;
}
public String getStartDate() {
return startDate;
}
public void setStartDate(String startDate) {
this.startDate = startDate;
}
public String getEndDate() {
return endDate;
}
public void setEndDate(String endDate) {
this.endDate = endDate;
}
}

View File

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

View File

@@ -21,6 +21,8 @@ public class DashboardSummaryVo {
private Long activeAssistantTotal;
private Long chatActiveUserTotal;
public Long getUserTotal() {
return userTotal;
}
@@ -84,4 +86,12 @@ public class DashboardSummaryVo {
public void setActiveAssistantTotal(Long activeAssistantTotal) {
this.activeAssistantTotal = activeAssistantTotal;
}
public Long getChatActiveUserTotal() {
return chatActiveUserTotal;
}
public void setChatActiveUserTotal(Long chatActiveUserTotal) {
this.chatActiveUserTotal = chatActiveUserTotal;
}
}

View File

@@ -0,0 +1,59 @@
package tech.easyflow.admin.model.dashboard;
import java.math.BigInteger;
/**
* 工作台用户活跃排行项。
*/
public class DashboardUserRankItemVo {
private BigInteger userId;
private String label;
private Long sessionTotal;
private Long messageTotal;
private Long assistantTotal;
public BigInteger getUserId() {
return userId;
}
public void setUserId(BigInteger userId) {
this.userId = userId;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getSessionTotal() {
return sessionTotal;
}
public void setSessionTotal(Long sessionTotal) {
this.sessionTotal = sessionTotal;
}
public Long getMessageTotal() {
return messageTotal;
}
public void setMessageTotal(Long messageTotal) {
this.messageTotal = messageTotal;
}
public Long getAssistantTotal() {
return assistantTotal;
}
public void setAssistantTotal(Long assistantTotal) {
this.assistantTotal = assistantTotal;
}
}

View File

@@ -7,12 +7,17 @@ import org.springframework.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.admin.service.dashboard.DashboardService;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
@@ -22,6 +27,7 @@ import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.service.SysAccountRoleService;
import tech.easyflow.system.service.SysRoleService;
@@ -35,6 +41,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -49,6 +56,8 @@ public class DashboardServiceImpl implements DashboardService {
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用";
private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8;
private static final int DEFAULT_USER_RANK_LIMIT = 5;
@Resource
private SysAccountRoleService sysAccountRoleService;
@@ -56,6 +65,9 @@ public class DashboardServiceImpl implements DashboardService {
@Resource
private SysRoleService sysRoleService;
@Resource
private SysAccountService sysAccountService;
@Resource
private ChatDashboardQueryService chatDashboardQueryService;
@@ -76,10 +88,14 @@ public class DashboardServiceImpl implements DashboardService {
result.setSummary(summary);
result.setChatStatus(chatPayload.chatStatus);
result.setTrends(chatPayload.trends);
result.setAssistantTrends(chatPayload.assistantTrends);
result.setDistribution(chatPayload.distribution);
result.setUserRanks(chatPayload.userRanks);
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
normalizedQuery.setRange(context.range);
normalizedQuery.setStartDate(context.queryStartDate);
normalizedQuery.setEndDate(context.queryEndDate);
result.setQuery(normalizedQuery);
result.setUpdatedAt(new Date());
return result;
@@ -101,6 +117,7 @@ public class DashboardServiceImpl implements DashboardService {
summary.setChatMessageTotal(0L);
summary.setChatSessionTotal(0L);
summary.setActiveAssistantTotal(0L);
summary.setChatActiveUserTotal(0L);
return summary;
}
@@ -122,7 +139,14 @@ public class DashboardServiceImpl implements DashboardService {
summary.setChatMessageTotal(0L);
summary.setChatSessionTotal(0L);
summary.setActiveAssistantTotal(0L);
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
summary.setChatActiveUserTotal(0L);
return new ChatDashboardPayload(
chatStatus,
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>()
);
}
LocalDate startDate = context.startTime.toLocalDate();
@@ -132,20 +156,34 @@ public class DashboardServiceImpl implements DashboardService {
summary.setChatMessageTotal(chatSummary.messageTotal());
summary.setChatSessionTotal(chatSummary.sessionTotal());
summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal());
summary.setChatActiveUserTotal(chatSummary.chatActiveUserTotal());
List<ChatDashboardTrend> rawTrends = "today".equals(context.range)
List<ChatDashboardTrend> rawTrends = useHourlyBuckets(context)
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
List<DashboardTrendItemVo> trends = buildTrendItems(context.range, rawTrends);
List<DashboardTrendItemVo> trends = buildTrendItems(context, rawTrends);
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
startDate,
endDate,
context.tenantFilterId,
DEFAULT_ASSISTANT_RANK_LIMIT
DEFAULT_ASSISTANT_TREND_LIMIT
);
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(rawRanks);
return new ChatDashboardPayload(chatStatus, trends, distribution);
List<DashboardAssistantTrendSeriesVo> assistantTrends = buildAssistantTrendSeries(
context,
rawRanks
);
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(
rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size()))
);
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks(
startDate,
endDate,
context.tenantFilterId,
DEFAULT_USER_RANK_LIMIT
);
List<DashboardUserRankItemVo> userRanks = buildUserRanks(rawUserRanks);
return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution, userRanks);
} catch (Exception ex) {
log.warn("加载工作台聊天统计失败已降级为不可用状态range={}, tenantId={}",
context.range,
@@ -156,7 +194,14 @@ public class DashboardServiceImpl implements DashboardService {
summary.setChatMessageTotal(0L);
summary.setChatSessionTotal(0L);
summary.setActiveAssistantTotal(0L);
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
summary.setChatActiveUserTotal(0L);
return new ChatDashboardPayload(
chatStatus,
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>()
);
}
}
@@ -167,8 +212,12 @@ public class DashboardServiceImpl implements DashboardService {
* @param rawTrends 原始趋势
* @return 趋势项
*/
private List<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
List<TimeBucket> buckets = buildBuckets(range);
private List<DashboardTrendItemVo> buildTrendItems(DashboardQueryContext context, List<ChatDashboardTrend> rawTrends) {
List<TimeBucket> buckets = buildBuckets(
context.range,
context.startTime.toLocalDate(),
context.endTime.toLocalDate().minusDays(1)
);
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
for (ChatDashboardTrend rawTrend : rawTrends) {
trendMap.put(rawTrend.bucketKey(), rawTrend);
@@ -180,7 +229,7 @@ public class DashboardServiceImpl implements DashboardService {
DashboardTrendItemVo item = new DashboardTrendItemVo();
item.setKey(bucket.key);
item.setLabel(bucket.label);
item.setActiveUserTotal(0L);
item.setActiveUserTotal(trend == null ? 0L : trend.activeUserTotal());
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
items.add(item);
@@ -201,10 +250,100 @@ public class DashboardServiceImpl implements DashboardService {
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
item.setAssistantId(rank.assistantId());
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
item.setUserTotal(rank.userTotal());
item.setMessageTotal(rank.messageTotal());
item.setSessionTotal(rank.sessionTotal());
item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal()));
item.setValue(rank.messageTotal());
item.setAvgSessionPerUser(calculateAvg(rank.sessionTotal(), rank.userTotal()));
item.setValue(rank.sessionTotal());
items.add(item);
}
return items;
}
/**
* 构建智能体活跃趋势序列。
*
* @param context 查询上下文
* @param ranks 智能体排行
* @return 趋势序列
*/
private List<DashboardAssistantTrendSeriesVo> buildAssistantTrendSeries(DashboardQueryContext context,
List<ChatAssistantUsageRank> ranks) {
if (ranks == null || ranks.isEmpty()) {
return new ArrayList<>();
}
List<TimeBucket> buckets = buildBuckets(
context.range,
context.startTime.toLocalDate(),
context.endTime.toLocalDate().minusDays(1)
);
Map<BigInteger, ChatAssistantUsageRank> rankMap = new LinkedHashMap<>();
for (ChatAssistantUsageRank rank : ranks) {
rankMap.putIfAbsent(rank.assistantId(), rank);
}
List<BigInteger> assistantIds = new ArrayList<>(rankMap.keySet());
List<ChatAssistantSessionTrend> rawAssistantTrends = useHourlyBuckets(context)
? chatDashboardQueryService.queryAssistantHourlyTrends(
context.startTime,
context.endTime,
context.tenantFilterId,
assistantIds
)
: chatDashboardQueryService.queryAssistantTrends(
context.startTime.toLocalDate(),
context.endTime.toLocalDate(),
context.tenantFilterId,
assistantIds
);
Map<BigInteger, Map<String, ChatAssistantSessionTrend>> trendMap = new HashMap<>();
for (ChatAssistantSessionTrend rawTrend : rawAssistantTrends) {
trendMap.computeIfAbsent(rawTrend.assistantId(), key -> new HashMap<>())
.put(rawTrend.bucketKey(), rawTrend);
}
List<DashboardAssistantTrendSeriesVo> seriesList = new ArrayList<>(rankMap.size());
for (ChatAssistantUsageRank rank : rankMap.values()) {
BigInteger assistantId = rank.assistantId();
DashboardAssistantTrendSeriesVo series = new DashboardAssistantTrendSeriesVo();
series.setAssistantId(assistantId);
series.setLabel(resolveAssistantLabel(assistantId, rank.assistantName()));
series.setTotalSessionCount(rank.sessionTotal());
List<DashboardAssistantTrendPointVo> points = new ArrayList<>(buckets.size());
Map<String, ChatAssistantSessionTrend> assistantTrendMap =
trendMap.getOrDefault(assistantId, new HashMap<>());
for (TimeBucket bucket : buckets) {
ChatAssistantSessionTrend trend = assistantTrendMap.get(bucket.key);
DashboardAssistantTrendPointVo point = new DashboardAssistantTrendPointVo();
point.setKey(bucket.key);
point.setLabel(bucket.label);
point.setSessionTotal(trend == null ? 0L : trend.sessionTotal());
points.add(point);
}
series.setPoints(points);
seriesList.add(series);
}
return seriesList;
}
/**
* 构建用户活跃排行。
*
* @param ranks 原始排行数据
* @return 页面排行项
*/
private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
Map<BigInteger, String> displayNameMap = resolveUserDisplayNameMap(ranks);
for (ChatActiveUserRank rank : ranks) {
DashboardUserRankItemVo item = new DashboardUserRankItemVo();
item.setUserId(rank.userId());
item.setLabel(resolveUserLabel(rank.userId(), rank.userAccount(), displayNameMap));
item.setSessionTotal(rank.sessionTotal());
item.setMessageTotal(rank.messageTotal());
item.setAssistantTotal(rank.assistantTotal());
items.add(item);
}
return items;
@@ -311,12 +450,28 @@ public class DashboardServiceImpl implements DashboardService {
if ("today".equals(context.range)) {
context.startTime = LocalDateTime.of(today, LocalTime.MIN);
context.endTime = context.startTime.plusDays(1);
context.queryStartDate = today.toString();
context.queryEndDate = today.toString();
} else if ("7d".equals(context.range)) {
context.startTime = LocalDateTime.of(today.minusDays(6), LocalTime.MIN);
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
} else {
context.queryStartDate = today.minusDays(6).toString();
context.queryEndDate = today.toString();
} else if ("30d".equals(context.range)) {
context.startTime = LocalDateTime.of(today.minusDays(29), LocalTime.MIN);
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
context.queryStartDate = today.minusDays(29).toString();
context.queryEndDate = today.toString();
} else {
LocalDate customStartDate = parseRequiredDate(query == null ? null : query.getStartDate(), "开始日期不能为空");
LocalDate customEndDate = parseRequiredDate(query == null ? null : query.getEndDate(), "结束日期不能为空");
if (customStartDate.isAfter(customEndDate)) {
throw new BusinessException("开始日期不能晚于结束日期");
}
context.startTime = LocalDateTime.of(customStartDate, LocalTime.MIN);
context.endTime = LocalDateTime.of(customEndDate.plusDays(1), LocalTime.MIN);
context.queryStartDate = customStartDate.toString();
context.queryEndDate = customEndDate.toString();
}
context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId();
@@ -359,7 +514,7 @@ public class DashboardServiceImpl implements DashboardService {
if (!StringUtils.hasText(range)) {
return "7d";
}
if ("today".equals(range) || "7d".equals(range) || "30d".equals(range)) {
if ("today".equals(range) || "7d".equals(range) || "30d".equals(range) || "custom".equals(range)) {
return range;
}
throw new BusinessException("不支持的时间范围: " + range);
@@ -371,13 +526,19 @@ public class DashboardServiceImpl implements DashboardService {
* @param range 时间范围
* @return 时间桶列表
*/
private List<TimeBucket> buildBuckets(String range) {
private List<TimeBucket> buildBuckets(String range, LocalDate customStartDate, LocalDate customEndDate) {
List<TimeBucket> buckets = new ArrayList<>();
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
if ("today".equals(range)) {
boolean hourlyBucket = "today".equals(range)
|| ("custom".equals(range)
&& customStartDate != null
&& customEndDate != null
&& customStartDate.equals(customEndDate));
if (hourlyBucket) {
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00");
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00");
LocalDateTime start = LocalDateTime.of(today, LocalTime.MIN);
LocalDate bucketDate = "today".equals(range) ? today : customStartDate;
LocalDateTime start = LocalDateTime.of(bucketDate, LocalTime.MIN);
for (int hour = 0; hour < 24; hour++) {
LocalDateTime current = start.plusHours(hour);
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
@@ -385,10 +546,20 @@ public class DashboardServiceImpl implements DashboardService {
return buckets;
}
int days = "7d".equals(range) ? 7 : 30;
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("MM-dd");
LocalDate start = today.minusDays(days - 1L);
int days;
LocalDate start;
if ("7d".equals(range)) {
days = 7;
start = today.minusDays(6);
} else if ("30d".equals(range)) {
days = 30;
start = today.minusDays(29);
} else {
start = customStartDate;
days = (int) java.time.temporal.ChronoUnit.DAYS.between(customStartDate, customEndDate) + 1;
}
for (int i = 0; i < days; i++) {
LocalDate current = start.plusDays(i);
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
@@ -396,6 +567,38 @@ public class DashboardServiceImpl implements DashboardService {
return buckets;
}
/**
* 当前上下文是否按小时构建趋势。
*
* @param context 查询上下文
* @return true 表示按小时
*/
private boolean useHourlyBuckets(DashboardQueryContext context) {
return "today".equals(context.range)
|| ("custom".equals(context.range)
&& context.startTime != null
&& context.endTime != null
&& context.startTime.toLocalDate().equals(context.endTime.toLocalDate().minusDays(1)));
}
/**
* 解析必填日期参数。
*
* @param dateText 日期文本
* @param errorMessage 错误信息
* @return 日期
*/
private LocalDate parseRequiredDate(String dateText, String errorMessage) {
if (!StringUtils.hasText(dateText)) {
throw new BusinessException(errorMessage);
}
try {
return LocalDate.parse(dateText.trim());
} catch (Exception ex) {
throw new BusinessException("日期格式不正确: " + dateText);
}
}
/**
* 把 LocalDateTime 转换为 Date。
*
@@ -436,6 +639,24 @@ public class DashboardServiceImpl implements DashboardService {
return (double) messageTotal / (double) sessionTotal;
}
/**
* 批量解析用户展示名称映射。
*
* @param ranks 活跃排行
* @return 名称映射
*/
private Map<BigInteger, String> resolveUserDisplayNameMap(List<ChatActiveUserRank> ranks) {
List<BigInteger> userIds = ranks.stream()
.map(ChatActiveUserRank::userId)
.filter(java.util.Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (userIds.isEmpty()) {
return new HashMap<>();
}
return sysAccountService.resolveDisplayNameMap(userIds);
}
/**
* 解析智能体展示名称。
*
@@ -450,6 +671,27 @@ public class DashboardServiceImpl implements DashboardService {
return assistantId == null ? "智能体-未知" : "智能体-" + assistantId;
}
/**
* 解析用户展示名称。
*
* @param userId 用户 ID
* @param userAccount 聊天侧账号快照
* @param displayNameMap 系统账号名称映射
* @return 展示名称
*/
private String resolveUserLabel(BigInteger userId, String userAccount, Map<BigInteger, String> displayNameMap) {
if (userId != null) {
String displayName = displayNameMap.get(userId);
if (StringUtils.hasText(displayName) && !displayName.equals(userId.toString())) {
return displayName;
}
}
if (StringUtils.hasText(userAccount)) {
return userAccount.trim();
}
return userId == null ? "用户-未知" : "用户-" + userId;
}
/**
* 工作台查询上下文。
*/
@@ -460,6 +702,8 @@ public class DashboardServiceImpl implements DashboardService {
private boolean superAdmin;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String queryStartDate;
private String queryEndDate;
}
/**
@@ -481,7 +725,9 @@ public class DashboardServiceImpl implements DashboardService {
private record ChatDashboardPayload(
DashboardChatStatusVo chatStatus,
List<DashboardTrendItemVo> trends,
List<DashboardDistributionItemVo> distribution
List<DashboardAssistantTrendSeriesVo> assistantTrends,
List<DashboardDistributionItemVo> distribution,
List<DashboardUserRankItemVo> userRanks
) {
}
}

View File

@@ -2,13 +2,20 @@ package tech.easyflow.admin.service.dashboard.impl;
import org.testng.Assert;
import org.testng.annotations.Test;
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.system.service.SysAccountService;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
@@ -19,9 +26,13 @@ import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -56,6 +67,7 @@ public class DashboardServiceImplTest {
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L));
Assert.assertEquals(summary.getChatActiveUserTotal(), Long.valueOf(0L));
}
/**
@@ -72,12 +84,19 @@ public class DashboardServiceImplTest {
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.querySummary(any(), any(), any()))
.thenReturn(new ChatDashboardSummary(3L, 9L, 1L));
.thenReturn(new ChatDashboardSummary(3L, 9L, 1L, 2L));
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L)));
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L, 2L)));
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 3L, 9L)));
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L)));
when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any()))
.thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L)));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 3L, 9L, 1L)));
SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(2), "演示用户"));
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("today", BigInteger.valueOf(9));
DashboardSummaryVo summary = new DashboardSummaryVo();
@@ -85,7 +104,10 @@ public class DashboardServiceImplTest {
Object chatStatus = readField(payload, "chatStatus");
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
Assert.assertEquals(trends.size(), 24);
@@ -94,13 +116,232 @@ public class DashboardServiceImplTest {
Assert.assertEquals(trends.get(10).getLabel(), "10:00");
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L));
Assert.assertEquals(trends.get(10).getActiveUserTotal(), Long.valueOf(2L));
Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L));
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L));
Assert.assertEquals(summary.getChatActiveUserTotal(), Long.valueOf(2L));
Assert.assertEquals(assistantTrends.size(), 1);
Assert.assertEquals(assistantTrends.get(0).getLabel(), "智能体-1");
Assert.assertEquals(assistantTrends.get(0).getTotalSessionCount(), Long.valueOf(3L));
Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 24);
Assert.assertEquals(assistantTrends.get(0).getPoints().get(10).getSessionTotal(), Long.valueOf(3L));
Assert.assertEquals(assistantTrends.get(0).getPoints().get(11).getSessionTotal(), Long.valueOf(0L));
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D));
Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户");
Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L));
}
/**
* 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。
*
* @throws Exception 反射调用失败
*/
@Test
@SuppressWarnings("unchecked")
public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.querySummary(any(), any(), any()))
.thenReturn(new ChatDashboardSummary(1L, 1L, 1L, 1L));
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
.thenReturn(List.of());
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L)));
SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(9), "9"));
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("today", BigInteger.ONE);
DashboardSummaryVo summary = new DashboardSummaryVo();
Object payload = invokeBuildChatPayload(service, context, summary);
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user");
}
/**
* 验证日趋势会保留 assistantId 为空的排行项,并补齐 7 天点位。
*
* @throws Exception 反射调用失败
*/
@Test
@SuppressWarnings("unchecked")
public void shouldBuildDailyAssistantTrendSeriesForRankedAssistants() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.querySummary(any(), any(), any()))
.thenReturn(new ChatDashboardSummary(4L, 12L, 2L, 3L));
when(chatDashboardQueryService.queryTrends(any(), any(), any()))
.thenReturn(List.of());
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of(
new ChatAssistantUsageRank(BigInteger.ONE, "助手-A", 3L, 4L, 12L),
new ChatAssistantUsageRank(null, "未知助手", 1L, 2L, 4L)
));
when(chatDashboardQueryService.queryAssistantTrends(any(), any(), any(), any()))
.thenReturn(List.of(
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().minusDays(6).toString(), 2L),
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L),
new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L)
));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
SysAccountService sysAccountService = mock(SysAccountService.class);
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of());
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", sysAccountService);
Object context = newContext("7d", BigInteger.ONE);
DashboardSummaryVo summary = new DashboardSummaryVo();
Object payload = invokeBuildChatPayload(service, context, summary);
List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
Assert.assertEquals(assistantTrends.size(), 2);
Assert.assertEquals(assistantTrends.get(0).getLabel(), "助手-A");
Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 7);
Assert.assertEquals(assistantTrends.get(0).getPoints().get(0).getSessionTotal(), Long.valueOf(2L));
Assert.assertEquals(assistantTrends.get(0).getPoints().get(6).getSessionTotal(), Long.valueOf(4L));
Assert.assertEquals(assistantTrends.get(0).getPoints().get(1).getSessionTotal(), Long.valueOf(0L));
Assert.assertNull(assistantTrends.get(1).getAssistantId());
Assert.assertEquals(assistantTrends.get(1).getLabel(), "未知助手");
Assert.assertEquals(assistantTrends.get(1).getPoints().get(3).getSessionTotal(), Long.valueOf(2L));
}
/**
* 验证自定义单天范围按小时桶构建。
*
* @throws Exception 反射调用失败
*/
@Test
@SuppressWarnings("unchecked")
public void shouldBuildHourlyTrendForCustomSingleDayRange() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
LocalDate customDate = LocalDate.now().minusDays(2);
String currentHourKey = LocalDateTime.of(customDate, LocalTime.of(8, 0))
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.querySummary(any(), any(), any()))
.thenReturn(new ChatDashboardSummary(2L, 6L, 1L, 1L));
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L)));
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", mock(SysAccountService.class));
Object context = newContext(
"custom",
BigInteger.ONE,
LocalDateTime.of(customDate, LocalTime.MIN),
LocalDateTime.of(customDate.plusDays(1), LocalTime.MIN)
);
DashboardSummaryVo summary = new DashboardSummaryVo();
Object payload = invokeBuildChatPayload(service, context, summary);
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
Assert.assertEquals(trends.size(), 24);
Assert.assertEquals(trends.get(8).getKey(), currentHourKey);
Assert.assertEquals(trends.get(8).getActiveUserTotal(), Long.valueOf(1L));
Assert.assertEquals(trends.get(9).getChatSessionTotal(), Long.valueOf(0L));
}
/**
* 验证自定义多天范围按天桶构建,并保留查询日期。
*
* @throws Exception 反射调用失败
*/
@Test
public void shouldBuildDailyBucketsForCustomMultiDayRangeContext() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
DashboardOverviewQuery query = new DashboardOverviewQuery();
query.setRange("custom");
query.setStartDate("2026-05-01");
query.setEndDate("2026-05-03");
Object context = invokeBuildContext(service, query);
Assert.assertEquals(readField(context, "range"), "custom");
Assert.assertEquals(
readField(context, "startTime"),
LocalDateTime.of(LocalDate.of(2026, 5, 1), LocalTime.MIN)
);
Assert.assertEquals(
readField(context, "endTime"),
LocalDateTime.of(LocalDate.of(2026, 5, 4), LocalTime.MIN)
);
Assert.assertEquals(readField(context, "queryStartDate"), "2026-05-01");
Assert.assertEquals(readField(context, "queryEndDate"), "2026-05-03");
}
/**
* 验证近 30 天趋势补齐完整 30 个桶,并按 Top 8 请求智能体活跃排行。
*
* @throws Exception 反射调用失败
*/
@Test
@SuppressWarnings("unchecked")
public void shouldBuildThirtyDayBucketsAndRequestTopEightAssistantRanks() throws Exception {
DashboardServiceImpl service = new DashboardServiceImpl();
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
LocalDate startDate = LocalDate.now().minusDays(29);
LocalDate endDate = LocalDate.now();
when(chatDashboardQueryService.available()).thenReturn(true);
when(chatDashboardQueryService.querySummary(any(), any(), any()))
.thenReturn(new ChatDashboardSummary(10L, 20L, 8L, 4L));
when(chatDashboardQueryService.queryTrends(any(), any(), any()))
.thenReturn(List.of(
new ChatDashboardTrend(startDate.toString(), 3L, 6L, 2L),
new ChatDashboardTrend(endDate.toString(), 7L, 14L, 4L)
));
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), eq(8)))
.thenReturn(IntStream.rangeClosed(1, 8)
.mapToObj(index -> new ChatAssistantUsageRank(
BigInteger.valueOf(index),
"助手-" + index,
index,
index * 2L,
index * 4L
))
.toList());
when(chatDashboardQueryService.queryAssistantTrends(any(), any(), any(), any()))
.thenReturn(List.of(
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L),
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L)
));
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
.thenReturn(List.of());
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
setField(service, "sysAccountService", mock(SysAccountService.class));
Object context = newContext("30d", BigInteger.ONE);
DashboardSummaryVo summary = new DashboardSummaryVo();
Object payload = invokeBuildChatPayload(service, context, summary);
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
List<DashboardAssistantTrendSeriesVo> assistantTrends =
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
Assert.assertEquals(trends.size(), 30);
Assert.assertEquals(trends.get(0).getKey(), startDate.toString());
Assert.assertEquals(trends.get(0).getChatSessionTotal(), Long.valueOf(3L));
Assert.assertEquals(trends.get(29).getKey(), endDate.toString());
Assert.assertEquals(trends.get(29).getChatMessageTotal(), Long.valueOf(14L));
Assert.assertEquals(trends.get(1).getChatSessionTotal(), Long.valueOf(0L));
Assert.assertEquals(assistantTrends.size(), 8);
Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 30);
verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8));
}
/**
@@ -112,6 +353,28 @@ public class DashboardServiceImplTest {
* @throws Exception 反射失败
*/
private Object newContext(String range, BigInteger tenantId) throws Exception {
return newContext(
range,
tenantId,
LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN),
LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN)
);
}
/**
* 构造查询上下文。
*
* @param range 时间范围
* @param tenantId 租户 ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 查询上下文实例
* @throws Exception 反射失败
*/
private Object newContext(String range,
BigInteger tenantId,
LocalDateTime startTime,
LocalDateTime endTime) throws Exception {
Class<?> contextClass = Class.forName(
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
);
@@ -120,11 +383,29 @@ public class DashboardServiceImplTest {
Object context = constructor.newInstance();
setField(context, "range", range);
setField(context, "tenantFilterId", tenantId);
setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN));
setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN));
setField(context, "startTime", startTime);
setField(context, "endTime", endTime);
return context;
}
/**
* 调用私有上下文构建方法。
*
* @param service service
* @param query 查询参数
* @return 上下文
* @throws Exception 反射失败
*/
private Object invokeBuildContext(DashboardServiceImpl service, DashboardOverviewQuery query) throws Exception {
Method method = DashboardServiceImpl.class.getDeclaredMethod(
"buildContext",
LoginAccount.class,
DashboardOverviewQuery.class
);
method.setAccessible(true);
return method.invoke(service, null, query);
}
/**
* 调用私有聊天载荷组装方法。
*

View File

@@ -0,0 +1,19 @@
package tech.easyflow.chatlog.domain.dto;
import java.math.BigInteger;
/**
* 聊天活跃用户排行项。
*
* @param userId 用户 ID
* @param userAccount 用户账号快照
* @param sessionTotal 会话总数
* @param messageTotal 消息总数
* @param assistantTotal 使用智能体数
*/
public record ChatActiveUserRank(BigInteger userId,
String userAccount,
long sessionTotal,
long messageTotal,
long assistantTotal) {
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.chatlog.domain.dto;
import java.math.BigInteger;
/**
* 智能体会话趋势项。
*
* @param assistantId 智能体 ID
* @param assistantName 智能体名称
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd小时趋势为 yyyy-MM-dd HH:00:00
* @param sessionTotal 会话总数
*/
public record ChatAssistantSessionTrend(BigInteger assistantId,
String assistantName,
String bucketKey,
long sessionTotal) {
}

View File

@@ -7,11 +7,13 @@ import java.math.BigInteger;
*
* @param assistantId 智能体 ID
* @param assistantName 智能体名称
* @param userTotal 使用用户数
* @param sessionTotal 会话总数
* @param messageTotal 消息总数
*/
public record ChatAssistantUsageRank(BigInteger assistantId,
String assistantName,
long userTotal,
long sessionTotal,
long messageTotal) {
}

View File

@@ -6,8 +6,12 @@ package tech.easyflow.chatlog.domain.dto;
* @param sessionTotal 会话总数
* @param messageTotal 消息总数
* @param activeAssistantTotal 活跃智能体数
* @param chatActiveUserTotal AI 活跃用户数
*/
public record ChatDashboardSummary(long sessionTotal, long messageTotal, long activeAssistantTotal) {
public record ChatDashboardSummary(long sessionTotal,
long messageTotal,
long activeAssistantTotal,
long chatActiveUserTotal) {
/**
* 创建空汇总结果。
@@ -15,6 +19,6 @@ public record ChatDashboardSummary(long sessionTotal, long messageTotal, long ac
* @return 空汇总结果
*/
public static ChatDashboardSummary empty() {
return new ChatDashboardSummary(0L, 0L, 0L);
return new ChatDashboardSummary(0L, 0L, 0L, 0L);
}
}

View File

@@ -6,6 +6,10 @@ package tech.easyflow.chatlog.domain.dto;
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd小时趋势为 yyyy-MM-dd HH:00:00
* @param sessionTotal 会话总数
* @param messageTotal 消息总数
* @param activeUserTotal AI 活跃用户数
*/
public record ChatDashboardTrend(String bucketKey, long sessionTotal, long messageTotal) {
public record ChatDashboardTrend(String bucketKey,
long sessionTotal,
long messageTotal,
long activeUserTotal) {
}

View File

@@ -4,6 +4,8 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
@@ -26,6 +28,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -222,21 +225,24 @@ public class ChatAnalyticalDBRepository {
List<Object> args = new java.util.ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT ")
.append("ifNull(sum(message_count), 0) AS message_total, ")
.append("ifNull(sum(session_count), 0) AS session_total, ")
.append("uniqExact(dimension_id) AS active_assistant_total ")
.append("FROM dws_chat_assistant_day ")
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
.append("uniqExact(agg.dimension_id) AS session_total, ")
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
.append("uniqExact(agg.assistant_id) AS active_assistant_total, ")
.append("uniqExact(agg.user_id) AS active_user_total ")
.append("FROM dws_chat_session_day agg ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
args.add(startDate.toString());
args.add(endDate.toString());
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
sql.toString(),
(rs, rowNum) -> new ChatDashboardSummary(
rs.getLong("session_total"),
rs.getLong("message_total"),
rs.getLong("active_assistant_total")
rs.getLong("active_assistant_total"),
rs.getLong("active_user_total")
),
args.toArray()
);
@@ -255,22 +261,25 @@ public class ChatAnalyticalDBRepository {
assertAvailable();
List<Object> args = new java.util.ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT toString(stat_date) AS bucket_key, ")
.append("ifNull(sum(message_count), 0) AS message_total, ")
.append("ifNull(sum(session_count), 0) AS session_total ")
.append("FROM dws_chat_assistant_day ")
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
sql.append("SELECT toString(agg.stat_date) AS bucket_key, ")
.append("uniqExact(agg.dimension_id) AS session_total, ")
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
.append("uniqExact(agg.user_id) AS active_user_total ")
.append("FROM dws_chat_session_day agg ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
args.add(startDate.toString());
args.add(endDate.toString());
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
sql.append(" GROUP BY stat_date ORDER BY stat_date ASC");
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
sql.append(" GROUP BY agg.stat_date ORDER BY agg.stat_date ASC");
return analyticalDBOperations.query(
sql.toString(),
(rs, rowNum) -> new ChatDashboardTrend(
rs.getString("bucket_key"),
rs.getLong("session_total"),
rs.getLong("message_total")
rs.getLong("message_total"),
rs.getLong("active_user_total")
),
args.toArray()
);
@@ -292,7 +301,8 @@ public class ChatAnalyticalDBRepository {
StringBuilder sql = new StringBuilder();
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
.append("count() AS message_total, ")
.append("uniqExact(l.session_id) AS session_total ")
.append("uniqExact(l.session_id) AS session_total, ")
.append("uniqExact(l.user_id) AS active_user_total ")
.append("FROM ods_chat_log l ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
@@ -306,7 +316,8 @@ public class ChatAnalyticalDBRepository {
(rs, rowNum) -> new ChatDashboardTrend(
rs.getString("bucket_key"),
rs.getLong("session_total"),
rs.getLong("message_total")
rs.getLong("message_total"),
rs.getLong("active_user_total")
),
args.toArray()
);
@@ -329,17 +340,21 @@ public class ChatAnalyticalDBRepository {
int safeLimit = Math.max(limit, 1);
List<Object> args = new java.util.ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT agg.assistant_id, snapshot.assistant_name, agg.session_total, agg.message_total ")
sql.append("SELECT agg.assistant_id AS assistant_id, ")
.append("snapshot.assistant_name AS assistant_name, ")
.append("agg.user_total, agg.session_total, agg.message_total ")
.append("FROM (")
.append("SELECT dimension_id AS assistant_id, ")
.append("ifNull(sum(session_count), 0) AS session_total, ")
.append("ifNull(sum(message_count), 0) AS message_total ")
.append("FROM dws_chat_assistant_day ")
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
.append("SELECT agg.assistant_id, ")
.append("uniqExact(agg.user_id) AS user_total, ")
.append("uniqExact(agg.dimension_id) AS session_total, ")
.append("ifNull(sum(agg.message_count), 0) AS message_total ")
.append("FROM dws_chat_session_day agg ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
args.add(startDate.toString());
args.add(endDate.toString());
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
sql.append(" GROUP BY dimension_id")
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
sql.append(" GROUP BY agg.assistant_id")
.append(") agg ")
.append("LEFT JOIN (")
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
@@ -347,7 +362,7 @@ public class ChatAnalyticalDBRepository {
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
sql.append(" GROUP BY assistant_id")
.append(") snapshot ON snapshot.assistant_id = agg.assistant_id ")
.append("ORDER BY agg.message_total DESC, agg.session_total DESC, agg.assistant_id ASC ")
.append("ORDER BY agg.session_total DESC, agg.user_total DESC, agg.message_total DESC, agg.assistant_id ASC ")
.append("LIMIT ?");
args.add(safeLimit);
@@ -356,6 +371,7 @@ public class ChatAnalyticalDBRepository {
(rs, rowNum) -> new ChatAssistantUsageRank(
bigInteger(rs.getObject("assistant_id")),
rs.getString("assistant_name"),
rs.getLong("user_total"),
rs.getLong("session_total"),
rs.getLong("message_total")
),
@@ -363,6 +379,215 @@ public class ChatAnalyticalDBRepository {
);
}
/**
* 查询智能体日趋势。
*
* @param startDate 开始日期,包含
* @param endDate 结束日期,不包含
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 趋势列表
*/
public List<ChatAssistantSessionTrend> queryAssistantSessionTrends(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
List<BigInteger> assistantIds) {
assertAvailable();
if (assistantIds == null || assistantIds.isEmpty()) {
return Collections.emptyList();
}
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT agg.assistant_id AS assistant_id, ")
.append("snapshot.assistant_name AS assistant_name, ")
.append("toString(agg.stat_date) AS bucket_key, ")
.append("uniqExact(agg.dimension_id) AS session_total ")
.append("FROM dws_chat_session_day agg ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
.append("LEFT JOIN (")
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
sql.append(" GROUP BY assistant_id")
.append(") snapshot ON snapshot.assistant_id = agg.assistant_id ")
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
args.add(startDate.toString());
args.add(endDate.toString());
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
appendAssistantIdFilter(sql, args, "agg.assistant_id", assistantIds);
sql.append(" GROUP BY agg.assistant_id, snapshot.assistant_name, agg.stat_date ")
.append("ORDER BY agg.stat_date ASC, agg.assistant_id ASC");
return analyticalDBOperations.query(
sql.toString(),
(rs, rowNum) -> new ChatAssistantSessionTrend(
bigInteger(rs.getObject("assistant_id")),
rs.getString("assistant_name"),
rs.getString("bucket_key"),
rs.getLong("session_total")
),
args.toArray()
);
}
/**
* 查询智能体小时趋势。
*
* @param startTime 开始时间,包含
* @param endTime 结束时间,不包含
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 趋势列表
*/
public List<ChatAssistantSessionTrend> queryAssistantSessionHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId,
List<BigInteger> assistantIds) {
assertAvailable();
if (assistantIds == null || assistantIds.isEmpty()) {
return Collections.emptyList();
}
List<Object> args = new ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT l.assistant_id AS assistant_id, ")
.append("snapshot.assistant_name AS assistant_name, ")
.append("formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
.append("uniqExact(l.session_id) AS session_total ")
.append("FROM ods_chat_log l ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
.append("LEFT JOIN (")
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
sql.append(" GROUP BY assistant_id")
.append(") snapshot ON snapshot.assistant_id = l.assistant_id ")
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
args.add(CH_DATE_TIME_FORMATTER.format(startTime));
args.add(CH_DATE_TIME_FORMATTER.format(endTime));
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
appendAssistantIdFilter(sql, args, "l.assistant_id", assistantIds);
sql.append(" GROUP BY l.assistant_id, snapshot.assistant_name, bucket_key ")
.append("ORDER BY bucket_key ASC, l.assistant_id ASC");
return analyticalDBOperations.query(
sql.toString(),
(rs, rowNum) -> new ChatAssistantSessionTrend(
bigInteger(rs.getObject("assistant_id")),
rs.getString("assistant_name"),
rs.getString("bucket_key"),
rs.getLong("session_total")
),
args.toArray()
);
}
/**
* 查询活跃用户排行。
*
* @param startDate 开始日期,包含
* @param endDate 结束日期,不包含
* @param tenantId 租户 ID空表示全局
* @param limit 返回条数
* @return 排行列表
*/
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
assertAvailable();
int safeLimit = Math.max(limit, 1);
List<Object> args = new java.util.ArrayList<>();
StringBuilder sql = new StringBuilder();
sql.append("SELECT agg.user_id AS user_id, ")
.append("snapshot.user_account AS user_account, ")
.append("agg.session_total, agg.message_total, agg.assistant_total ")
.append("FROM (")
.append("SELECT agg.user_id, ")
.append("uniqExact(agg.dimension_id) AS session_total, ")
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
.append("uniqExact(agg.assistant_id) AS assistant_total ")
.append("FROM dws_chat_session_day agg ")
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
args.add(startDate.toString());
args.add(endDate.toString());
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
sql.append(" GROUP BY agg.user_id")
.append(") agg ")
.append("LEFT JOIN (")
.append("SELECT user_id, argMax(user_account, modified) AS user_account ")
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
sql.append(" GROUP BY user_id")
.append(") snapshot ON snapshot.user_id = agg.user_id ")
.append("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC ")
.append("LIMIT ?");
args.add(safeLimit);
return analyticalDBOperations.query(
sql.toString(),
(rs, rowNum) -> new ChatActiveUserRank(
bigInteger(rs.getObject("user_id")),
rs.getString("user_account"),
rs.getLong("session_total"),
rs.getLong("message_total"),
rs.getLong("assistant_total")
),
args.toArray()
);
}
/**
* 追加智能体 ID 过滤。
*
* @param sql SQL 构造器
* @param params 参数列表
* @param columnName 列名
* @param assistantIds 智能体 ID 列表
*/
private void appendAssistantIdFilter(StringBuilder sql,
List<Object> params,
String columnName,
List<BigInteger> assistantIds) {
if (assistantIds == null || assistantIds.isEmpty()) {
return;
}
List<BigInteger> nonNullAssistantIds = new ArrayList<>();
boolean containsNullAssistantId = false;
for (BigInteger assistantId : assistantIds) {
if (assistantId == null) {
containsNullAssistantId = true;
} else {
nonNullAssistantIds.add(assistantId);
}
}
if (nonNullAssistantIds.isEmpty() && !containsNullAssistantId) {
return;
}
sql.append(" AND (");
boolean hasPreviousCondition = false;
if (!nonNullAssistantIds.isEmpty()) {
sql.append(columnName).append(" IN (");
for (int i = 0; i < nonNullAssistantIds.size(); i++) {
if (i > 0) {
sql.append(", ");
}
sql.append("?");
params.add(nonNullAssistantIds.get(i));
}
sql.append(")");
hasPreviousCondition = true;
}
if (containsNullAssistantId) {
if (hasPreviousCondition) {
sql.append(" OR ");
}
sql.append(columnName).append(" IS NULL");
}
sql.append(")");
}
public void refreshDws(Set<LocalDate> dates) {
if (!enabled() || dates.isEmpty()) {
return;

View File

@@ -1,5 +1,7 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
@@ -58,6 +60,48 @@ public interface ChatDashboardQueryService {
BigInteger tenantId,
int limit);
/**
* 查询智能体日趋势。
*
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 智能体日趋势
*/
List<ChatAssistantSessionTrend> queryAssistantTrends(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
List<BigInteger> assistantIds);
/**
* 查询智能体小时趋势。
*
* @param startTime 开始时间,包含
* @param endTime 结束时间,不包含
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 智能体小时趋势
*/
List<ChatAssistantSessionTrend> queryAssistantHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId,
List<BigInteger> assistantIds);
/**
* 查询活跃用户排行。
*
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param limit 返回条数
* @return 活跃用户排行
*/
List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit);
/**
* 当前分析库是否可用。
*

View File

@@ -1,6 +1,8 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
@@ -93,6 +95,66 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit);
}
/**
* 查询智能体日趋势。
*
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 智能体日趋势
*/
@Override
public List<ChatAssistantSessionTrend> queryAssistantTrends(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
List<BigInteger> assistantIds) {
if (!available() || assistantIds == null || assistantIds.isEmpty()) {
return Collections.emptyList();
}
return analyticalDBRepository.queryAssistantSessionTrends(startDate, endDate, tenantId, assistantIds);
}
/**
* 查询智能体小时趋势。
*
* @param startTime 开始时间,包含
* @param endTime 结束时间,不包含
* @param tenantId 租户 ID空表示全局
* @param assistantIds 智能体 ID 列表
* @return 智能体小时趋势
*/
@Override
public List<ChatAssistantSessionTrend> queryAssistantHourlyTrends(LocalDateTime startTime,
LocalDateTime endTime,
BigInteger tenantId,
List<BigInteger> assistantIds) {
if (!available() || assistantIds == null || assistantIds.isEmpty()) {
return Collections.emptyList();
}
return analyticalDBRepository.queryAssistantSessionHourlyTrends(startTime, endTime, tenantId, assistantIds);
}
/**
* 查询活跃用户排行。
*
* @param startDate 开始日期,包含当天
* @param endDate 结束日期,不包含当天
* @param tenantId 租户 ID空表示全局
* @param limit 返回条数
* @return 活跃用户排行
*/
@Override
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
LocalDate endDate,
BigInteger tenantId,
int limit) {
if (!available()) {
return Collections.emptyList();
}
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit);
}
/**
* 当前分析库是否可用。
*

View File

@@ -0,0 +1,188 @@
package tech.easyflow.chatlog.repository.analyticaldb;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
import org.springframework.jdbc.core.RowMapper;
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.analyticaldb.config.AnalyticalDBFlywayProperties;
import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations;
import tech.easyflow.common.analyticaldb.page.AnalyticalDBPageRequest;
import tech.easyflow.common.analyticaldb.page.AnalyticalDBPageResult;
import tech.easyflow.common.analyticaldb.support.AnalyticalDBHealthSupport;
import java.math.BigInteger;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* {@link ChatAnalyticalDBRepository} 测试。
*/
public class ChatAnalyticalDBRepositoryTest {
/**
* 验证工作台汇总使用跨天去重的 session 口径。
*/
@Test
public void shouldUseDistinctSessionSqlForDashboardSummary() {
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
operations.queryOneResult = new ChatDashboardSummary(2L, 5L, 1L, 1L);
ChatAnalyticalDBRepository repository = newRepository(operations);
repository.queryDashboardSummary(LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 8), BigInteger.ONE);
Assert.assertNotNull(operations.lastQueryOneSql);
Assert.assertTrue(operations.lastQueryOneSql.contains("FROM dws_chat_session_day agg"));
Assert.assertTrue(operations.lastQueryOneSql.contains("uniqExact(agg.dimension_id) AS session_total"));
Assert.assertTrue(operations.lastQueryOneSql.contains("uniqExact(agg.user_id) AS active_user_total"));
}
/**
* 验证智能体使用榜按去重会话数排序,并同时统计用户数。
*/
@Test
public void shouldUseDistinctSessionSqlForAssistantUsageRanks() {
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
ChatAnalyticalDBRepository repository = newRepository(operations);
repository.queryAssistantUsageRanks(LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 8), BigInteger.ONE, 5);
Assert.assertNotNull(operations.lastQuerySql);
Assert.assertTrue(operations.lastQuerySql.contains("FROM dws_chat_session_day agg"));
Assert.assertTrue(operations.lastQuerySql.contains("uniqExact(agg.user_id) AS user_total"));
Assert.assertTrue(operations.lastQuerySql.contains("uniqExact(agg.dimension_id) AS session_total"));
Assert.assertTrue(operations.lastQuerySql.contains(
"ORDER BY agg.session_total DESC, agg.user_total DESC, agg.message_total DESC, agg.assistant_id ASC"
));
Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id AS assistant_id"));
Assert.assertTrue(operations.lastQuerySql.contains("snapshot.assistant_name AS assistant_name"));
}
/**
* 验证智能体趋势查询显式返回 assistant_id 别名,避免 ClickHouse JDBC 无法按列名映射。
*/
@Test
public void shouldAliasAssistantIdForAssistantTrendQueries() {
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
ChatAnalyticalDBRepository repository = newRepository(operations);
repository.queryAssistantSessionTrends(
LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 8),
BigInteger.ONE,
List.of(BigInteger.ONE, BigInteger.TWO)
);
Assert.assertNotNull(operations.lastQuerySql);
Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id AS assistant_id"));
Assert.assertTrue(operations.lastQuerySql.contains("snapshot.assistant_name AS assistant_name"));
Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id IN (?, ?)"));
}
/**
* 验证智能体趋势查询在 Top 列表包含空 assistant_id 时会补上 IS NULL 条件。
*/
@Test
public void shouldSupportNullAssistantIdInAssistantTrendQueries() {
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
ChatAnalyticalDBRepository repository = newRepository(operations);
repository.queryAssistantSessionTrends(
LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 8),
BigInteger.ONE,
Arrays.asList(BigInteger.ONE, null)
);
Assert.assertNotNull(operations.lastQuerySql);
Assert.assertTrue(operations.lastQuerySql.contains("(agg.assistant_id IN (?) OR agg.assistant_id IS NULL)"));
}
/**
* 构造仓储实例。
*
* @param operations 分析库操作桩
* @return 仓储实例
*/
private ChatAnalyticalDBRepository newRepository(RecordingAnalyticalDBOperations operations) {
StaticListableBeanFactory beanFactory = new StaticListableBeanFactory();
beanFactory.addBean("analyticalDBOperations", operations);
ObjectProvider<AnalyticalDBOperations> provider = beanFactory.getBeanProvider(AnalyticalDBOperations.class);
AnalyticalDBHealthSupport healthSupport =
new AnalyticalDBHealthSupport(provider, new AnalyticalDBFlywayProperties());
ChatJsonSupport jsonSupport = new ChatJsonSupport(new ObjectMapper());
return new ChatAnalyticalDBRepository(provider, healthSupport, jsonSupport);
}
/**
* 记录 SQL 的分析库桩实现。
*/
private static class RecordingAnalyticalDBOperations implements AnalyticalDBOperations {
private String lastQueryOneSql;
private String lastQuerySql;
private ChatDashboardSummary queryOneResult;
@Override
public boolean available() {
return true;
}
@Override
public void assertAvailable() {
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
this.lastQuerySql = sql;
return Collections.emptyList();
}
@Override
public <T> T queryOne(String sql, Class<T> requiredType, Object... args) {
this.lastQueryOneSql = sql;
return null;
}
@SuppressWarnings("unchecked")
@Override
public <T> T queryOne(String sql, RowMapper<T> rowMapper, Object... args) {
this.lastQueryOneSql = sql;
return (T) queryOneResult;
}
@Override
public <T> List<T> queryForList(String sql, Class<T> elementType, Object... args) {
return Collections.emptyList();
}
@Override
public int update(String sql, Object... args) {
return 0;
}
@Override
public <T> int[][] batchUpdate(String sql,
List<T> items,
int batchSize,
ParameterizedPreparedStatementSetter<T> setter) {
return new int[0][];
}
@Override
public <T> AnalyticalDBPageResult<T> page(String countSql,
Object[] countArgs,
String dataSql,
Object[] dataArgs,
AnalyticalDBPageRequest pageRequest,
RowMapper<T> rowMapper) {
return null;
}
}
}

View File

@@ -1,15 +1,18 @@
import { requestClient } from '#/api/request';
export type DashboardRange = '7d' | '30d' | 'today';
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
export interface DashboardOverviewQuery {
endDate?: string;
range?: DashboardRange;
startDate?: string;
}
export interface DashboardSummary {
activeUserTotal: number;
activeAssistantTotal: number;
botTotal: number;
chatActiveUserTotal: number;
chatMessageTotal: number;
chatSessionTotal: number;
knowledgeBaseTotal: number;
@@ -30,10 +33,24 @@ export interface DashboardTrendItem {
label: string;
}
export interface DashboardAssistantTrendPoint {
key: string;
label: string;
sessionTotal: number;
}
export interface DashboardAssistantTrendSeries {
assistantId?: number | string;
label: string;
points: DashboardAssistantTrendPoint[];
totalSessionCount: number;
}
export interface DashboardDistributionItem {
activeUserTotal: number;
assistantId?: number | string;
avgMessagePerSession?: number;
avgSessionPerUser?: number;
botTotal: number;
key: string;
knowledgeBaseTotal: number;
@@ -45,13 +62,23 @@ export interface DashboardDistributionItem {
workflowTotal: number;
}
export interface DashboardUserRankItem {
assistantTotal: number;
label: string;
messageTotal: number;
sessionTotal: number;
userId?: number | string;
}
export interface DashboardOverviewResponse {
assistantTrends: DashboardAssistantTrendSeries[];
chatStatus: DashboardChatStatus;
distribution: DashboardDistributionItem[];
query: DashboardOverviewQuery;
summary: DashboardSummary;
trends: DashboardTrendItem[];
updatedAt: string;
userRanks: DashboardUserRankItem[];
}
export async function getDashboardOverview(params: DashboardOverviewQuery) {

View File

@@ -50,6 +50,8 @@
"embedUsageTip2": "Embed only in trusted sites and combine with rate limiting.",
"publicPageBlocked": "Anonymous access is disabled for this bot",
"publicPageBlockedTip": "Enable anonymous access in publish settings before using external links.",
"publicPageLoginRequired": "Authentication required",
"publicPageLoginRequiredTip": "This chat page requires a valid login session or access token before it can be used.",
"publicChatTitle": "Public Chat Assistant",
"publicChatSubtitle": "Powered by EasyFlow",
"publicChatPlaceholder": "Type your question and press Enter to send",

View File

@@ -50,6 +50,8 @@
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
"publicPageBlocked": "该助手未开放匿名访问",
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
"publicPageLoginRequired": "用户未认证,请登录",
"publicPageLoginRequiredTip": "当前聊天页需要有效登录态或访问令牌后才可继续使用。",
"publicChatTitle": "公开聊天助手",
"publicChatSubtitle": "由 EasyFlow 驱动",
"publicChatPlaceholder": "输入你的问题,按 Enter 发送",

View File

@@ -2,12 +2,14 @@
import type { EchartsUIType } from '@easyflow/plugins/echarts';
import type {
DashboardAssistantTrendSeries,
DashboardDistributionItem,
DashboardOverviewQuery,
DashboardOverviewResponse,
DashboardRange,
DashboardSummary,
DashboardTrendItem,
DashboardUserRankItem,
} from '#/api/dashboard';
import {
@@ -17,6 +19,7 @@ import {
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { AnalysisChartCard } from '@easyflow/common-ui';
@@ -25,10 +28,26 @@ import { useUserStore } from '@easyflow/stores';
import { convertToRgb } from '@easyflow/utils';
import { RefreshRight } from '@element-plus/icons-vue';
import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
import {
ElButton,
ElDatePicker,
ElEmpty,
ElPopover,
ElRadioButton,
ElRadioGroup,
} from 'element-plus';
import { getDashboardOverview } from '#/api/dashboard';
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
interface AssistantTrendSelectionItem {
assistantKey: string;
color: string;
isSelected: boolean;
series: DashboardAssistantTrendSeries;
}
interface SummaryCardItem {
available?: boolean;
label: string;
@@ -39,16 +58,23 @@ let greetingTimer: null | ReturnType<typeof setInterval> = null;
const userStore = useUserStore();
const now = ref(new Date());
const filters = reactive<Required<DashboardOverviewQuery>>({
const filters = reactive<DashboardOverviewQuery>({
range: '7d',
});
const customDateRange = ref<string[]>([]);
const overview = ref<DashboardOverviewResponse | null>(null);
const isLoading = ref(false);
const errorMessage = ref('');
const trendMode = ref<DashboardTrendMode>('usage');
const selectedAssistantTrendKeys = ref<string[]>([]);
const trendChartRef = ref<EchartsUIType>();
const { renderEcharts: renderTrendEcharts } = useEcharts(trendChartRef);
const {
getChartInstance: getTrendChartInstance,
renderEcharts: renderTrendEcharts,
resize: resizeTrendChart,
} = useEcharts(trendChartRef);
const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
{ label: '今日', value: 'today' },
@@ -56,10 +82,17 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
{ label: '近 30 天', value: '30d' },
];
const trendModeOptions: Array<{ label: string; value: DashboardTrendMode }> = [
{ label: '使用量趋势', value: 'usage' },
{ label: '用户活跃', value: 'userActive' },
{ label: '智能体活跃', value: 'assistantActive' },
];
const emptySummary: DashboardSummary = {
activeAssistantTotal: 0,
activeUserTotal: 0,
botTotal: 0,
chatActiveUserTotal: 0,
chatMessageTotal: 0,
chatSessionTotal: 0,
knowledgeBaseTotal: 0,
@@ -68,36 +101,67 @@ const emptySummary: DashboardSummary = {
};
const summary = computed(() => overview.value?.summary ?? emptySummary);
const trends = computed<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
const trends = computed<DashboardTrendItem[]>(
() => overview.value?.trends ?? [],
);
const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
() => overview.value?.assistantTrends ?? [],
);
const distribution = computed<DashboardDistributionItem[]>(
() => overview.value?.distribution ?? [],
);
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
const userRanks = computed<DashboardUserRankItem[]>(
() => overview.value?.userRanks ?? [],
);
const chatAvailable = computed(
() => overview.value?.chatStatus?.available !== false,
);
const chatStatusMessage = computed(
() => overview.value?.chatStatus?.message || '聊天数据不可用',
);
const isUsageTrend = computed(() => trendMode.value === 'usage');
const isUserActiveTrend = computed(() => trendMode.value === 'userActive');
const isAssistantActiveTrend = computed(
() => trendMode.value === 'assistantActive',
);
const summaryCards = computed<SummaryCardItem[]>(() => [
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
{
label: '知识库数量',
value: formatCount(summary.value.knowledgeBaseTotal),
available: chatAvailable.value,
label: 'AI活跃用户',
value: formatOptionalCount(
summary.value.chatActiveUserTotal,
chatAvailable.value,
),
},
{
available: chatAvailable.value,
label: '聊天消息总数',
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
label: '活跃智能体',
value: formatOptionalCount(
summary.value.activeAssistantTotal,
chatAvailable.value,
),
},
{
available: chatAvailable.value,
label: '聊天会话总数',
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
value: formatOptionalCount(
summary.value.chatSessionTotal,
chatAvailable.value,
),
},
{
available: chatAvailable.value,
label: '活跃智能体数',
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
label: '聊天消息总数',
value: formatOptionalCount(
summary.value.chatMessageTotal,
chatAvailable.value,
),
},
{ label: '智能体总数', value: formatCount(summary.value.botTotal) },
{
label: '知识库总数',
value: formatCount(summary.value.knowledgeBaseTotal),
},
]);
@@ -134,15 +198,99 @@ const greetingTitle = computed(
() => `${greetingText.value}${displayName.value}`,
);
const trendDescription = computed(() => {
if (isUsageTrend.value) {
return '消息与会话趋势';
}
if (isUserActiveTrend.value) {
return '用户活跃趋势';
}
return '智能体活跃趋势';
});
const selectedAssistantTrendSet = computed(
() => new Set(selectedAssistantTrendKeys.value),
);
const assistantTrendColors = computed(() =>
buildAssistantTrendPalette(assistantTrends.value.length),
);
const assistantTrendSelectionItems = computed<AssistantTrendSelectionItem[]>(
() =>
assistantTrends.value.map((series, index) => {
const assistantKey = getAssistantTrendKey(
series.assistantId,
series.label,
);
return {
assistantKey,
color:
assistantTrendColors.value[index] || getChartTokenColor('--primary'),
isSelected: selectedAssistantTrendSet.value.has(assistantKey),
series,
};
}),
);
const selectedAssistantTrends = computed<AssistantTrendSelectionItem[]>(() =>
assistantTrendSelectionItems.value.filter((item) => item.isSelected),
);
const selectedAssistantTrendCount = computed(
() => selectedAssistantTrends.value.length,
);
const selectedAssistantTrendPreviewItems = computed(() =>
selectedAssistantTrends.value.slice(0, 3),
);
const assistantTrendSelectorLabel = computed(() => {
const total = assistantTrendSelectionItems.value.length;
if (total === 0) {
return '无可选智能体';
}
return `${selectedAssistantTrendCount.value}/${total} 已选`;
});
const assistantTrendSelectorSummary = computed(() => {
const count = selectedAssistantTrendCount.value;
if (count === 0) {
return '未选择智能体';
}
if (count === 1) {
return selectedAssistantTrends.value[0]?.series.label || '已选择 1 个';
}
return `${selectedAssistantTrends.value[0]?.series.label || '已选择'}${count}`;
});
const showAssistantTrendNoData = computed(
() =>
isAssistantActiveTrend.value &&
assistantTrendSelectionItems.value.length === 0,
);
const showAssistantTrendEmptySelection = computed(
() =>
isAssistantActiveTrend.value &&
assistantTrendSelectionItems.value.length > 0 &&
selectedAssistantTrends.value.length === 0,
);
const showTrendChart = computed(() => chatAvailable.value);
async function loadOverview() {
isLoading.value = true;
errorMessage.value = '';
try {
const data = await getDashboardOverview({
endDate: filters.range === 'custom' ? filters.endDate : undefined,
range: filters.range,
startDate: filters.range === 'custom' ? filters.startDate : undefined,
});
overview.value = data;
resetAssistantTrendSelection();
await renderCharts();
} catch (error) {
overview.value = null;
@@ -155,24 +303,197 @@ async function loadOverview() {
async function renderCharts() {
await nextTick();
if (!chatAvailable.value) {
if (!showTrendChart.value) {
return;
}
renderTrendChart();
}
function renderTrendChart() {
const xAxisData = trends.value.map((item) => item.label);
const messageData = trends.value.map((item) => item.chatMessageTotal);
const sessionData = trends.value.map((item) => item.chatSessionTotal);
const primaryColor = getChartTokenColor('--primary');
const successColor = getChartTokenColor('--success');
const warningColor = getChartTokenColor('--warning');
const destructiveColor = getChartTokenColor('--destructive');
const axisColor = getChartTokenColor('--border');
const tooltipLineColor = getChartTokenColor('--accent');
const textColor = getChartTokenColor('--foreground');
if (isAssistantActiveTrend.value) {
const xAxisData =
assistantTrendSelectionItems.value[0]?.series.points.map(
(point) => point.label,
) ?? [];
const assistantSeries = assistantTrendSelectionItems.value.map((item) => ({
data: item.series.points.map((point) => point.sessionTotal),
emphasis: {
focus: 'series' as const,
},
itemStyle: {
borderColor: item.color,
borderWidth: 2,
color: '#ffffff',
},
lineStyle: {
color: item.color,
width: 3,
},
name: item.series.label,
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
type: 'line' as const,
}));
renderTrendEcharts({
color: assistantTrendSelectionItems.value.map((item) => item.color),
grid: {
bottom: 18,
containLabel: true,
left: 12,
right: 12,
top: 12,
},
legend: {
data: assistantTrendSelectionItems.value.map(
(item) => item.series.label,
),
selected: buildAssistantTrendSelectedMap(),
show: false,
},
tooltip: {
axisPointer: {
lineStyle: {
color: tooltipLineColor,
width: 1,
},
type: 'line',
},
trigger: 'axis',
},
xAxis: {
axisLine: {
lineStyle: {
color: axisColor,
},
},
axisTick: {
show: false,
},
boundaryGap: false,
data: xAxisData,
type: 'category',
},
yAxis: {
axisLine: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
lineStyle: {
color: axisColor,
type: 'dashed',
},
},
type: 'value',
},
series: assistantSeries,
});
return;
}
const xAxisData = trends.value.map((item) => item.label);
const messageData = trends.value.map((item) => item.chatMessageTotal);
const sessionData = trends.value.map((item) => item.chatSessionTotal);
const activeUserData = trends.value.map((item) => item.activeUserTotal);
const usageSeries = [
{
data: messageData,
emphasis: {
focus: 'none' as const,
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
color: primaryColor,
},
scale: true,
},
itemStyle: {
borderColor: primaryColor,
borderWidth: 2,
color: '#ffffff',
},
lineStyle: {
color: primaryColor,
width: 3,
},
name: '消息数',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 9,
type: 'line' as const,
},
{
data: sessionData,
emphasis: {
focus: 'none' as const,
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
color: successColor,
},
scale: true,
},
itemStyle: {
borderColor: successColor,
borderWidth: 2,
color: '#ffffff',
},
lineStyle: {
color: successColor,
width: 3,
},
name: '会话数',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 9,
type: 'line' as const,
},
];
const activeSeries = [
{
data: activeUserData,
emphasis: {
focus: 'none' as const,
},
itemStyle: {
borderColor: primaryColor,
borderWidth: 2,
color: '#ffffff',
},
lineStyle: {
color: primaryColor,
width: 3,
},
name: '活跃用户数',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 9,
type: 'line' as const,
},
];
renderTrendEcharts({
color: [primaryColor, successColor],
color: isUsageTrend.value
? [primaryColor, successColor]
: [primaryColor, warningColor, destructiveColor],
grid: {
bottom: 18,
containLabel: true,
@@ -231,62 +552,7 @@ function renderTrendChart() {
},
type: 'value',
},
series: [
{
data: messageData,
emphasis: {
focus: 'none',
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
color: primaryColor,
},
scale: true,
},
itemStyle: {
borderColor: primaryColor,
borderWidth: 2,
color: '#ffffff',
},
lineStyle: {
color: primaryColor,
width: 3,
},
name: '消息数',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 9,
type: 'line',
},
{
data: sessionData,
emphasis: {
focus: 'none',
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
color: successColor,
},
scale: true,
},
itemStyle: {
borderColor: successColor,
borderWidth: 2,
color: '#ffffff',
},
lineStyle: {
color: successColor,
width: 3,
},
name: '会话数',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 9,
type: 'line',
},
],
series: isUsageTrend.value ? usageSeries : activeSeries,
});
}
@@ -295,9 +561,130 @@ function handleRangeChange(value: boolean | number | string | undefined) {
return;
}
filters.range = value;
customDateRange.value = [];
filters.startDate = undefined;
filters.endDate = undefined;
void loadOverview();
}
function handleCustomRangeChange(value: string[] | undefined) {
if (!value || value.length !== 2 || !value[0] || !value[1]) {
customDateRange.value = [];
filters.range = '7d';
filters.startDate = undefined;
filters.endDate = undefined;
void loadOverview();
return;
}
filters.range = 'custom';
filters.startDate = value[0];
filters.endDate = value[1];
void loadOverview();
}
function handleTrendModeChange(value: boolean | number | string | undefined) {
if (
value !== 'usage' &&
value !== 'userActive' &&
value !== 'assistantActive'
) {
return;
}
trendMode.value = value;
}
function resetAssistantTrendSelection() {
selectedAssistantTrendKeys.value = assistantTrends.value.map((series) =>
getAssistantTrendKey(series.assistantId, series.label),
);
}
function toggleAssistantTrend(assistantKey: string) {
const currentItem = assistantTrendSelectionItems.value.find(
(item) => item.assistantKey === assistantKey,
);
if (!currentItem) {
return;
}
const selectedKeys = new Set(selectedAssistantTrendKeys.value);
let actionType: 'legendSelect' | 'legendUnSelect' = 'legendSelect';
if (selectedKeys.has(assistantKey)) {
selectedKeys.delete(assistantKey);
actionType = 'legendUnSelect';
} else {
selectedKeys.add(assistantKey);
}
selectedAssistantTrendKeys.value = [...selectedKeys];
if (!isAssistantActiveTrend.value) {
return;
}
void nextTick(() => {
const chartInstance = getTrendChartInstance();
if (!chartInstance) {
return;
}
chartInstance.dispatchAction({
name: currentItem.series.label,
type: actionType,
});
});
}
function selectAllAssistantTrends() {
selectedAssistantTrendKeys.value = assistantTrendSelectionItems.value.map(
(item) => item.assistantKey,
);
if (!isAssistantActiveTrend.value) {
return;
}
void nextTick(() => {
renderTrendChart();
resizeTrendChart();
});
}
function clearAssistantTrendSelection() {
const previousItems = [...selectedAssistantTrends.value];
selectedAssistantTrendKeys.value = [];
if (!isAssistantActiveTrend.value) {
return;
}
void nextTick(() => {
const chartInstance = getTrendChartInstance();
if (!chartInstance) {
return;
}
for (const item of previousItems) {
chartInstance.dispatchAction({
name: item.series.label,
type: 'legendUnSelect',
});
}
});
}
function buildAssistantTrendSelectedMap() {
return Object.fromEntries(
assistantTrendSelectionItems.value.map((item) => [
item.series.label,
item.isSelected,
]),
);
}
watch(
[trendMode, chatAvailable, trends, assistantTrends],
() => {
if (!showTrendChart.value) {
return;
}
void renderCharts();
},
{
flush: 'post',
},
);
function formatCount(value?: number) {
return Number(value || 0).toLocaleString('zh-CN');
}
@@ -307,13 +694,54 @@ function formatOptionalCount(value: number | undefined, available: boolean) {
}
function getChartTokenColor(variableName: string) {
return convertToRgb(`hsl(${getChartTokenHsl(variableName)})`);
}
function getChartTokenHsl(variableName: string) {
if (typeof window === 'undefined') {
return '#3b82f6';
return '211 100% 50%';
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(variableName)
.trim();
return value ? convertToRgb(`hsl(${value})`) : '#3b82f6';
return value || '211 100% 50%';
}
function buildAssistantTrendPalette(total: number) {
const palette = [
getChartTokenColor('--primary'),
getChartTokenColor('--success'),
getChartTokenColor('--warning'),
getChartTokenColor('--destructive'),
];
if (total <= palette.length) {
return palette.slice(0, total);
}
const primaryHue = extractHue(getChartTokenHsl('--primary')) ?? 211;
const derivedHues = [46, 92, 138, 184, 230, 276].map(
(offset) => `hsl(${normalizeHue(primaryHue + offset)} 68% 52%)`,
);
return [...palette, ...derivedHues].slice(0, total);
}
function getAssistantTrendKey(
assistantId: number | string | undefined,
label: string,
) {
return assistantId === undefined
? `label:${label}`
: `assistant:${assistantId}`;
}
function extractHue(hslValue: string) {
const [hue] = hslValue.split(/\s+/);
const parsed = Number.parseFloat(hue || '');
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeHue(value: number) {
const normalized = value % 360;
return normalized < 0 ? normalized + 360 : normalized;
}
function formatAvg(value?: number) {
@@ -363,6 +791,16 @@ onBeforeUnmount(() => {
<div
class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
>
<ElDatePicker
v-model="customDateRange"
class="w-full sm:w-[280px]"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleCustomRangeChange"
/>
<ElRadioGroup
:model-value="filters.range"
size="default"
@@ -392,7 +830,7 @@ onBeforeUnmount(() => {
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="item in 8"
v-for="item in 6"
:key="item"
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
></div>
@@ -436,14 +874,134 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<AnalysisChartCard title="聊天趋势">
<section
class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
>
<AnalysisChartCard title="趋势概览">
<template v-if="chatAvailable">
<div class="space-y-2">
<p class="text-muted-foreground text-sm">
观察所选时间范围内消息数与会话数的趋势变化
</p>
<EchartsUI ref="trendChartRef" height="360px" />
<div class="space-y-4">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="text-muted-foreground text-sm">
{{ trendDescription }}
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<ElPopover
v-if="
isAssistantActiveTrend &&
assistantTrendSelectionItems.length > 0
"
placement="bottom-end"
trigger="click"
:width="340"
>
<template #reference>
<button
type="button"
class="border-border/60 bg-background/88 hover:border-border hover:bg-muted/20 text-foreground inline-flex h-9 max-w-[260px] items-center gap-2 rounded-2xl border px-3.5 text-sm transition-[border-color,background-color,box-shadow] duration-200"
>
<span
v-if="selectedAssistantTrendPreviewItems.length > 0"
class="flex shrink-0 items-center -space-x-1.5"
>
<span
v-for="item in selectedAssistantTrendPreviewItems"
:key="item.assistantKey"
class="border-background inline-block h-2.5 w-2.5 rounded-full border"
:style="{ backgroundColor: item.color }"
></span>
</span>
<span class="min-w-0 flex-1 truncate font-medium">
{{ assistantTrendSelectorSummary }}
</span>
<span
class="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium"
>
{{ assistantTrendSelectorLabel }}
</span>
</button>
</template>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold">智能体</div>
<div class="flex items-center gap-2">
<button
type="button"
class="text-primary text-xs font-medium"
@click="selectAllAssistantTrends"
>
全选
</button>
<button
type="button"
class="text-muted-foreground text-xs font-medium"
@click="clearAssistantTrendSelection"
>
清空
</button>
</div>
</div>
<div class="grid max-h-64 gap-2 overflow-y-auto">
<button
v-for="item in assistantTrendSelectionItems"
:key="item.assistantKey"
type="button"
class="border-border/60 bg-background hover:border-border flex items-center gap-3 rounded-2xl border px-3 py-2 text-left transition-colors"
:class="
item.isSelected
? 'border-primary/40 bg-primary/5'
: 'text-muted-foreground'
"
@click="toggleAssistantTrend(item.assistantKey)"
>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:style="{ backgroundColor: item.color }"
></span>
<span
class="min-w-0 flex-1 truncate text-sm font-medium"
>
{{ item.series.label }}
</span>
<span class="text-muted-foreground shrink-0 text-xs">
{{ formatCount(item.series.totalSessionCount) }}
</span>
</button>
</div>
</div>
</ElPopover>
<ElRadioGroup
:model-value="trendMode"
size="small"
@update:model-value="handleTrendModeChange"
>
<ElRadioButton
v-for="item in trendModeOptions"
:key="item.value"
:value="item.value"
:label="item.label"
>
{{ item.label }}
</ElRadioButton>
</ElRadioGroup>
</div>
</div>
<div class="relative min-h-[360px]">
<div
v-if="showAssistantTrendNoData"
class="bg-background/92 absolute inset-0 z-10 flex items-center justify-center"
>
<ElEmpty description="暂无智能体活跃数据" />
</div>
<div
v-else-if="showAssistantTrendEmptySelection"
class="bg-background/92 absolute inset-0 z-10 flex items-center justify-center"
>
<ElEmpty description="请选择智能体" />
</div>
<EchartsUI ref="trendChartRef" height="360px" />
</div>
</div>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
@@ -451,9 +1009,9 @@ onBeforeUnmount(() => {
</div>
</AnalysisChartCard>
<AnalysisChartCard title="智能体排行">
<AnalysisChartCard title="智能体使用榜">
<template v-if="chatAvailable">
<div v-if="distribution.length" class="space-y-3">
<div v-if="distribution.length > 0" class="space-y-3">
<div
v-for="(item, index) in distribution"
:key="item.key || item.label"
@@ -471,24 +1029,24 @@ onBeforeUnmount(() => {
{{ item.label }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
消息 {{ formatCount(item.messageTotal) }} · 会话
{{ formatCount(item.sessionTotal) }}
用户 {{ formatCount(item.userTotal) }} · 消息
{{ formatCount(item.messageTotal) }}
</div>
</div>
</div>
</div>
<div class="text-right">
<div class="text-foreground text-lg font-semibold">
{{ formatAvg(item.avgMessagePerSession) }}
{{ formatCount(item.sessionTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
平均每会话消息数
会话数 · 人均 {{ formatAvg(item.avgSessionPerUser) }}
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[360px] items-center justify-center">
<ElEmpty description="暂无聊天排行数据" />
<ElEmpty description="暂无智能体使用数据" />
</div>
</template>
<div v-else class="flex min-h-[360px] items-center justify-center">
@@ -496,6 +1054,66 @@ onBeforeUnmount(() => {
</div>
</AnalysisChartCard>
</section>
<section>
<AnalysisChartCard title="用户活跃榜">
<template v-if="chatAvailable">
<div v-if="userRanks.length > 0" class="space-y-3">
<div
v-for="(item, index) in userRanks"
:key="item.userId || item.label"
class="border-border/60 bg-card flex flex-col gap-4 rounded-2xl border px-4 py-4 md:flex-row md:items-center md:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<div
class="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
>
{{ index + 1 }}
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold">
{{ item.label }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
消息 {{ formatCount(item.messageTotal) }}
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.sessionTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">会话数</div>
</div>
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.assistantTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
使用智能体数
</div>
</div>
<div
class="bg-muted/30 col-span-2 rounded-2xl px-4 py-3 text-right md:col-span-1"
>
<div class="text-foreground text-base font-semibold">
{{ formatCount(item.messageTotal) }}
</div>
<div class="text-muted-foreground mt-1 text-xs">消息数</div>
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[220px] items-center justify-center">
<ElEmpty description="暂无用户活跃数据" />
</div>
</template>
<div v-else class="flex min-h-[220px] items-center justify-center">
<ElEmpty :description="chatStatusMessage" />
</div>
</AnalysisChartCard>
</section>
</template>
</div>
</template>

View File

@@ -16,13 +16,16 @@ import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
import { useRoute, useRouter } from 'vue-router';
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { LOGIN_PATH } from '@easyflow/constants';
import { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { useAccessStore } from '@easyflow/stores';
import { uuid } from '@easyflow/utils';
import { useTitle } from '@vueuse/core';
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
import { refreshTokenApi } from '#/api/core';
import { baseRequestClient, sseClient } from '#/api/request';
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
@@ -106,12 +109,21 @@ interface PublicChatSessionRestoreResult {
messages?: PublicChatMessageRecord[];
}
type AuthResolutionStatus = 'authenticated' | 'error' | 'unauthenticated';
interface AuthResolutionResult {
message?: string;
nextToken?: string;
status: AuthResolutionStatus;
}
const PUBLIC_CHAT_API_KEY_QUERY_KEYS = ['token', 'apikey', 'apiKey'] as const;
const PUBLIC_CHAT_CONTEXT_VERSION = 1;
const route = useRoute();
const router = useRouter();
const pageTitle = useTitle();
const accessStore = useAccessStore();
const botInfo = ref<BotInfo | null>(null);
const conversationId = ref<string>('');
@@ -149,6 +161,28 @@ const setPageTitle = () => {
pageTitle.value = botTitle || $t('bot.publicChatTitle');
};
const getUnauthenticatedMessage = () => $t('bot.publicPageLoginRequired');
const normalizePublicChatErrorMessage = (message?: string) => {
const trimmed = String(message || '').trim();
if (!trimmed) {
return $t('bot.publicChatInitError');
}
if (trimmed === '请登录' || trimmed === '用户未认证,请先登录') {
return getUnauthenticatedMessage();
}
return trimmed;
};
const goToLogin = async () => {
await router.push({
path: LOGIN_PATH,
query: {
redirect: encodeURIComponent(route.fullPath),
},
});
};
const isAtBottom = () => {
if (!messageContainerRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
@@ -438,34 +472,109 @@ const exchangeApiKeyToAccessToken = async (apiKey: string) => {
return accessToken;
};
const resolveAuthenticatedAccess = async (token: string) => {
const extractErrorStatus = (error: any) => {
const status = Number(error?.response?.status);
return Number.isFinite(status) ? status : 0;
};
const extractErrorMessage = (error: any) => {
const responseData = error?.response?.data ?? {};
const message = String(
responseData?.message || responseData?.error || error?.message || '',
).trim();
return message;
};
const isUnauthorizedError = (error: any) => {
const responseData = error?.response?.data ?? {};
return (
extractErrorStatus(error) === 401 || Number(responseData?.errorCode) === 401
);
};
const probeAuthenticatedAccess = async (token: string) => {
if (!token.trim()) {
return { status: 'unauthenticated' } as AuthResolutionResult;
}
try {
const profileResp = await getResponseBody(
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
headers: buildRequestHeaders(token),
}),
);
return profileResp.errorCode === 0 && !!profileResp.data;
} catch {
return false;
if (profileResp.errorCode === 0 && profileResp.data) {
return {
nextToken: token,
status: 'authenticated',
} as AuthResolutionResult;
}
if (profileResp.errorCode === 401) {
return { status: 'unauthenticated' } as AuthResolutionResult;
}
return {
message: normalizePublicChatErrorMessage(profileResp.message),
status: 'error',
} as AuthResolutionResult;
} catch (error) {
if (isUnauthorizedError(error)) {
return { status: 'unauthenticated' } as AuthResolutionResult;
}
return {
message: normalizePublicChatErrorMessage(extractErrorMessage(error)),
status: 'error',
} as AuthResolutionResult;
}
};
const tryRefreshCurrentAccessToken = async () => {
try {
const refreshed = await refreshTokenApi();
const nextToken = String(refreshed?.data || '').trim();
if (!nextToken) {
return { status: 'unauthenticated' } as AuthResolutionResult;
}
accessStore.setAccessToken(nextToken);
return await probeAuthenticatedAccess(nextToken);
} catch (error) {
if (isUnauthorizedError(error)) {
return { status: 'unauthenticated' } as AuthResolutionResult;
}
return {
message: normalizePublicChatErrorMessage(extractErrorMessage(error)),
status: 'error',
} as AuthResolutionResult;
}
};
const resolveAuthenticatedAccess = async (
token: string,
options?: { allowRefresh?: boolean },
) => {
const initialResult = await probeAuthenticatedAccess(token);
if (initialResult.status !== 'unauthenticated' || !options?.allowRefresh) {
return initialResult;
}
return await tryRefreshCurrentAccessToken();
};
const ensureRequestAccessToken = async () => {
requestAccessToken.value = '';
authenticatedAccess.value = false;
const urlApiKey = readUrlApiKey();
if (urlApiKey) {
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
const authenticated = await resolveAuthenticatedAccess(exchangedToken);
if (!authenticated) {
const authResult = await resolveAuthenticatedAccess(exchangedToken);
if (authResult.status === 'error') {
throw new Error(authResult.message || $t('bot.publicChatInitError'));
}
if (authResult.status !== 'authenticated') {
throw new Error($t('bot.publicChatTokenInvalid'));
}
requestAccessToken.value = exchangedToken;
requestAccessToken.value = authResult.nextToken || exchangedToken;
authenticatedAccess.value = true;
upsertPublicChatContext(
{
accessToken: exchangedToken,
accessToken: requestAccessToken.value,
authenticatedAccess: true,
},
{ resetConversation: true },
@@ -476,36 +585,45 @@ const ensureRequestAccessToken = async () => {
const storedContext = readPublicChatContext(botId.value);
if (storedContext?.accessToken) {
const tokenValid = await resolveAuthenticatedAccess(
const authResult = await resolveAuthenticatedAccess(
storedContext.accessToken,
);
if (tokenValid) {
requestAccessToken.value = storedContext.accessToken;
authenticatedAccess.value = storedContext.authenticatedAccess;
if (authResult.status === 'error') {
throw new Error(authResult.message || $t('bot.publicChatInitError'));
}
if (authResult.status === 'authenticated') {
requestAccessToken.value =
authResult.nextToken || storedContext.accessToken;
authenticatedAccess.value = true;
upsertPublicChatContext({
accessToken: storedContext.accessToken,
authenticatedAccess: storedContext.authenticatedAccess,
accessToken: requestAccessToken.value,
authenticatedAccess: true,
conversationId: storedContext.conversationId,
});
return;
}
clearPublicChatContext(botId.value);
}
const tokenResp = await getResponseBody<string>(
baseRequestClient.get('/api/temp-token/create'),
);
if (tokenResp.errorCode !== 0 || !tokenResp.data) {
throw new Error($t('bot.publicChatInitError'));
const currentAccessToken = String(accessStore.accessToken || '').trim();
if (currentAccessToken) {
const authResult = await resolveAuthenticatedAccess(currentAccessToken, {
allowRefresh: true,
});
if (authResult.status === 'error') {
throw new Error(authResult.message || $t('bot.publicChatInitError'));
}
if (authResult.status === 'authenticated') {
requestAccessToken.value = authResult.nextToken || currentAccessToken;
authenticatedAccess.value = true;
upsertPublicChatContext(
{
accessToken: requestAccessToken.value,
authenticatedAccess: true,
},
{ resetConversation: true },
);
}
}
requestAccessToken.value = String(tokenResp.data);
upsertPublicChatContext(
{
accessToken: requestAccessToken.value,
authenticatedAccess: false,
},
{ resetConversation: true },
);
};
const resetConversationState = () => {
@@ -1116,7 +1234,7 @@ const initPublicChat = async () => {
console.error(error);
initError.value =
error instanceof Error && error.message
? error.message
? normalizePublicChatErrorMessage(error.message)
: $t('bot.publicChatInitError');
setPageTitle();
} finally {
@@ -1221,9 +1339,16 @@ onBeforeUnmount(() => {
class="public-chat-banner public-chat-banner-warning"
>
<p class="public-chat-banner-title">
{{ $t('bot.publicPageBlocked') }}
{{ $t('bot.publicPageLoginRequired') }}
</p>
<p>{{ $t('bot.publicPageBlockedTip') }}</p>
<p>{{ $t('bot.publicPageLoginRequiredTip') }}</p>
<ElButton
type="primary"
class="public-chat-banner-action"
@click="goToLogin"
>
{{ $t('authentication.goToLogin') }}
</ElButton>
</div>
<template v-else>
<div
@@ -1596,6 +1721,10 @@ onBeforeUnmount(() => {
border: 1px solid #fed7aa;
}
.public-chat-banner-action {
margin-top: 12px;
}
.public-chat-message-row {
display: flex;
margin-bottom: 18px;

View File

@@ -1,10 +1,10 @@
<svelte:options customElement={{
tag: "tinyflow-component",
shadow: "none",
// props: {
// options: { reflect: true, type: 'Object', attribute: 'options' },
// onInit: { reflect: true, type: 'Object', attribute: 'onInit' }
// },
props: {
options: { type: 'Object', attribute: 'options' },
onInit: { type: 'Object', attribute: 'on-init' }
},
}} />
<script lang="ts">
@@ -14,31 +14,50 @@
import type {TinyflowData, TinyflowOptions} from '#types';
import {setContext} from 'svelte';
const { options, onInit }: {
const props = $props<{
options: TinyflowOptions,
onInit: (svelteFlow: ReturnType<typeof useSvelteFlow>) => void,
} = $props();
}>();
let { data } = options;
let initialViewport = null;
if (typeof data === 'string') {
try {
data = JSON.parse(data.trim());
} catch (e) {
console.error('Invalid JSON data:', data);
const parseData = (source: TinyflowOptions['data']) => {
let nextData = source;
if (typeof nextData === 'string') {
try {
nextData = JSON.parse(nextData.trim());
} catch (error) {
console.error('Invalid JSON data:', nextData, error);
}
}
}
initialViewport = (data as TinyflowData)?.viewport || null;
return nextData as TinyflowData | null | undefined;
};
const getOptions = () => props.options;
const contextOptions = new Proxy({} as TinyflowOptions, {
get(_target, property) {
return (getOptions() as Record<PropertyKey, unknown>)[property];
},
has(_target, property) {
return property in getOptions();
},
ownKeys() {
return Reflect.ownKeys(getOptions());
},
getOwnPropertyDescriptor(_target, property) {
const descriptor = Object.getOwnPropertyDescriptor(getOptions(), property);
return descriptor ? { ...descriptor, configurable: true } : undefined;
}
}) as TinyflowOptions;
const data = parseData(getOptions().data);
const initialViewport = data?.viewport || null;
store.init(
(data as TinyflowData)?.nodes || [],
(data as TinyflowData)?.edges || [],
data?.nodes || [],
data?.edges || [],
initialViewport,
);
setContext('tinyflow_options', options);
setContext('tinyflow_options', contextOptions);
</script>
<SvelteFlowProvider>
<TinyflowCore {onInit} />
<TinyflowCore onInit={props.onInit} />
</SvelteFlowProvider>

View File

@@ -46,8 +46,6 @@
const { onInit }: { onInit: any; [key: string]: any } = $props();
const svelteFlow = useSvelteFlow();
onInit(svelteFlow);
let showEdgePanel = $state(false);
let currentEdge = $state<Edge | null>(null);
let nodePickerVisible = $state(false);
@@ -342,7 +340,7 @@
{ duration: 180 }
);
} else {
svelteFlow.zoomTo(zoom, { duration: 180 });
(svelteFlow as any).zoomTo?.(zoom, { duration: 180 });
}
}
}
@@ -726,6 +724,7 @@
}
onMount(() => {
onInit(svelteFlow);
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
repairOrphanParentNodes();
if (!readonly) {

View File

@@ -1,18 +1,30 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import type {MyHTMLButtonAttributes} from './types';
import type {Snippet} from 'svelte';
const { children, primary, onclick, ...rest }: MyHTMLButtonAttributes & {
children?: Snippet;
primary?: boolean;
} = $props();
const {
children,
primary,
onclick,
class: className = '',
style = '',
...rest
}: Omit<MyHTMLButtonAttributes, 'class' | 'style'> & {
children?: Snippet;
primary?: boolean;
class?: string;
style?: string;
} = $props();
</script>
<button
type="button"
{...rest}
onclick={onclick}
class="tf-btn {primary?'tf-btn-primary':''} nopan nodrag {rest.class}"
{style}
class="tf-btn {primary?'tf-btn-primary':''} nopan nodrag {className}"
>
{@render children?.()}
</button>

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import type {MyHTMLInputAttributes} from './types';

View File

@@ -1,17 +1,30 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import {Button, Input} from './index';
import type {MyHTMLAttributes} from './types';
const { placeholder, label, value, buttonText = "选择...",onChosen, ...rest }: {
placeholder?: string;
label?: any;
value?: any;
buttonText?:string
onChosen?: (value?: any, label?: any, event?: Event) => void,
} & MyHTMLAttributes = $props();
const {
placeholder,
label,
value,
buttonText = "选择...",
onChosen,
class: className = '',
style = '',
...rest
}: Omit<MyHTMLAttributes, 'class' | 'style'> & {
placeholder?: string;
label?: any;
value?: any;
buttonText?: string;
onChosen?: (value?: any, label?: any, event?: Event) => void;
class?: string;
style?: string;
} = $props();
</script>
<div {...rest} class="tf-chosen nopan nodrag {rest.class}">
<div {...rest} {style} class="tf-chosen nopan nodrag {className}">
<input type="hidden" value={value}>
<Input value={label} {placeholder} style="flex-grow: 1;" disabled/>
<Button onclick={(e)=>{

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import type {Snippet} from 'svelte';
import {Render} from './index';

View File

@@ -1,6 +1,28 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import type {MyHTMLInputAttributes} from './types';
const { ...rest }: MyHTMLInputAttributes = $props();
const {
class: className = '',
style = '',
type = 'text',
variant = 'default',
value,
...rest
}: Omit<MyHTMLInputAttributes, 'class' | 'style' | 'type' | 'value'> & {
class?: string;
style?: string;
type?: string;
value?: unknown;
variant?: 'default' | 'borderless';
} = $props();
</script>
<input type="text" spellcheck="false" {...rest} class="tf-input nopan nodrag {rest.class}" />
<input
{type}
spellcheck="false"
{...rest}
{value}
{style}
class="tf-input {variant === 'borderless' ? 'tf-input-borderless' : ''} nopan nodrag {className}"
/>

View File

@@ -1,11 +1,24 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import Button from './button.svelte';
import type {MyHTMLButtonAttributes} from './types';
const { ...rest }: MyHTMLButtonAttributes = $props();
const {
class: className = '',
style,
...rest
}: MyHTMLButtonAttributes & {
class?: string | null;
style?: string | null;
} = $props();
</script>
<Button {...rest} class="input-btn-more {rest.class}">
<Button
{...rest}
class="input-btn-more {className}"
style={style ?? undefined}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M4.5 10.5C3.675 10.5 3 11.175 3 12C3 12.825 3.675 13.5 4.5 13.5C5.325 13.5 6 12.825 6 12C6 11.175 5.325 10.5 4.5 10.5ZM19.5 10.5C18.675 10.5 18 11.175 18 12C18 12.825 18.675 13.5 19.5 13.5C20.325 13.5 21 12.825 21 12C21 11.175 20.325 10.5 19.5 10.5ZM12 10.5C11.175 10.5 10.5 11.175 10.5 12C10.5 12.825 11.175 13.5 12 13.5C12.825 13.5 13.5 12.825 13.5 12C13.5 11.175 12.825 10.5 12 10.5Z"></path>

View File

@@ -29,8 +29,9 @@
let floatingRef: any = $state();
let hoveredItem: SelectItem | null = $state(null);
let isOpen = $state(false);
let selectedItem = $state<SelectItem | null>(null);
let selectedItem = $derived.by(() => {
$effect(() => {
let found: SelectItem | null = null;
const findItem = (items: SelectItem[]) => {
for (const it of items) {
@@ -41,8 +42,15 @@
}
};
findItem(refOptions);
return found;
selectedItem = found;
});
let selectedNodeType = $derived(selectedItem?.nodeType);
let selectedNodeIcon = $derived(
selectedNodeType ? nodeIcons[selectedNodeType] : undefined,
);
let selectedLabel = $derived(
selectedItem?.displayLabel || selectedItem?.label || '',
);
function closeMenu() {
floatingRef?.hide();
@@ -111,12 +119,12 @@
<div class="tf-mixed-box tf-mixed-ref-box">
{#if selectedItem}
<div class="tf-mixed-sel-val">
{#if selectedItem.nodeType && nodeIcons[selectedItem.nodeType]}
{#if selectedNodeType && selectedNodeIcon}
<span class="tf-mixed-val-icon">
{@html nodeIcons[selectedItem.nodeType]}
{@html selectedNodeIcon}
</span>
{/if}
<span class="tf-mixed-val-name">{selectedItem.displayLabel || selectedItem.label}</span>
<span class="tf-mixed-val-name">{selectedLabel}</span>
</div>
{:else}
<div class="tf-mixed-placeholder">{placeholder}</div>

View File

@@ -1,6 +1,10 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
let { target } = $props();
if (typeof target === 'undefined') target = "undefined";
const props = $props<{ target?: string | (() => unknown) }>();
const target = $derived(
typeof props.target === 'undefined' ? 'undefined' : props.target
);
</script>
@@ -9,4 +13,3 @@
{:else }
{@html target}
{/if}

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import {FloatingTrigger} from './index';
import type {SelectItem} from '#types';
@@ -541,6 +543,7 @@
color: var(--tf-text-secondary);
line-height: 1.3;
margin-top: 2px;
line-clamp: 2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;

View File

@@ -1,14 +1,27 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import type {MyHTMLTextareaAttributes} from './types';
const { value, height, autoHeight = true, maxHeight, onHeightChange, ...rest }: MyHTMLTextareaAttributes & {
value?: any;
height?: string | number;
autoHeight?: boolean;
rows?: number;
maxHeight?: string | number;
onHeightChange?: (height: string) => void;
} = $props();
const {
value,
height,
autoHeight = true,
maxHeight,
onHeightChange,
class: className = '',
style = '',
...rest
}: Omit<MyHTMLTextareaAttributes, 'value' | 'class' | 'style'> & {
value?: unknown;
class?: string;
style?: string;
height?: string | number;
autoHeight?: boolean;
rows?: number;
maxHeight?: string | number;
onHeightChange?: (height: string) => void;
} = $props();
let textareaEl: HTMLTextAreaElement;
let defaultHeight: number;
@@ -69,6 +82,7 @@
bind:this={textareaEl}
spellcheck="false"
{...rest}
{style}
oninput={(e)=>{
adjustHeight();
rest.oninput?.(e);
@@ -77,5 +91,5 @@
adjustHeight();
rest.onchange?.(e);
}}
class="tf-textarea nodrag nowheel {rest.class}"
>{value || ""}</textarea>
class="tf-textarea nodrag nowheel {className}"
>{String(value ?? '')}</textarea>

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import {onMount} from 'svelte';
import {Compartment, EditorState, type Transaction} from '@codemirror/state';

View File

@@ -79,7 +79,7 @@
});
triggerObject?.hide();
};
let selectItems = useRefOptions(useChildrenOnly);
let selectItems = useRefOptions(() => useChildrenOnly === true);
</script>
@@ -175,4 +175,3 @@
</style>

View File

@@ -275,13 +275,6 @@
align-items: center;
}
.input-item-inline {
display: flex;
align-items: center;
font-size: 12px;
color: var(--tf-text-secondary);
}
.input-more-setting {
display: flex;
flex-direction: column;

View File

@@ -14,6 +14,7 @@
import {useCopyNode} from '../utils/useCopyNode.svelte';
import {getOptions} from '../utils/NodeUtils';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import type {TinyflowNodeData} from '#types';
const {
data,
@@ -32,7 +33,7 @@
wrapperClass = '',
onCollapse
}: {
data: NodeProps['data'],
data: TinyflowNodeData,
id?: NodeProps['id'],
icon?: Snippet,
handle?: Snippet,
@@ -49,7 +50,7 @@
onCollapse?: (key: string) => void,
} = $props();
let activeKeys = data.expand ? ['key'] : [];
const activeKeys = $derived.by(() => data.expand ? ['key'] : []);
const { updateNodeData, getNode } = useSvelteFlow();
const updateNodeInternals = useUpdateNodeInternals();

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import {defaultKeymap, history, historyKeymap} from '@codemirror/commands';
import {Compartment, EditorState, type Extension} from '@codemirror/state';

View File

@@ -82,7 +82,7 @@
});
triggerObject?.hide();
};
let selectItems = useRefOptions(useChildrenOnly);
let selectItems = useRefOptions(() => useChildrenOnly === true);
</script>

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
@@ -7,11 +9,11 @@
import {useAddParameter} from '../utils/useAddParameter.svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import {onMount} from 'svelte';
import type {SelectItem} from '#types';
import type {SelectItem, TinyflowNodeData} from '#types';
import CodeScriptEditor from '../core/CodeScriptEditor.svelte';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {
@@ -669,7 +671,20 @@
style="flex: 1; height: 100%; min-width: 0;"
/>
{:else}
<div class="condition-branch-name" style="padding-left: 8px" ondblclick={(e) => { e.stopPropagation(); editingBranchId = branch.id; }}>
<div
class="condition-branch-name"
style="padding-left: 8px"
role="button"
tabindex="0"
ondblclick={(e) => { e.stopPropagation(); editingBranchId = branch.id; }}
onkeydown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
editingBranchId = branch.id;
}
}}
>
{branch.label || `条件分支${index + 1}`}
</div>
{/if}
@@ -1181,26 +1196,6 @@
height: 32px;
}
.input-more-setting {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: var(--tf-bg-surface);
border: 1px solid var(--tf-border-color);
border-radius: 6px;
width: 180px;
box-shadow: var(--tf-shadow-medium);
.input-more-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--tf-text-secondary);
}
}
.condition-expression-panel {
min-height: 140px;
display: flex;

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
@@ -7,12 +9,12 @@
import {useAddParameter} from '../utils/useAddParameter.svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
import type {Parameter} from '#types';
import type {Parameter, TinyflowNodeData} from '#types';
import {deepEqual} from '#components/utils/deepEqual';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();
@@ -94,7 +96,7 @@
message: e.target.value
}
})
}} value={data.message as string||""} />
}} value={String(data.message || '')} />
</div>
@@ -128,5 +130,3 @@
</style>

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type Node, type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
@@ -8,17 +10,24 @@
import {getOptions} from '../utils/NodeUtils';
import OutputDefList from '../core/OutputDefList.svelte';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import type {TinyflowNodeData} from '#types';
import {onMount} from 'svelte';
const { data, ...rest }: {
data: NodeProps['data'],
const props = $props<{
data: TinyflowNodeData,
[key: string]: any
} = $props();
}>();
const data = $derived(props.data);
const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const flowInstance = useSvelteFlow();
const { updateNodeData: updateNodeDataInner } = flowInstance;
const getRestProps = () => {
const { data: _data, ...rest } = props;
return rest;
};
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
@@ -49,19 +58,22 @@
updateFormValue(form, (event.target as any)?.value);
};
const node = {
...rest,
const buildNode = (nextData = data) => ({
...getRestProps(),
id: currentNodeId,
data
} as Node;
data: nextData
} as Node);
const externalElement = document.createElement('div') as HTMLElement;
const options = getOptions();
const customNode = options.customNodes![rest.type as string];
customNode.render?.(externalElement, node, flowInstance);
const forms = customNode.forms;
const customNode = $derived.by(() => options.customNodes![getRestProps().type as string]);
const forms = $derived.by(() => customNode.forms);
let container: HTMLElement;
onMount(() => {
customNode.render?.(externalElement, buildNode(), flowInstance);
});
let container = $state<HTMLElement | null>(null);
$effect(() => {
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
if (data.expand && container) {
@@ -71,7 +83,7 @@
$effect(() => {
if (data) {
customNode.onUpdate?.(externalElement, { ...node, data });
customNode.onUpdate?.(externalElement, buildNode(data));
}
});
@@ -94,7 +106,7 @@
</script>
<NodeWrapper data={{...data, description: customNode.description}} {...rest}>
<NodeWrapper data={{...data, description: customNode.description}} {...getRestProps()}>
{#snippet icon()}
{@html customNode.icon}
@@ -285,6 +297,7 @@
background: var(--tf-slider-track-bg);
border-radius: 2px;
outline: none;
appearance: none;
-webkit-appearance: none;
}

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps} from '@xyflow/svelte';
@@ -5,9 +7,10 @@
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import RefParameterList from '../core/RefParameterList.svelte';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import type {TinyflowNodeData} from '#types';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();
@@ -46,5 +49,3 @@
}
</style>

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
@@ -10,9 +12,10 @@
// 添加生命周期函数
import {onMount} from 'svelte';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import type {TinyflowNodeData} from '#types';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();
@@ -77,7 +80,7 @@
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const bodyMethods = new Set(['post', 'put', 'delete', 'patch']);
const showBodyConfig = $derived(bodyMethods.has((data.method || '').toLowerCase()));
const showBodyConfig = $derived(bodyMethods.has(String(data.method || '').toLowerCase()));
</script>
@@ -215,7 +218,7 @@
rows={5}
style="width: 100%"
parameters={editorParameters}
placeholder="请输入 json 信息" value={data.bodyJson}
placeholder="请输入 json 信息" value={String(data.bodyJson || '')}
oninput={(e:any)=>{
updateNodeData(currentNodeId,{
bodyJson: e.target.value,
@@ -233,7 +236,7 @@
rows={5}
style="width: 100%"
parameters={editorParameters}
placeholder="请输入请求信息" value={data.bodyRaw}
placeholder="请输入请求信息" value={String(data.bodyRaw || '')}
oninput={(e:any)=>{
updateNodeData(currentNodeId,{
bodyRaw: e.target.value,

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
@@ -7,7 +9,7 @@
import {getOptions} from '../utils/NodeUtils';
import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import type {Parameter, SelectItem} from '#types';
import type {Parameter, SelectItem, TinyflowNodeData} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import {
buildEditorReferenceParameters,
@@ -17,7 +19,7 @@
} from '../../utils/workflowNodeFields';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
@@ -177,16 +179,6 @@
}
});
$effect(() => {
const validIds = new Set(queryContextOptions.map((item) => String(item.value)));
const normalized = queryContextNodeIds.filter((item) => validIds.has(item));
if (normalized.length !== queryContextNodeIds.length) {
updateNodeData(currentNodeId, {
queryContextNodeIds: normalized
});
}
});
const toggleQueryContextNode = (nodeId: string) => {
const currentIds = [...queryContextNodeIds];
const exists = currentIds.includes(nodeId);
@@ -404,6 +396,7 @@
background: var(--tf-slider-track-bg);
border-radius: 2px;
outline: none;
appearance: none;
-webkit-appearance: none;
}

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {Handle, type NodeProps, Position} from '@xyflow/svelte';
@@ -5,9 +7,10 @@
import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import type {TinyflowNodeData} from '#types';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
@@ -8,11 +10,11 @@
import {getOptions} from '../utils/NodeUtils';
import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import type {SelectItem} from '#types';
import type {SelectItem, TinyflowNodeData} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();
@@ -109,9 +111,9 @@
placeholder="请输入关键字"
style="width: 100%"
parameters={editorParameters}
value={data.keyword || ''}
oninput={(e)=>{
const newValue = e.target.value;
value={String(data.keyword || '')}
oninput={(e: Event)=>{
const newValue = (e.target as HTMLInputElement).value;
updateNodeData(currentNodeId, ()=>{
return {
keyword: newValue
@@ -127,10 +129,10 @@
mode="input"
placeholder="搜索的数据条数"
style="width: 100%"
value={data.limit || ''}
value={String(data.limit || '')}
parameters={editorParameters}
oninput={(e)=>{
const newValue = e.target.value;
oninput={(e: Event)=>{
const newValue = (e.target as HTMLInputElement).value;
updateNodeData(currentNodeId, ()=>{
return {
limit: newValue
@@ -170,4 +172,3 @@
}
</style>

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {Heading, Input, Textarea} from '../base';
@@ -12,9 +14,10 @@
normalizeStartNodeData,
normalizeStartFormMeta,
} from '../../utils/workflowNodeFields';
import type {TinyflowNodeData} from '#types';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();

View File

@@ -1,3 +1,5 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
@@ -7,9 +9,10 @@
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import type {TinyflowNodeData} from '#types';
const { data, ...rest }: {
data: NodeProps['data'],
data: TinyflowNodeData,
[key: string]: any
} = $props();
@@ -62,7 +65,7 @@
template: e.target.value
}
})
}} value={data.template ||""} />
}} value={String(data.template || '')} />
</div>
@@ -89,5 +92,3 @@
</style>

View File

@@ -144,10 +144,16 @@ const nodeToOptions = (
}
};
export const useRefOptions: any = (useChildrenOnly: boolean = false) => {
export const useRefOptions: any = (
useChildrenOnly: boolean | (() => boolean) = false,
) => {
const currentNodeId = getCurrentNodeId();
const currentNode = useNodesData(currentNodeId);
const { nodes, edges, nodeLookup } = $derived(useStore());
const isChildrenOnly = () =>
typeof useChildrenOnly === 'function'
? useChildrenOnly()
: useChildrenOnly;
let selectItems = $derived.by(() => {
const resultOptions = [];
@@ -158,7 +164,7 @@ export const useRefOptions: any = (useChildrenOnly: boolean = false) => {
//通过 nodeLookup.get 才会得到有 parentId 的 node
const cNode = nodeLookup.get(currentNodeId)!;
if (useChildrenOnly) {
if (isChildrenOnly()) {
for (const node of nodes) {
const nodeIsChildren = node.parentId === currentNode.current.id;
if (nodeIsChildren) {

View File

@@ -4,6 +4,7 @@ import type { Node, useSvelteFlow } from '@xyflow/svelte';
export type TinyflowData = Partial<
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
>;
export type TinyflowNodeData = Record<string, any>;
export type TinyflowTheme = 'light' | 'dark';
export type SelectItem = {

View File

@@ -897,7 +897,10 @@ describe('workflow node fields', () => {
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
true,
);
expect(normalizedWorkflow.nodes[0]?.data?.startFormSchema?.[0]?.key).toBe(
expect(
(normalizedWorkflow.nodes[0]?.data as Record<string, any> | undefined)
?.startFormSchema?.[0]?.key,
).toBe(
'user_input',
);
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);

View File

@@ -1237,7 +1237,10 @@ function replaceStartFieldReferenceValue(
return value;
}
function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
export function collectDownstreamNodeIds(
rootNodeId: string,
edges: Edge[],
) {
const nodeIds = new Set<string>();
const visit = (nodeId: string) => {
@@ -1281,7 +1284,7 @@ export function renameStartFieldReferencesInNodes(
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
const nextNodes = nodes.map((node) => {
const nextNodes: Node[] = nodes.map((node) => {
if (node.id === normalizedStartNodeId) {
return node;
}
@@ -1295,7 +1298,7 @@ export function renameStartFieldReferencesInNodes(
}
return {
...node,
data: nextData,
data: nextData ?? {},
};
});