Compare commits
3 Commits
5827ecde42
...
8d07b306e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d07b306e5 | |||
| ba70fec9a5 | |||
| 31b0e21d3d |
@@ -0,0 +1,37 @@
|
|||||||
|
package tech.easyflow.admin.model.dashboard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台智能体趋势点位。
|
||||||
|
*/
|
||||||
|
public class DashboardAssistantTrendPointVo {
|
||||||
|
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
private Long sessionTotal;
|
||||||
|
|
||||||
|
public String getKey() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKey(String key) {
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLabel(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSessionTotal() {
|
||||||
|
return sessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionTotal(Long sessionTotal) {
|
||||||
|
this.sessionTotal = sessionTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tech.easyflow.admin.model.dashboard;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台智能体趋势序列。
|
||||||
|
*/
|
||||||
|
public class DashboardAssistantTrendSeriesVo {
|
||||||
|
|
||||||
|
private BigInteger assistantId;
|
||||||
|
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
private Long totalSessionCount;
|
||||||
|
|
||||||
|
private List<DashboardAssistantTrendPointVo> points;
|
||||||
|
|
||||||
|
public BigInteger getAssistantId() {
|
||||||
|
return assistantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAssistantId(BigInteger assistantId) {
|
||||||
|
this.assistantId = assistantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLabel(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTotalSessionCount() {
|
||||||
|
return totalSessionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalSessionCount(Long totalSessionCount) {
|
||||||
|
this.totalSessionCount = totalSessionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DashboardAssistantTrendPointVo> getPoints() {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPoints(List<DashboardAssistantTrendPointVo> points) {
|
||||||
|
this.points = points;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ public class DashboardDistributionItemVo {
|
|||||||
|
|
||||||
private Long sessionTotal;
|
private Long sessionTotal;
|
||||||
|
|
||||||
|
private Double avgSessionPerUser;
|
||||||
|
|
||||||
private Double avgMessagePerSession;
|
private Double avgMessagePerSession;
|
||||||
|
|
||||||
public String getKey() {
|
public String getKey() {
|
||||||
@@ -119,6 +121,14 @@ public class DashboardDistributionItemVo {
|
|||||||
this.sessionTotal = sessionTotal;
|
this.sessionTotal = sessionTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Double getAvgSessionPerUser() {
|
||||||
|
return avgSessionPerUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvgSessionPerUser(Double avgSessionPerUser) {
|
||||||
|
this.avgSessionPerUser = avgSessionPerUser;
|
||||||
|
}
|
||||||
|
|
||||||
public Double getAvgMessagePerSession() {
|
public Double getAvgMessagePerSession() {
|
||||||
return avgMessagePerSession;
|
return avgMessagePerSession;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package tech.easyflow.admin.model.dashboard;
|
|||||||
public class DashboardOverviewQuery {
|
public class DashboardOverviewQuery {
|
||||||
|
|
||||||
private String range;
|
private String range;
|
||||||
|
private String startDate;
|
||||||
|
private String endDate;
|
||||||
|
|
||||||
public String getRange() {
|
public String getRange() {
|
||||||
return range;
|
return range;
|
||||||
@@ -14,4 +16,20 @@ public class DashboardOverviewQuery {
|
|||||||
public void setRange(String range) {
|
public void setRange(String range) {
|
||||||
this.range = range;
|
this.range = range;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getStartDate() {
|
||||||
|
return startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartDate(String startDate) {
|
||||||
|
this.startDate = startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEndDate() {
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndDate(String endDate) {
|
||||||
|
this.endDate = endDate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ public class DashboardOverviewVo {
|
|||||||
|
|
||||||
private List<DashboardTrendItemVo> trends;
|
private List<DashboardTrendItemVo> trends;
|
||||||
|
|
||||||
|
private List<DashboardAssistantTrendSeriesVo> assistantTrends;
|
||||||
|
|
||||||
private List<DashboardDistributionItemVo> distribution;
|
private List<DashboardDistributionItemVo> distribution;
|
||||||
|
|
||||||
|
private List<DashboardUserRankItemVo> userRanks;
|
||||||
|
|
||||||
private DashboardOverviewQuery query;
|
private DashboardOverviewQuery query;
|
||||||
|
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
@@ -52,6 +56,22 @@ public class DashboardOverviewVo {
|
|||||||
this.distribution = distribution;
|
this.distribution = distribution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<DashboardAssistantTrendSeriesVo> getAssistantTrends() {
|
||||||
|
return assistantTrends;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAssistantTrends(List<DashboardAssistantTrendSeriesVo> assistantTrends) {
|
||||||
|
this.assistantTrends = assistantTrends;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DashboardUserRankItemVo> getUserRanks() {
|
||||||
|
return userRanks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserRanks(List<DashboardUserRankItemVo> userRanks) {
|
||||||
|
this.userRanks = userRanks;
|
||||||
|
}
|
||||||
|
|
||||||
public DashboardOverviewQuery getQuery() {
|
public DashboardOverviewQuery getQuery() {
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public class DashboardSummaryVo {
|
|||||||
|
|
||||||
private Long activeAssistantTotal;
|
private Long activeAssistantTotal;
|
||||||
|
|
||||||
|
private Long chatActiveUserTotal;
|
||||||
|
|
||||||
public Long getUserTotal() {
|
public Long getUserTotal() {
|
||||||
return userTotal;
|
return userTotal;
|
||||||
}
|
}
|
||||||
@@ -84,4 +86,12 @@ public class DashboardSummaryVo {
|
|||||||
public void setActiveAssistantTotal(Long activeAssistantTotal) {
|
public void setActiveAssistantTotal(Long activeAssistantTotal) {
|
||||||
this.activeAssistantTotal = activeAssistantTotal;
|
this.activeAssistantTotal = activeAssistantTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getChatActiveUserTotal() {
|
||||||
|
return chatActiveUserTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatActiveUserTotal(Long chatActiveUserTotal) {
|
||||||
|
this.chatActiveUserTotal = chatActiveUserTotal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tech.easyflow.admin.model.dashboard;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台用户活跃排行项。
|
||||||
|
*/
|
||||||
|
public class DashboardUserRankItemVo {
|
||||||
|
|
||||||
|
private BigInteger userId;
|
||||||
|
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
private Long sessionTotal;
|
||||||
|
|
||||||
|
private Long messageTotal;
|
||||||
|
|
||||||
|
private Long assistantTotal;
|
||||||
|
|
||||||
|
public BigInteger getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(BigInteger userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLabel(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSessionTotal() {
|
||||||
|
return sessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionTotal(Long sessionTotal) {
|
||||||
|
this.sessionTotal = sessionTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMessageTotal() {
|
||||||
|
return messageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessageTotal(Long messageTotal) {
|
||||||
|
this.messageTotal = messageTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getAssistantTotal() {
|
||||||
|
return assistantTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAssistantTotal(Long assistantTotal) {
|
||||||
|
this.assistantTotal = assistantTotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,17 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
import tech.easyflow.admin.model.dashboard.DashboardChatStatusVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendPointVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
|
||||||
import tech.easyflow.admin.service.dashboard.DashboardService;
|
import tech.easyflow.admin.service.dashboard.DashboardService;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -22,6 +27,7 @@ import tech.easyflow.common.entity.LoginAccount;
|
|||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.system.entity.SysAccountRole;
|
import tech.easyflow.system.entity.SysAccountRole;
|
||||||
import tech.easyflow.system.entity.SysRole;
|
import tech.easyflow.system.entity.SysRole;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
import tech.easyflow.system.service.SysAccountRoleService;
|
import tech.easyflow.system.service.SysAccountRoleService;
|
||||||
import tech.easyflow.system.service.SysRoleService;
|
import tech.easyflow.system.service.SysRoleService;
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -49,6 +56,8 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
|
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.systemDefault();
|
||||||
private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用";
|
private static final String CHAT_UNAVAILABLE_MESSAGE = "聊天数据不可用";
|
||||||
private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
|
private static final int DEFAULT_ASSISTANT_RANK_LIMIT = 5;
|
||||||
|
private static final int DEFAULT_ASSISTANT_TREND_LIMIT = 8;
|
||||||
|
private static final int DEFAULT_USER_RANK_LIMIT = 5;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountRoleService sysAccountRoleService;
|
private SysAccountRoleService sysAccountRoleService;
|
||||||
@@ -56,6 +65,9 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
@Resource
|
@Resource
|
||||||
private SysRoleService sysRoleService;
|
private SysRoleService sysRoleService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private SysAccountService sysAccountService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ChatDashboardQueryService chatDashboardQueryService;
|
private ChatDashboardQueryService chatDashboardQueryService;
|
||||||
|
|
||||||
@@ -76,10 +88,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
result.setSummary(summary);
|
result.setSummary(summary);
|
||||||
result.setChatStatus(chatPayload.chatStatus);
|
result.setChatStatus(chatPayload.chatStatus);
|
||||||
result.setTrends(chatPayload.trends);
|
result.setTrends(chatPayload.trends);
|
||||||
|
result.setAssistantTrends(chatPayload.assistantTrends);
|
||||||
result.setDistribution(chatPayload.distribution);
|
result.setDistribution(chatPayload.distribution);
|
||||||
|
result.setUserRanks(chatPayload.userRanks);
|
||||||
|
|
||||||
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
DashboardOverviewQuery normalizedQuery = new DashboardOverviewQuery();
|
||||||
normalizedQuery.setRange(context.range);
|
normalizedQuery.setRange(context.range);
|
||||||
|
normalizedQuery.setStartDate(context.queryStartDate);
|
||||||
|
normalizedQuery.setEndDate(context.queryEndDate);
|
||||||
result.setQuery(normalizedQuery);
|
result.setQuery(normalizedQuery);
|
||||||
result.setUpdatedAt(new Date());
|
result.setUpdatedAt(new Date());
|
||||||
return result;
|
return result;
|
||||||
@@ -101,6 +117,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(0L);
|
summary.setChatMessageTotal(0L);
|
||||||
summary.setChatSessionTotal(0L);
|
summary.setChatSessionTotal(0L);
|
||||||
summary.setActiveAssistantTotal(0L);
|
summary.setActiveAssistantTotal(0L);
|
||||||
|
summary.setChatActiveUserTotal(0L);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +139,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(0L);
|
summary.setChatMessageTotal(0L);
|
||||||
summary.setChatSessionTotal(0L);
|
summary.setChatSessionTotal(0L);
|
||||||
summary.setActiveAssistantTotal(0L);
|
summary.setActiveAssistantTotal(0L);
|
||||||
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
|
summary.setChatActiveUserTotal(0L);
|
||||||
|
return new ChatDashboardPayload(
|
||||||
|
chatStatus,
|
||||||
|
new ArrayList<>(),
|
||||||
|
new ArrayList<>(),
|
||||||
|
new ArrayList<>(),
|
||||||
|
new ArrayList<>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDate startDate = context.startTime.toLocalDate();
|
LocalDate startDate = context.startTime.toLocalDate();
|
||||||
@@ -132,20 +156,34 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(chatSummary.messageTotal());
|
summary.setChatMessageTotal(chatSummary.messageTotal());
|
||||||
summary.setChatSessionTotal(chatSummary.sessionTotal());
|
summary.setChatSessionTotal(chatSummary.sessionTotal());
|
||||||
summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal());
|
summary.setActiveAssistantTotal(chatSummary.activeAssistantTotal());
|
||||||
|
summary.setChatActiveUserTotal(chatSummary.chatActiveUserTotal());
|
||||||
|
|
||||||
List<ChatDashboardTrend> rawTrends = "today".equals(context.range)
|
List<ChatDashboardTrend> rawTrends = useHourlyBuckets(context)
|
||||||
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
|
? chatDashboardQueryService.queryHourlyTrends(context.startTime, context.endTime, context.tenantFilterId)
|
||||||
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
|
: chatDashboardQueryService.queryTrends(startDate, endDate, context.tenantFilterId);
|
||||||
List<DashboardTrendItemVo> trends = buildTrendItems(context.range, rawTrends);
|
List<DashboardTrendItemVo> trends = buildTrendItems(context, rawTrends);
|
||||||
|
|
||||||
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
|
List<ChatAssistantUsageRank> rawRanks = chatDashboardQueryService.queryAssistantUsageRanks(
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
context.tenantFilterId,
|
context.tenantFilterId,
|
||||||
DEFAULT_ASSISTANT_RANK_LIMIT
|
DEFAULT_ASSISTANT_TREND_LIMIT
|
||||||
);
|
);
|
||||||
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(rawRanks);
|
List<DashboardAssistantTrendSeriesVo> assistantTrends = buildAssistantTrendSeries(
|
||||||
return new ChatDashboardPayload(chatStatus, trends, distribution);
|
context,
|
||||||
|
rawRanks
|
||||||
|
);
|
||||||
|
List<DashboardDistributionItemVo> distribution = buildAssistantDistribution(
|
||||||
|
rawRanks.subList(0, Math.min(DEFAULT_ASSISTANT_RANK_LIMIT, rawRanks.size()))
|
||||||
|
);
|
||||||
|
List<ChatActiveUserRank> rawUserRanks = chatDashboardQueryService.queryActiveUserRanks(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
context.tenantFilterId,
|
||||||
|
DEFAULT_USER_RANK_LIMIT
|
||||||
|
);
|
||||||
|
List<DashboardUserRankItemVo> userRanks = buildUserRanks(rawUserRanks);
|
||||||
|
return new ChatDashboardPayload(chatStatus, trends, assistantTrends, distribution, userRanks);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
log.warn("加载工作台聊天统计失败,已降级为不可用状态,range={}, tenantId={}",
|
||||||
context.range,
|
context.range,
|
||||||
@@ -156,7 +194,14 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
summary.setChatMessageTotal(0L);
|
summary.setChatMessageTotal(0L);
|
||||||
summary.setChatSessionTotal(0L);
|
summary.setChatSessionTotal(0L);
|
||||||
summary.setActiveAssistantTotal(0L);
|
summary.setActiveAssistantTotal(0L);
|
||||||
return new ChatDashboardPayload(chatStatus, new ArrayList<>(), new ArrayList<>());
|
summary.setChatActiveUserTotal(0L);
|
||||||
|
return new ChatDashboardPayload(
|
||||||
|
chatStatus,
|
||||||
|
new ArrayList<>(),
|
||||||
|
new ArrayList<>(),
|
||||||
|
new ArrayList<>(),
|
||||||
|
new ArrayList<>()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +212,12 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
* @param rawTrends 原始趋势
|
* @param rawTrends 原始趋势
|
||||||
* @return 趋势项
|
* @return 趋势项
|
||||||
*/
|
*/
|
||||||
private List<DashboardTrendItemVo> buildTrendItems(String range, List<ChatDashboardTrend> rawTrends) {
|
private List<DashboardTrendItemVo> buildTrendItems(DashboardQueryContext context, List<ChatDashboardTrend> rawTrends) {
|
||||||
List<TimeBucket> buckets = buildBuckets(range);
|
List<TimeBucket> buckets = buildBuckets(
|
||||||
|
context.range,
|
||||||
|
context.startTime.toLocalDate(),
|
||||||
|
context.endTime.toLocalDate().minusDays(1)
|
||||||
|
);
|
||||||
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
|
Map<String, ChatDashboardTrend> trendMap = new HashMap<>();
|
||||||
for (ChatDashboardTrend rawTrend : rawTrends) {
|
for (ChatDashboardTrend rawTrend : rawTrends) {
|
||||||
trendMap.put(rawTrend.bucketKey(), rawTrend);
|
trendMap.put(rawTrend.bucketKey(), rawTrend);
|
||||||
@@ -180,7 +229,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
DashboardTrendItemVo item = new DashboardTrendItemVo();
|
DashboardTrendItemVo item = new DashboardTrendItemVo();
|
||||||
item.setKey(bucket.key);
|
item.setKey(bucket.key);
|
||||||
item.setLabel(bucket.label);
|
item.setLabel(bucket.label);
|
||||||
item.setActiveUserTotal(0L);
|
item.setActiveUserTotal(trend == null ? 0L : trend.activeUserTotal());
|
||||||
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
|
item.setChatMessageTotal(trend == null ? 0L : trend.messageTotal());
|
||||||
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
item.setChatSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
||||||
items.add(item);
|
items.add(item);
|
||||||
@@ -201,10 +250,100 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
|
item.setKey(rank.assistantId() == null ? "" : rank.assistantId().toString());
|
||||||
item.setAssistantId(rank.assistantId());
|
item.setAssistantId(rank.assistantId());
|
||||||
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
|
item.setLabel(resolveAssistantLabel(rank.assistantId(), rank.assistantName()));
|
||||||
|
item.setUserTotal(rank.userTotal());
|
||||||
item.setMessageTotal(rank.messageTotal());
|
item.setMessageTotal(rank.messageTotal());
|
||||||
item.setSessionTotal(rank.sessionTotal());
|
item.setSessionTotal(rank.sessionTotal());
|
||||||
item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal()));
|
item.setAvgMessagePerSession(calculateAvg(rank.messageTotal(), rank.sessionTotal()));
|
||||||
item.setValue(rank.messageTotal());
|
item.setAvgSessionPerUser(calculateAvg(rank.sessionTotal(), rank.userTotal()));
|
||||||
|
item.setValue(rank.sessionTotal());
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建智能体活跃趋势序列。
|
||||||
|
*
|
||||||
|
* @param context 查询上下文
|
||||||
|
* @param ranks 智能体排行
|
||||||
|
* @return 趋势序列
|
||||||
|
*/
|
||||||
|
private List<DashboardAssistantTrendSeriesVo> buildAssistantTrendSeries(DashboardQueryContext context,
|
||||||
|
List<ChatAssistantUsageRank> ranks) {
|
||||||
|
if (ranks == null || ranks.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
List<TimeBucket> buckets = buildBuckets(
|
||||||
|
context.range,
|
||||||
|
context.startTime.toLocalDate(),
|
||||||
|
context.endTime.toLocalDate().minusDays(1)
|
||||||
|
);
|
||||||
|
Map<BigInteger, ChatAssistantUsageRank> rankMap = new LinkedHashMap<>();
|
||||||
|
for (ChatAssistantUsageRank rank : ranks) {
|
||||||
|
rankMap.putIfAbsent(rank.assistantId(), rank);
|
||||||
|
}
|
||||||
|
List<BigInteger> assistantIds = new ArrayList<>(rankMap.keySet());
|
||||||
|
List<ChatAssistantSessionTrend> rawAssistantTrends = useHourlyBuckets(context)
|
||||||
|
? chatDashboardQueryService.queryAssistantHourlyTrends(
|
||||||
|
context.startTime,
|
||||||
|
context.endTime,
|
||||||
|
context.tenantFilterId,
|
||||||
|
assistantIds
|
||||||
|
)
|
||||||
|
: chatDashboardQueryService.queryAssistantTrends(
|
||||||
|
context.startTime.toLocalDate(),
|
||||||
|
context.endTime.toLocalDate(),
|
||||||
|
context.tenantFilterId,
|
||||||
|
assistantIds
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<BigInteger, Map<String, ChatAssistantSessionTrend>> trendMap = new HashMap<>();
|
||||||
|
for (ChatAssistantSessionTrend rawTrend : rawAssistantTrends) {
|
||||||
|
trendMap.computeIfAbsent(rawTrend.assistantId(), key -> new HashMap<>())
|
||||||
|
.put(rawTrend.bucketKey(), rawTrend);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DashboardAssistantTrendSeriesVo> seriesList = new ArrayList<>(rankMap.size());
|
||||||
|
for (ChatAssistantUsageRank rank : rankMap.values()) {
|
||||||
|
BigInteger assistantId = rank.assistantId();
|
||||||
|
DashboardAssistantTrendSeriesVo series = new DashboardAssistantTrendSeriesVo();
|
||||||
|
series.setAssistantId(assistantId);
|
||||||
|
series.setLabel(resolveAssistantLabel(assistantId, rank.assistantName()));
|
||||||
|
series.setTotalSessionCount(rank.sessionTotal());
|
||||||
|
|
||||||
|
List<DashboardAssistantTrendPointVo> points = new ArrayList<>(buckets.size());
|
||||||
|
Map<String, ChatAssistantSessionTrend> assistantTrendMap =
|
||||||
|
trendMap.getOrDefault(assistantId, new HashMap<>());
|
||||||
|
for (TimeBucket bucket : buckets) {
|
||||||
|
ChatAssistantSessionTrend trend = assistantTrendMap.get(bucket.key);
|
||||||
|
DashboardAssistantTrendPointVo point = new DashboardAssistantTrendPointVo();
|
||||||
|
point.setKey(bucket.key);
|
||||||
|
point.setLabel(bucket.label);
|
||||||
|
point.setSessionTotal(trend == null ? 0L : trend.sessionTotal());
|
||||||
|
points.add(point);
|
||||||
|
}
|
||||||
|
series.setPoints(points);
|
||||||
|
seriesList.add(series);
|
||||||
|
}
|
||||||
|
return seriesList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建用户活跃排行。
|
||||||
|
*
|
||||||
|
* @param ranks 原始排行数据
|
||||||
|
* @return 页面排行项
|
||||||
|
*/
|
||||||
|
private List<DashboardUserRankItemVo> buildUserRanks(List<ChatActiveUserRank> ranks) {
|
||||||
|
List<DashboardUserRankItemVo> items = new ArrayList<>(ranks.size());
|
||||||
|
Map<BigInteger, String> displayNameMap = resolveUserDisplayNameMap(ranks);
|
||||||
|
for (ChatActiveUserRank rank : ranks) {
|
||||||
|
DashboardUserRankItemVo item = new DashboardUserRankItemVo();
|
||||||
|
item.setUserId(rank.userId());
|
||||||
|
item.setLabel(resolveUserLabel(rank.userId(), rank.userAccount(), displayNameMap));
|
||||||
|
item.setSessionTotal(rank.sessionTotal());
|
||||||
|
item.setMessageTotal(rank.messageTotal());
|
||||||
|
item.setAssistantTotal(rank.assistantTotal());
|
||||||
items.add(item);
|
items.add(item);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
@@ -311,12 +450,28 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
if ("today".equals(context.range)) {
|
if ("today".equals(context.range)) {
|
||||||
context.startTime = LocalDateTime.of(today, LocalTime.MIN);
|
context.startTime = LocalDateTime.of(today, LocalTime.MIN);
|
||||||
context.endTime = context.startTime.plusDays(1);
|
context.endTime = context.startTime.plusDays(1);
|
||||||
|
context.queryStartDate = today.toString();
|
||||||
|
context.queryEndDate = today.toString();
|
||||||
} else if ("7d".equals(context.range)) {
|
} else if ("7d".equals(context.range)) {
|
||||||
context.startTime = LocalDateTime.of(today.minusDays(6), LocalTime.MIN);
|
context.startTime = LocalDateTime.of(today.minusDays(6), LocalTime.MIN);
|
||||||
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
||||||
} else {
|
context.queryStartDate = today.minusDays(6).toString();
|
||||||
|
context.queryEndDate = today.toString();
|
||||||
|
} else if ("30d".equals(context.range)) {
|
||||||
context.startTime = LocalDateTime.of(today.minusDays(29), LocalTime.MIN);
|
context.startTime = LocalDateTime.of(today.minusDays(29), LocalTime.MIN);
|
||||||
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
context.endTime = LocalDateTime.of(today.plusDays(1), LocalTime.MIN);
|
||||||
|
context.queryStartDate = today.minusDays(29).toString();
|
||||||
|
context.queryEndDate = today.toString();
|
||||||
|
} else {
|
||||||
|
LocalDate customStartDate = parseRequiredDate(query == null ? null : query.getStartDate(), "开始日期不能为空");
|
||||||
|
LocalDate customEndDate = parseRequiredDate(query == null ? null : query.getEndDate(), "结束日期不能为空");
|
||||||
|
if (customStartDate.isAfter(customEndDate)) {
|
||||||
|
throw new BusinessException("开始日期不能晚于结束日期");
|
||||||
|
}
|
||||||
|
context.startTime = LocalDateTime.of(customStartDate, LocalTime.MIN);
|
||||||
|
context.endTime = LocalDateTime.of(customEndDate.plusDays(1), LocalTime.MIN);
|
||||||
|
context.queryStartDate = customStartDate.toString();
|
||||||
|
context.queryEndDate = customEndDate.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId();
|
context.tenantFilterId = context.superAdmin || loginAccount == null ? null : loginAccount.getTenantId();
|
||||||
@@ -359,7 +514,7 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
if (!StringUtils.hasText(range)) {
|
if (!StringUtils.hasText(range)) {
|
||||||
return "7d";
|
return "7d";
|
||||||
}
|
}
|
||||||
if ("today".equals(range) || "7d".equals(range) || "30d".equals(range)) {
|
if ("today".equals(range) || "7d".equals(range) || "30d".equals(range) || "custom".equals(range)) {
|
||||||
return range;
|
return range;
|
||||||
}
|
}
|
||||||
throw new BusinessException("不支持的时间范围: " + range);
|
throw new BusinessException("不支持的时间范围: " + range);
|
||||||
@@ -371,13 +526,19 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
* @param range 时间范围
|
* @param range 时间范围
|
||||||
* @return 时间桶列表
|
* @return 时间桶列表
|
||||||
*/
|
*/
|
||||||
private List<TimeBucket> buildBuckets(String range) {
|
private List<TimeBucket> buildBuckets(String range, LocalDate customStartDate, LocalDate customEndDate) {
|
||||||
List<TimeBucket> buckets = new ArrayList<>();
|
List<TimeBucket> buckets = new ArrayList<>();
|
||||||
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
|
LocalDate today = LocalDate.now(DEFAULT_ZONE_ID);
|
||||||
if ("today".equals(range)) {
|
boolean hourlyBucket = "today".equals(range)
|
||||||
|
|| ("custom".equals(range)
|
||||||
|
&& customStartDate != null
|
||||||
|
&& customEndDate != null
|
||||||
|
&& customStartDate.equals(customEndDate));
|
||||||
|
if (hourlyBucket) {
|
||||||
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00");
|
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00");
|
||||||
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00");
|
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00");
|
||||||
LocalDateTime start = LocalDateTime.of(today, LocalTime.MIN);
|
LocalDate bucketDate = "today".equals(range) ? today : customStartDate;
|
||||||
|
LocalDateTime start = LocalDateTime.of(bucketDate, LocalTime.MIN);
|
||||||
for (int hour = 0; hour < 24; hour++) {
|
for (int hour = 0; hour < 24; hour++) {
|
||||||
LocalDateTime current = start.plusHours(hour);
|
LocalDateTime current = start.plusHours(hour);
|
||||||
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
||||||
@@ -385,10 +546,20 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
int days = "7d".equals(range) ? 7 : 30;
|
|
||||||
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
DateTimeFormatter keyFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("MM-dd");
|
DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("MM-dd");
|
||||||
LocalDate start = today.minusDays(days - 1L);
|
int days;
|
||||||
|
LocalDate start;
|
||||||
|
if ("7d".equals(range)) {
|
||||||
|
days = 7;
|
||||||
|
start = today.minusDays(6);
|
||||||
|
} else if ("30d".equals(range)) {
|
||||||
|
days = 30;
|
||||||
|
start = today.minusDays(29);
|
||||||
|
} else {
|
||||||
|
start = customStartDate;
|
||||||
|
days = (int) java.time.temporal.ChronoUnit.DAYS.between(customStartDate, customEndDate) + 1;
|
||||||
|
}
|
||||||
for (int i = 0; i < days; i++) {
|
for (int i = 0; i < days; i++) {
|
||||||
LocalDate current = start.plusDays(i);
|
LocalDate current = start.plusDays(i);
|
||||||
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
buckets.add(new TimeBucket(current.format(keyFormatter), current.format(labelFormatter)));
|
||||||
@@ -396,6 +567,38 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return buckets;
|
return buckets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前上下文是否按小时构建趋势。
|
||||||
|
*
|
||||||
|
* @param context 查询上下文
|
||||||
|
* @return true 表示按小时
|
||||||
|
*/
|
||||||
|
private boolean useHourlyBuckets(DashboardQueryContext context) {
|
||||||
|
return "today".equals(context.range)
|
||||||
|
|| ("custom".equals(context.range)
|
||||||
|
&& context.startTime != null
|
||||||
|
&& context.endTime != null
|
||||||
|
&& context.startTime.toLocalDate().equals(context.endTime.toLocalDate().minusDays(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析必填日期参数。
|
||||||
|
*
|
||||||
|
* @param dateText 日期文本
|
||||||
|
* @param errorMessage 错误信息
|
||||||
|
* @return 日期
|
||||||
|
*/
|
||||||
|
private LocalDate parseRequiredDate(String dateText, String errorMessage) {
|
||||||
|
if (!StringUtils.hasText(dateText)) {
|
||||||
|
throw new BusinessException(errorMessage);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(dateText.trim());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("日期格式不正确: " + dateText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 LocalDateTime 转换为 Date。
|
* 把 LocalDateTime 转换为 Date。
|
||||||
*
|
*
|
||||||
@@ -436,6 +639,24 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return (double) messageTotal / (double) sessionTotal;
|
return (double) messageTotal / (double) sessionTotal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量解析用户展示名称映射。
|
||||||
|
*
|
||||||
|
* @param ranks 活跃排行
|
||||||
|
* @return 名称映射
|
||||||
|
*/
|
||||||
|
private Map<BigInteger, String> resolveUserDisplayNameMap(List<ChatActiveUserRank> ranks) {
|
||||||
|
List<BigInteger> userIds = ranks.stream()
|
||||||
|
.map(ChatActiveUserRank::userId)
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
return sysAccountService.resolveDisplayNameMap(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析智能体展示名称。
|
* 解析智能体展示名称。
|
||||||
*
|
*
|
||||||
@@ -450,6 +671,27 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
return assistantId == null ? "智能体-未知" : "智能体-" + assistantId;
|
return assistantId == null ? "智能体-未知" : "智能体-" + assistantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析用户展示名称。
|
||||||
|
*
|
||||||
|
* @param userId 用户 ID
|
||||||
|
* @param userAccount 聊天侧账号快照
|
||||||
|
* @param displayNameMap 系统账号名称映射
|
||||||
|
* @return 展示名称
|
||||||
|
*/
|
||||||
|
private String resolveUserLabel(BigInteger userId, String userAccount, Map<BigInteger, String> displayNameMap) {
|
||||||
|
if (userId != null) {
|
||||||
|
String displayName = displayNameMap.get(userId);
|
||||||
|
if (StringUtils.hasText(displayName) && !displayName.equals(userId.toString())) {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(userAccount)) {
|
||||||
|
return userAccount.trim();
|
||||||
|
}
|
||||||
|
return userId == null ? "用户-未知" : "用户-" + userId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作台查询上下文。
|
* 工作台查询上下文。
|
||||||
*/
|
*/
|
||||||
@@ -460,6 +702,8 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
private boolean superAdmin;
|
private boolean superAdmin;
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
|
private String queryStartDate;
|
||||||
|
private String queryEndDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -481,7 +725,9 @@ public class DashboardServiceImpl implements DashboardService {
|
|||||||
private record ChatDashboardPayload(
|
private record ChatDashboardPayload(
|
||||||
DashboardChatStatusVo chatStatus,
|
DashboardChatStatusVo chatStatus,
|
||||||
List<DashboardTrendItemVo> trends,
|
List<DashboardTrendItemVo> trends,
|
||||||
List<DashboardDistributionItemVo> distribution
|
List<DashboardAssistantTrendSeriesVo> assistantTrends,
|
||||||
|
List<DashboardDistributionItemVo> distribution,
|
||||||
|
List<DashboardUserRankItemVo> userRanks
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ package tech.easyflow.admin.service.dashboard.impl;
|
|||||||
|
|
||||||
import org.testng.Assert;
|
import org.testng.Assert;
|
||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardAssistantTrendSeriesVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardDistributionItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardOverviewQuery;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
import tech.easyflow.admin.model.dashboard.DashboardSummaryVo;
|
||||||
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
import tech.easyflow.admin.model.dashboard.DashboardTrendItemVo;
|
||||||
|
import tech.easyflow.admin.model.dashboard.DashboardUserRankItemVo;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
import tech.easyflow.chatlog.service.ChatDashboardQueryService;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
@@ -19,9 +26,13 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +67,7 @@ public class DashboardServiceImplTest {
|
|||||||
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
|
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
|
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L));
|
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertEquals(summary.getChatActiveUserTotal(), Long.valueOf(0L));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,12 +84,19 @@ public class DashboardServiceImplTest {
|
|||||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
|
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
|
||||||
when(chatDashboardQueryService.available()).thenReturn(true);
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||||
.thenReturn(new ChatDashboardSummary(3L, 9L, 1L));
|
.thenReturn(new ChatDashboardSummary(3L, 9L, 1L, 2L));
|
||||||
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
|
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
|
||||||
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L)));
|
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 3L, 9L, 2L)));
|
||||||
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||||
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 3L, 9L)));
|
.thenReturn(List.of(new ChatAssistantUsageRank(BigInteger.ONE, "", 2L, 3L, 9L)));
|
||||||
|
when(chatDashboardQueryService.queryAssistantHourlyTrends(any(), any(), any(), any()))
|
||||||
|
.thenReturn(List.of(new ChatAssistantSessionTrend(BigInteger.ONE, "", currentHourKey, 3L)));
|
||||||
|
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(2), "demo-user", 3L, 9L, 1L)));
|
||||||
|
SysAccountService sysAccountService = mock(SysAccountService.class);
|
||||||
|
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(2), "演示用户"));
|
||||||
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
setField(service, "sysAccountService", sysAccountService);
|
||||||
|
|
||||||
Object context = newContext("today", BigInteger.valueOf(9));
|
Object context = newContext("today", BigInteger.valueOf(9));
|
||||||
DashboardSummaryVo summary = new DashboardSummaryVo();
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
@@ -85,7 +104,10 @@ public class DashboardServiceImplTest {
|
|||||||
|
|
||||||
Object chatStatus = readField(payload, "chatStatus");
|
Object chatStatus = readField(payload, "chatStatus");
|
||||||
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
||||||
|
List<DashboardAssistantTrendSeriesVo> assistantTrends =
|
||||||
|
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
|
||||||
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
List<DashboardDistributionItemVo> distribution = (List<DashboardDistributionItemVo>) readField(payload, "distribution");
|
||||||
|
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
|
||||||
|
|
||||||
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
Assert.assertTrue(Boolean.TRUE.equals(invokeGetter(chatStatus, "getAvailable")));
|
||||||
Assert.assertEquals(trends.size(), 24);
|
Assert.assertEquals(trends.size(), 24);
|
||||||
@@ -94,13 +116,232 @@ public class DashboardServiceImplTest {
|
|||||||
Assert.assertEquals(trends.get(10).getLabel(), "10:00");
|
Assert.assertEquals(trends.get(10).getLabel(), "10:00");
|
||||||
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
|
Assert.assertEquals(trends.get(10).getChatMessageTotal(), Long.valueOf(9L));
|
||||||
Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L));
|
Assert.assertEquals(trends.get(10).getChatSessionTotal(), Long.valueOf(3L));
|
||||||
|
Assert.assertEquals(trends.get(10).getActiveUserTotal(), Long.valueOf(2L));
|
||||||
Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L));
|
Assert.assertEquals(trends.get(11).getChatMessageTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
|
Assert.assertEquals(trends.get(11).getChatSessionTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
|
Assert.assertEquals(summary.getChatMessageTotal(), Long.valueOf(9L));
|
||||||
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
|
Assert.assertEquals(summary.getChatSessionTotal(), Long.valueOf(3L));
|
||||||
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L));
|
Assert.assertEquals(summary.getActiveAssistantTotal(), Long.valueOf(1L));
|
||||||
|
Assert.assertEquals(summary.getChatActiveUserTotal(), Long.valueOf(2L));
|
||||||
|
Assert.assertEquals(assistantTrends.size(), 1);
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getLabel(), "智能体-1");
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getTotalSessionCount(), Long.valueOf(3L));
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 24);
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().get(10).getSessionTotal(), Long.valueOf(3L));
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().get(11).getSessionTotal(), Long.valueOf(0L));
|
||||||
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
|
Assert.assertEquals(distribution.get(0).getLabel(), "智能体-1");
|
||||||
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
Assert.assertEquals(distribution.get(0).getAvgMessagePerSession(), Double.valueOf(3D));
|
||||||
|
Assert.assertEquals(distribution.get(0).getAvgSessionPerUser(), Double.valueOf(1.5D));
|
||||||
|
Assert.assertEquals(userRanks.get(0).getLabel(), "演示用户");
|
||||||
|
Assert.assertEquals(userRanks.get(0).getAssistantTotal(), Long.valueOf(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当系统账号名称仅回退为纯 ID 时,仍优先继续回退到聊天侧账号。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldFallbackToUserAccountWhenSystemDisplayNameIsOnlyId() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
|
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||||
|
.thenReturn(new ChatDashboardSummary(1L, 1L, 1L, 1L));
|
||||||
|
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of(new ChatActiveUserRank(BigInteger.valueOf(9), "chat-user", 1L, 1L, 1L)));
|
||||||
|
SysAccountService sysAccountService = mock(SysAccountService.class);
|
||||||
|
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of(BigInteger.valueOf(9), "9"));
|
||||||
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
setField(service, "sysAccountService", sysAccountService);
|
||||||
|
|
||||||
|
Object context = newContext("today", BigInteger.ONE);
|
||||||
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
|
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||||
|
|
||||||
|
List<DashboardUserRankItemVo> userRanks = (List<DashboardUserRankItemVo>) readField(payload, "userRanks");
|
||||||
|
Assert.assertEquals(userRanks.get(0).getLabel(), "chat-user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证日趋势会保留 assistantId 为空的排行项,并补齐 7 天点位。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldBuildDailyAssistantTrendSeriesForRankedAssistants() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
|
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||||
|
.thenReturn(new ChatDashboardSummary(4L, 12L, 2L, 3L));
|
||||||
|
when(chatDashboardQueryService.queryTrends(any(), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
new ChatAssistantUsageRank(BigInteger.ONE, "助手-A", 3L, 4L, 12L),
|
||||||
|
new ChatAssistantUsageRank(null, "未知助手", 1L, 2L, 4L)
|
||||||
|
));
|
||||||
|
when(chatDashboardQueryService.queryAssistantTrends(any(), any(), any(), any()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().minusDays(6).toString(), 2L),
|
||||||
|
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-A", LocalDate.now().toString(), 4L),
|
||||||
|
new ChatAssistantSessionTrend(null, "未知助手", LocalDate.now().minusDays(3).toString(), 2L)
|
||||||
|
));
|
||||||
|
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
SysAccountService sysAccountService = mock(SysAccountService.class);
|
||||||
|
when(sysAccountService.resolveDisplayNameMap(any())).thenReturn(Map.of());
|
||||||
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
setField(service, "sysAccountService", sysAccountService);
|
||||||
|
|
||||||
|
Object context = newContext("7d", BigInteger.ONE);
|
||||||
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
|
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||||
|
|
||||||
|
List<DashboardAssistantTrendSeriesVo> assistantTrends =
|
||||||
|
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
|
||||||
|
Assert.assertEquals(assistantTrends.size(), 2);
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getLabel(), "助手-A");
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 7);
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().get(0).getSessionTotal(), Long.valueOf(2L));
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().get(6).getSessionTotal(), Long.valueOf(4L));
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().get(1).getSessionTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertNull(assistantTrends.get(1).getAssistantId());
|
||||||
|
Assert.assertEquals(assistantTrends.get(1).getLabel(), "未知助手");
|
||||||
|
Assert.assertEquals(assistantTrends.get(1).getPoints().get(3).getSessionTotal(), Long.valueOf(2L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证自定义单天范围按小时桶构建。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldBuildHourlyTrendForCustomSingleDayRange() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
|
LocalDate customDate = LocalDate.now().minusDays(2);
|
||||||
|
String currentHourKey = LocalDateTime.of(customDate, LocalTime.of(8, 0))
|
||||||
|
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:00:00"));
|
||||||
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
|
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||||
|
.thenReturn(new ChatDashboardSummary(2L, 6L, 1L, 1L));
|
||||||
|
when(chatDashboardQueryService.queryHourlyTrends(any(), any(), any()))
|
||||||
|
.thenReturn(List.of(new ChatDashboardTrend(currentHourKey, 2L, 6L, 1L)));
|
||||||
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
setField(service, "sysAccountService", mock(SysAccountService.class));
|
||||||
|
|
||||||
|
Object context = newContext(
|
||||||
|
"custom",
|
||||||
|
BigInteger.ONE,
|
||||||
|
LocalDateTime.of(customDate, LocalTime.MIN),
|
||||||
|
LocalDateTime.of(customDate.plusDays(1), LocalTime.MIN)
|
||||||
|
);
|
||||||
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
|
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||||
|
|
||||||
|
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
||||||
|
Assert.assertEquals(trends.size(), 24);
|
||||||
|
Assert.assertEquals(trends.get(8).getKey(), currentHourKey);
|
||||||
|
Assert.assertEquals(trends.get(8).getActiveUserTotal(), Long.valueOf(1L));
|
||||||
|
Assert.assertEquals(trends.get(9).getChatSessionTotal(), Long.valueOf(0L));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证自定义多天范围按天桶构建,并保留查询日期。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBuildDailyBucketsForCustomMultiDayRangeContext() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
DashboardOverviewQuery query = new DashboardOverviewQuery();
|
||||||
|
query.setRange("custom");
|
||||||
|
query.setStartDate("2026-05-01");
|
||||||
|
query.setEndDate("2026-05-03");
|
||||||
|
|
||||||
|
Object context = invokeBuildContext(service, query);
|
||||||
|
Assert.assertEquals(readField(context, "range"), "custom");
|
||||||
|
Assert.assertEquals(
|
||||||
|
readField(context, "startTime"),
|
||||||
|
LocalDateTime.of(LocalDate.of(2026, 5, 1), LocalTime.MIN)
|
||||||
|
);
|
||||||
|
Assert.assertEquals(
|
||||||
|
readField(context, "endTime"),
|
||||||
|
LocalDateTime.of(LocalDate.of(2026, 5, 4), LocalTime.MIN)
|
||||||
|
);
|
||||||
|
Assert.assertEquals(readField(context, "queryStartDate"), "2026-05-01");
|
||||||
|
Assert.assertEquals(readField(context, "queryEndDate"), "2026-05-03");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证近 30 天趋势补齐完整 30 个桶,并按 Top 8 请求智能体活跃排行。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void shouldBuildThirtyDayBucketsAndRequestTopEightAssistantRanks() throws Exception {
|
||||||
|
DashboardServiceImpl service = new DashboardServiceImpl();
|
||||||
|
ChatDashboardQueryService chatDashboardQueryService = mock(ChatDashboardQueryService.class);
|
||||||
|
LocalDate startDate = LocalDate.now().minusDays(29);
|
||||||
|
LocalDate endDate = LocalDate.now();
|
||||||
|
when(chatDashboardQueryService.available()).thenReturn(true);
|
||||||
|
when(chatDashboardQueryService.querySummary(any(), any(), any()))
|
||||||
|
.thenReturn(new ChatDashboardSummary(10L, 20L, 8L, 4L));
|
||||||
|
when(chatDashboardQueryService.queryTrends(any(), any(), any()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
new ChatDashboardTrend(startDate.toString(), 3L, 6L, 2L),
|
||||||
|
new ChatDashboardTrend(endDate.toString(), 7L, 14L, 4L)
|
||||||
|
));
|
||||||
|
when(chatDashboardQueryService.queryAssistantUsageRanks(any(), any(), any(), eq(8)))
|
||||||
|
.thenReturn(IntStream.rangeClosed(1, 8)
|
||||||
|
.mapToObj(index -> new ChatAssistantUsageRank(
|
||||||
|
BigInteger.valueOf(index),
|
||||||
|
"助手-" + index,
|
||||||
|
index,
|
||||||
|
index * 2L,
|
||||||
|
index * 4L
|
||||||
|
))
|
||||||
|
.toList());
|
||||||
|
when(chatDashboardQueryService.queryAssistantTrends(any(), any(), any(), any()))
|
||||||
|
.thenReturn(List.of(
|
||||||
|
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", startDate.toString(), 2L),
|
||||||
|
new ChatAssistantSessionTrend(BigInteger.ONE, "助手-1", endDate.toString(), 4L)
|
||||||
|
));
|
||||||
|
when(chatDashboardQueryService.queryActiveUserRanks(any(), any(), any(), any(Integer.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
setField(service, "chatDashboardQueryService", chatDashboardQueryService);
|
||||||
|
setField(service, "sysAccountService", mock(SysAccountService.class));
|
||||||
|
|
||||||
|
Object context = newContext("30d", BigInteger.ONE);
|
||||||
|
DashboardSummaryVo summary = new DashboardSummaryVo();
|
||||||
|
Object payload = invokeBuildChatPayload(service, context, summary);
|
||||||
|
|
||||||
|
List<DashboardTrendItemVo> trends = (List<DashboardTrendItemVo>) readField(payload, "trends");
|
||||||
|
List<DashboardAssistantTrendSeriesVo> assistantTrends =
|
||||||
|
(List<DashboardAssistantTrendSeriesVo>) readField(payload, "assistantTrends");
|
||||||
|
Assert.assertEquals(trends.size(), 30);
|
||||||
|
Assert.assertEquals(trends.get(0).getKey(), startDate.toString());
|
||||||
|
Assert.assertEquals(trends.get(0).getChatSessionTotal(), Long.valueOf(3L));
|
||||||
|
Assert.assertEquals(trends.get(29).getKey(), endDate.toString());
|
||||||
|
Assert.assertEquals(trends.get(29).getChatMessageTotal(), Long.valueOf(14L));
|
||||||
|
Assert.assertEquals(trends.get(1).getChatSessionTotal(), Long.valueOf(0L));
|
||||||
|
Assert.assertEquals(assistantTrends.size(), 8);
|
||||||
|
Assert.assertEquals(assistantTrends.get(0).getPoints().size(), 30);
|
||||||
|
verify(chatDashboardQueryService).queryAssistantUsageRanks(any(), any(), any(), eq(8));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +353,28 @@ public class DashboardServiceImplTest {
|
|||||||
* @throws Exception 反射失败
|
* @throws Exception 反射失败
|
||||||
*/
|
*/
|
||||||
private Object newContext(String range, BigInteger tenantId) throws Exception {
|
private Object newContext(String range, BigInteger tenantId) throws Exception {
|
||||||
|
return newContext(
|
||||||
|
range,
|
||||||
|
tenantId,
|
||||||
|
LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN),
|
||||||
|
LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造查询上下文。
|
||||||
|
*
|
||||||
|
* @param range 时间范围
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param startTime 开始时间
|
||||||
|
* @param endTime 结束时间
|
||||||
|
* @return 查询上下文实例
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private Object newContext(String range,
|
||||||
|
BigInteger tenantId,
|
||||||
|
LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime) throws Exception {
|
||||||
Class<?> contextClass = Class.forName(
|
Class<?> contextClass = Class.forName(
|
||||||
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
|
"tech.easyflow.admin.service.dashboard.impl.DashboardServiceImpl$DashboardQueryContext"
|
||||||
);
|
);
|
||||||
@@ -120,11 +383,29 @@ public class DashboardServiceImplTest {
|
|||||||
Object context = constructor.newInstance();
|
Object context = constructor.newInstance();
|
||||||
setField(context, "range", range);
|
setField(context, "range", range);
|
||||||
setField(context, "tenantFilterId", tenantId);
|
setField(context, "tenantFilterId", tenantId);
|
||||||
setField(context, "startTime", LocalDateTime.of(LocalDate.now(), java.time.LocalTime.MIN));
|
setField(context, "startTime", startTime);
|
||||||
setField(context, "endTime", LocalDateTime.of(LocalDate.now().plusDays(1), java.time.LocalTime.MIN));
|
setField(context, "endTime", endTime);
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用私有上下文构建方法。
|
||||||
|
*
|
||||||
|
* @param service service
|
||||||
|
* @param query 查询参数
|
||||||
|
* @return 上下文
|
||||||
|
* @throws Exception 反射失败
|
||||||
|
*/
|
||||||
|
private Object invokeBuildContext(DashboardServiceImpl service, DashboardOverviewQuery query) throws Exception {
|
||||||
|
Method method = DashboardServiceImpl.class.getDeclaredMethod(
|
||||||
|
"buildContext",
|
||||||
|
LoginAccount.class,
|
||||||
|
DashboardOverviewQuery.class
|
||||||
|
);
|
||||||
|
method.setAccessible(true);
|
||||||
|
return method.invoke(service, null, query);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用私有聊天载荷组装方法。
|
* 调用私有聊天载荷组装方法。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package tech.easyflow.chatlog.domain.dto;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天活跃用户排行项。
|
||||||
|
*
|
||||||
|
* @param userId 用户 ID
|
||||||
|
* @param userAccount 用户账号快照
|
||||||
|
* @param sessionTotal 会话总数
|
||||||
|
* @param messageTotal 消息总数
|
||||||
|
* @param assistantTotal 使用智能体数
|
||||||
|
*/
|
||||||
|
public record ChatActiveUserRank(BigInteger userId,
|
||||||
|
String userAccount,
|
||||||
|
long sessionTotal,
|
||||||
|
long messageTotal,
|
||||||
|
long assistantTotal) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package tech.easyflow.chatlog.domain.dto;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体会话趋势项。
|
||||||
|
*
|
||||||
|
* @param assistantId 智能体 ID
|
||||||
|
* @param assistantName 智能体名称
|
||||||
|
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
||||||
|
* @param sessionTotal 会话总数
|
||||||
|
*/
|
||||||
|
public record ChatAssistantSessionTrend(BigInteger assistantId,
|
||||||
|
String assistantName,
|
||||||
|
String bucketKey,
|
||||||
|
long sessionTotal) {
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ import java.math.BigInteger;
|
|||||||
*
|
*
|
||||||
* @param assistantId 智能体 ID
|
* @param assistantId 智能体 ID
|
||||||
* @param assistantName 智能体名称
|
* @param assistantName 智能体名称
|
||||||
|
* @param userTotal 使用用户数
|
||||||
* @param sessionTotal 会话总数
|
* @param sessionTotal 会话总数
|
||||||
* @param messageTotal 消息总数
|
* @param messageTotal 消息总数
|
||||||
*/
|
*/
|
||||||
public record ChatAssistantUsageRank(BigInteger assistantId,
|
public record ChatAssistantUsageRank(BigInteger assistantId,
|
||||||
String assistantName,
|
String assistantName,
|
||||||
|
long userTotal,
|
||||||
long sessionTotal,
|
long sessionTotal,
|
||||||
long messageTotal) {
|
long messageTotal) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ package tech.easyflow.chatlog.domain.dto;
|
|||||||
* @param sessionTotal 会话总数
|
* @param sessionTotal 会话总数
|
||||||
* @param messageTotal 消息总数
|
* @param messageTotal 消息总数
|
||||||
* @param activeAssistantTotal 活跃智能体数
|
* @param activeAssistantTotal 活跃智能体数
|
||||||
|
* @param chatActiveUserTotal AI 活跃用户数
|
||||||
*/
|
*/
|
||||||
public record ChatDashboardSummary(long sessionTotal, long messageTotal, long activeAssistantTotal) {
|
public record ChatDashboardSummary(long sessionTotal,
|
||||||
|
long messageTotal,
|
||||||
|
long activeAssistantTotal,
|
||||||
|
long chatActiveUserTotal) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建空汇总结果。
|
* 创建空汇总结果。
|
||||||
@@ -15,6 +19,6 @@ public record ChatDashboardSummary(long sessionTotal, long messageTotal, long ac
|
|||||||
* @return 空汇总结果
|
* @return 空汇总结果
|
||||||
*/
|
*/
|
||||||
public static ChatDashboardSummary empty() {
|
public static ChatDashboardSummary empty() {
|
||||||
return new ChatDashboardSummary(0L, 0L, 0L);
|
return new ChatDashboardSummary(0L, 0L, 0L, 0L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ package tech.easyflow.chatlog.domain.dto;
|
|||||||
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
* @param bucketKey 趋势桶标识,日趋势为 yyyy-MM-dd,小时趋势为 yyyy-MM-dd HH:00:00
|
||||||
* @param sessionTotal 会话总数
|
* @param sessionTotal 会话总数
|
||||||
* @param messageTotal 消息总数
|
* @param messageTotal 消息总数
|
||||||
|
* @param activeUserTotal AI 活跃用户数
|
||||||
*/
|
*/
|
||||||
public record ChatDashboardTrend(String bucketKey, long sessionTotal, long messageTotal) {
|
public record ChatDashboardTrend(String bucketKey,
|
||||||
|
long sessionTotal,
|
||||||
|
long messageTotal,
|
||||||
|
long activeUserTotal) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import org.springframework.beans.factory.ObjectProvider;
|
|||||||
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
|
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -26,6 +28,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -222,21 +225,24 @@ public class ChatAnalyticalDBRepository {
|
|||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT ")
|
sql.append("SELECT ")
|
||||||
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
|
||||||
.append("uniqExact(dimension_id) AS active_assistant_total ")
|
.append("uniqExact(agg.assistant_id) AS active_assistant_total, ")
|
||||||
.append("FROM dws_chat_assistant_day ")
|
.append("uniqExact(agg.user_id) AS active_user_total ")
|
||||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
.append("FROM dws_chat_session_day agg ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
|
||||||
|
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
|
||||||
args.add(startDate.toString());
|
args.add(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
|
|
||||||
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
|
ChatDashboardSummary summary = analyticalDBOperations.queryOne(
|
||||||
sql.toString(),
|
sql.toString(),
|
||||||
(rs, rowNum) -> new ChatDashboardSummary(
|
(rs, rowNum) -> new ChatDashboardSummary(
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total"),
|
rs.getLong("message_total"),
|
||||||
rs.getLong("active_assistant_total")
|
rs.getLong("active_assistant_total"),
|
||||||
|
rs.getLong("active_user_total")
|
||||||
),
|
),
|
||||||
args.toArray()
|
args.toArray()
|
||||||
);
|
);
|
||||||
@@ -255,22 +261,25 @@ public class ChatAnalyticalDBRepository {
|
|||||||
assertAvailable();
|
assertAvailable();
|
||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT toString(stat_date) AS bucket_key, ")
|
sql.append("SELECT toString(agg.stat_date) AS bucket_key, ")
|
||||||
.append("ifNull(sum(message_count), 0) AS message_total, ")
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
.append("ifNull(sum(session_count), 0) AS session_total ")
|
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
|
||||||
.append("FROM dws_chat_assistant_day ")
|
.append("uniqExact(agg.user_id) AS active_user_total ")
|
||||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
.append("FROM dws_chat_session_day agg ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
|
||||||
|
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
|
||||||
args.add(startDate.toString());
|
args.add(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
sql.append(" GROUP BY stat_date ORDER BY stat_date ASC");
|
sql.append(" GROUP BY agg.stat_date ORDER BY agg.stat_date ASC");
|
||||||
|
|
||||||
return analyticalDBOperations.query(
|
return analyticalDBOperations.query(
|
||||||
sql.toString(),
|
sql.toString(),
|
||||||
(rs, rowNum) -> new ChatDashboardTrend(
|
(rs, rowNum) -> new ChatDashboardTrend(
|
||||||
rs.getString("bucket_key"),
|
rs.getString("bucket_key"),
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total")
|
rs.getLong("message_total"),
|
||||||
|
rs.getLong("active_user_total")
|
||||||
),
|
),
|
||||||
args.toArray()
|
args.toArray()
|
||||||
);
|
);
|
||||||
@@ -292,7 +301,8 @@ public class ChatAnalyticalDBRepository {
|
|||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
sql.append("SELECT formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
||||||
.append("count() AS message_total, ")
|
.append("count() AS message_total, ")
|
||||||
.append("uniqExact(l.session_id) AS session_total ")
|
.append("uniqExact(l.session_id) AS session_total, ")
|
||||||
|
.append("uniqExact(l.user_id) AS active_user_total ")
|
||||||
.append("FROM ods_chat_log l ")
|
.append("FROM ods_chat_log l ")
|
||||||
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
|
||||||
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
|
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
|
||||||
@@ -306,7 +316,8 @@ public class ChatAnalyticalDBRepository {
|
|||||||
(rs, rowNum) -> new ChatDashboardTrend(
|
(rs, rowNum) -> new ChatDashboardTrend(
|
||||||
rs.getString("bucket_key"),
|
rs.getString("bucket_key"),
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total")
|
rs.getLong("message_total"),
|
||||||
|
rs.getLong("active_user_total")
|
||||||
),
|
),
|
||||||
args.toArray()
|
args.toArray()
|
||||||
);
|
);
|
||||||
@@ -329,17 +340,21 @@ public class ChatAnalyticalDBRepository {
|
|||||||
int safeLimit = Math.max(limit, 1);
|
int safeLimit = Math.max(limit, 1);
|
||||||
List<Object> args = new java.util.ArrayList<>();
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder();
|
StringBuilder sql = new StringBuilder();
|
||||||
sql.append("SELECT agg.assistant_id, snapshot.assistant_name, agg.session_total, agg.message_total ")
|
sql.append("SELECT agg.assistant_id AS assistant_id, ")
|
||||||
|
.append("snapshot.assistant_name AS assistant_name, ")
|
||||||
|
.append("agg.user_total, agg.session_total, agg.message_total ")
|
||||||
.append("FROM (")
|
.append("FROM (")
|
||||||
.append("SELECT dimension_id AS assistant_id, ")
|
.append("SELECT agg.assistant_id, ")
|
||||||
.append("ifNull(sum(session_count), 0) AS session_total, ")
|
.append("uniqExact(agg.user_id) AS user_total, ")
|
||||||
.append("ifNull(sum(message_count), 0) AS message_total ")
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
.append("FROM dws_chat_assistant_day ")
|
.append("ifNull(sum(agg.message_count), 0) AS message_total ")
|
||||||
.append("WHERE stat_date >= toDate(?) AND stat_date < toDate(?)");
|
.append("FROM dws_chat_session_day agg ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
|
||||||
|
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
|
||||||
args.add(startDate.toString());
|
args.add(startDate.toString());
|
||||||
args.add(endDate.toString());
|
args.add(endDate.toString());
|
||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
sql.append(" GROUP BY dimension_id")
|
sql.append(" GROUP BY agg.assistant_id")
|
||||||
.append(") agg ")
|
.append(") agg ")
|
||||||
.append("LEFT JOIN (")
|
.append("LEFT JOIN (")
|
||||||
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
||||||
@@ -347,7 +362,7 @@ public class ChatAnalyticalDBRepository {
|
|||||||
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
sql.append(" GROUP BY assistant_id")
|
sql.append(" GROUP BY assistant_id")
|
||||||
.append(") snapshot ON snapshot.assistant_id = agg.assistant_id ")
|
.append(") snapshot ON snapshot.assistant_id = agg.assistant_id ")
|
||||||
.append("ORDER BY agg.message_total DESC, agg.session_total DESC, agg.assistant_id ASC ")
|
.append("ORDER BY agg.session_total DESC, agg.user_total DESC, agg.message_total DESC, agg.assistant_id ASC ")
|
||||||
.append("LIMIT ?");
|
.append("LIMIT ?");
|
||||||
args.add(safeLimit);
|
args.add(safeLimit);
|
||||||
|
|
||||||
@@ -356,6 +371,7 @@ public class ChatAnalyticalDBRepository {
|
|||||||
(rs, rowNum) -> new ChatAssistantUsageRank(
|
(rs, rowNum) -> new ChatAssistantUsageRank(
|
||||||
bigInteger(rs.getObject("assistant_id")),
|
bigInteger(rs.getObject("assistant_id")),
|
||||||
rs.getString("assistant_name"),
|
rs.getString("assistant_name"),
|
||||||
|
rs.getLong("user_total"),
|
||||||
rs.getLong("session_total"),
|
rs.getLong("session_total"),
|
||||||
rs.getLong("message_total")
|
rs.getLong("message_total")
|
||||||
),
|
),
|
||||||
@@ -363,6 +379,215 @@ public class ChatAnalyticalDBRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体日趋势。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含
|
||||||
|
* @param endDate 结束日期,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
* @return 趋势列表
|
||||||
|
*/
|
||||||
|
public List<ChatAssistantSessionTrend> queryAssistantSessionTrends(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
List<BigInteger> assistantIds) {
|
||||||
|
assertAvailable();
|
||||||
|
if (assistantIds == null || assistantIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("SELECT agg.assistant_id AS assistant_id, ")
|
||||||
|
.append("snapshot.assistant_name AS assistant_name, ")
|
||||||
|
.append("toString(agg.stat_date) AS bucket_key, ")
|
||||||
|
.append("uniqExact(agg.dimension_id) AS session_total ")
|
||||||
|
.append("FROM dws_chat_session_day agg ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
|
||||||
|
.append("LEFT JOIN (")
|
||||||
|
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
||||||
|
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
|
sql.append(" GROUP BY assistant_id")
|
||||||
|
.append(") snapshot ON snapshot.assistant_id = agg.assistant_id ")
|
||||||
|
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
|
||||||
|
args.add(startDate.toString());
|
||||||
|
args.add(endDate.toString());
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
|
appendAssistantIdFilter(sql, args, "agg.assistant_id", assistantIds);
|
||||||
|
sql.append(" GROUP BY agg.assistant_id, snapshot.assistant_name, agg.stat_date ")
|
||||||
|
.append("ORDER BY agg.stat_date ASC, agg.assistant_id ASC");
|
||||||
|
|
||||||
|
return analyticalDBOperations.query(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatAssistantSessionTrend(
|
||||||
|
bigInteger(rs.getObject("assistant_id")),
|
||||||
|
rs.getString("assistant_name"),
|
||||||
|
rs.getString("bucket_key"),
|
||||||
|
rs.getLong("session_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体小时趋势。
|
||||||
|
*
|
||||||
|
* @param startTime 开始时间,包含
|
||||||
|
* @param endTime 结束时间,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
* @return 趋势列表
|
||||||
|
*/
|
||||||
|
public List<ChatAssistantSessionTrend> queryAssistantSessionHourlyTrends(LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime,
|
||||||
|
BigInteger tenantId,
|
||||||
|
List<BigInteger> assistantIds) {
|
||||||
|
assertAvailable();
|
||||||
|
if (assistantIds == null || assistantIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<Object> args = new ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("SELECT l.assistant_id AS assistant_id, ")
|
||||||
|
.append("snapshot.assistant_name AS assistant_name, ")
|
||||||
|
.append("formatDateTime(toStartOfHour(l.created), '%Y-%m-%d %H:00:00') AS bucket_key, ")
|
||||||
|
.append("uniqExact(l.session_id) AS session_total ")
|
||||||
|
.append("FROM ods_chat_log l ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = l.session_id ")
|
||||||
|
.append("LEFT JOIN (")
|
||||||
|
.append("SELECT assistant_id, argMax(assistant_name, modified) AS assistant_name ")
|
||||||
|
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
|
sql.append(" GROUP BY assistant_id")
|
||||||
|
.append(") snapshot ON snapshot.assistant_id = l.assistant_id ")
|
||||||
|
.append("WHERE l.created >= toDateTime(?) AND l.created < toDateTime(?)");
|
||||||
|
args.add(CH_DATE_TIME_FORMATTER.format(startTime));
|
||||||
|
args.add(CH_DATE_TIME_FORMATTER.format(endTime));
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
|
appendAssistantIdFilter(sql, args, "l.assistant_id", assistantIds);
|
||||||
|
sql.append(" GROUP BY l.assistant_id, snapshot.assistant_name, bucket_key ")
|
||||||
|
.append("ORDER BY bucket_key ASC, l.assistant_id ASC");
|
||||||
|
|
||||||
|
return analyticalDBOperations.query(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatAssistantSessionTrend(
|
||||||
|
bigInteger(rs.getObject("assistant_id")),
|
||||||
|
rs.getString("assistant_name"),
|
||||||
|
rs.getString("bucket_key"),
|
||||||
|
rs.getLong("session_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询活跃用户排行。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含
|
||||||
|
* @param endDate 结束日期,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 排行列表
|
||||||
|
*/
|
||||||
|
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
int limit) {
|
||||||
|
assertAvailable();
|
||||||
|
int safeLimit = Math.max(limit, 1);
|
||||||
|
List<Object> args = new java.util.ArrayList<>();
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
sql.append("SELECT agg.user_id AS user_id, ")
|
||||||
|
.append("snapshot.user_account AS user_account, ")
|
||||||
|
.append("agg.session_total, agg.message_total, agg.assistant_total ")
|
||||||
|
.append("FROM (")
|
||||||
|
.append("SELECT agg.user_id, ")
|
||||||
|
.append("uniqExact(agg.dimension_id) AS session_total, ")
|
||||||
|
.append("ifNull(sum(agg.message_count), 0) AS message_total, ")
|
||||||
|
.append("uniqExact(agg.assistant_id) AS assistant_total ")
|
||||||
|
.append("FROM dws_chat_session_day agg ")
|
||||||
|
.append("LEFT JOIN (SELECT id, tenant_id FROM ods_chat_session FINAL) s ON s.id = agg.dimension_id ")
|
||||||
|
.append("WHERE agg.stat_date >= toDate(?) AND agg.stat_date < toDate(?)");
|
||||||
|
args.add(startDate.toString());
|
||||||
|
args.add(endDate.toString());
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "s.tenant_id");
|
||||||
|
sql.append(" GROUP BY agg.user_id")
|
||||||
|
.append(") agg ")
|
||||||
|
.append("LEFT JOIN (")
|
||||||
|
.append("SELECT user_id, argMax(user_account, modified) AS user_account ")
|
||||||
|
.append("FROM ods_chat_session FINAL WHERE is_deleted = 0 ");
|
||||||
|
appendOptionalTenantFilter(sql, args, tenantId, "tenant_id");
|
||||||
|
sql.append(" GROUP BY user_id")
|
||||||
|
.append(") snapshot ON snapshot.user_id = agg.user_id ")
|
||||||
|
.append("ORDER BY agg.session_total DESC, agg.message_total DESC, agg.user_id ASC ")
|
||||||
|
.append("LIMIT ?");
|
||||||
|
args.add(safeLimit);
|
||||||
|
|
||||||
|
return analyticalDBOperations.query(
|
||||||
|
sql.toString(),
|
||||||
|
(rs, rowNum) -> new ChatActiveUserRank(
|
||||||
|
bigInteger(rs.getObject("user_id")),
|
||||||
|
rs.getString("user_account"),
|
||||||
|
rs.getLong("session_total"),
|
||||||
|
rs.getLong("message_total"),
|
||||||
|
rs.getLong("assistant_total")
|
||||||
|
),
|
||||||
|
args.toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加智能体 ID 过滤。
|
||||||
|
*
|
||||||
|
* @param sql SQL 构造器
|
||||||
|
* @param params 参数列表
|
||||||
|
* @param columnName 列名
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
*/
|
||||||
|
private void appendAssistantIdFilter(StringBuilder sql,
|
||||||
|
List<Object> params,
|
||||||
|
String columnName,
|
||||||
|
List<BigInteger> assistantIds) {
|
||||||
|
if (assistantIds == null || assistantIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<BigInteger> nonNullAssistantIds = new ArrayList<>();
|
||||||
|
boolean containsNullAssistantId = false;
|
||||||
|
for (BigInteger assistantId : assistantIds) {
|
||||||
|
if (assistantId == null) {
|
||||||
|
containsNullAssistantId = true;
|
||||||
|
} else {
|
||||||
|
nonNullAssistantIds.add(assistantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nonNullAssistantIds.isEmpty() && !containsNullAssistantId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.append(" AND (");
|
||||||
|
boolean hasPreviousCondition = false;
|
||||||
|
if (!nonNullAssistantIds.isEmpty()) {
|
||||||
|
sql.append(columnName).append(" IN (");
|
||||||
|
for (int i = 0; i < nonNullAssistantIds.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sql.append(", ");
|
||||||
|
}
|
||||||
|
sql.append("?");
|
||||||
|
params.add(nonNullAssistantIds.get(i));
|
||||||
|
}
|
||||||
|
sql.append(")");
|
||||||
|
hasPreviousCondition = true;
|
||||||
|
}
|
||||||
|
if (containsNullAssistantId) {
|
||||||
|
if (hasPreviousCondition) {
|
||||||
|
sql.append(" OR ");
|
||||||
|
}
|
||||||
|
sql.append(columnName).append(" IS NULL");
|
||||||
|
}
|
||||||
|
sql.append(")");
|
||||||
|
}
|
||||||
|
|
||||||
public void refreshDws(Set<LocalDate> dates) {
|
public void refreshDws(Set<LocalDate> dates) {
|
||||||
if (!enabled() || dates.isEmpty()) {
|
if (!enabled() || dates.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package tech.easyflow.chatlog.service;
|
package tech.easyflow.chatlog.service;
|
||||||
|
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -58,6 +60,48 @@ public interface ChatDashboardQueryService {
|
|||||||
BigInteger tenantId,
|
BigInteger tenantId,
|
||||||
int limit);
|
int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体日趋势。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
* @return 智能体日趋势
|
||||||
|
*/
|
||||||
|
List<ChatAssistantSessionTrend> queryAssistantTrends(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
List<BigInteger> assistantIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体小时趋势。
|
||||||
|
*
|
||||||
|
* @param startTime 开始时间,包含
|
||||||
|
* @param endTime 结束时间,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
* @return 智能体小时趋势
|
||||||
|
*/
|
||||||
|
List<ChatAssistantSessionTrend> queryAssistantHourlyTrends(LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime,
|
||||||
|
BigInteger tenantId,
|
||||||
|
List<BigInteger> assistantIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询活跃用户排行。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 活跃用户排行
|
||||||
|
*/
|
||||||
|
List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
int limit);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前分析库是否可用。
|
* 当前分析库是否可用。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.chatlog.service.impl;
|
package tech.easyflow.chatlog.service.impl;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatActiveUserRank;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantSessionTrend;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
import tech.easyflow.chatlog.domain.dto.ChatAssistantUsageRank;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardTrend;
|
||||||
@@ -93,6 +95,66 @@ public class ChatDashboardQueryServiceImpl implements ChatDashboardQueryService
|
|||||||
return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit);
|
return analyticalDBRepository.queryAssistantUsageRanks(startDate, endDate, tenantId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体日趋势。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
* @return 智能体日趋势
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ChatAssistantSessionTrend> queryAssistantTrends(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
List<BigInteger> assistantIds) {
|
||||||
|
if (!available() || assistantIds == null || assistantIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryAssistantSessionTrends(startDate, endDate, tenantId, assistantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询智能体小时趋势。
|
||||||
|
*
|
||||||
|
* @param startTime 开始时间,包含
|
||||||
|
* @param endTime 结束时间,不包含
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param assistantIds 智能体 ID 列表
|
||||||
|
* @return 智能体小时趋势
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ChatAssistantSessionTrend> queryAssistantHourlyTrends(LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime,
|
||||||
|
BigInteger tenantId,
|
||||||
|
List<BigInteger> assistantIds) {
|
||||||
|
if (!available() || assistantIds == null || assistantIds.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryAssistantSessionHourlyTrends(startTime, endTime, tenantId, assistantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询活跃用户排行。
|
||||||
|
*
|
||||||
|
* @param startDate 开始日期,包含当天
|
||||||
|
* @param endDate 结束日期,不包含当天
|
||||||
|
* @param tenantId 租户 ID,空表示全局
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 活跃用户排行
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ChatActiveUserRank> queryActiveUserRanks(LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
BigInteger tenantId,
|
||||||
|
int limit) {
|
||||||
|
if (!available()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
return analyticalDBRepository.queryActiveUserRanks(startDate, endDate, tenantId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前分析库是否可用。
|
* 当前分析库是否可用。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package tech.easyflow.chatlog.repository.analyticaldb;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.beans.factory.support.StaticListableBeanFactory;
|
||||||
|
import org.springframework.jdbc.core.ParameterizedPreparedStatementSetter;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatDashboardSummary;
|
||||||
|
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||||
|
import tech.easyflow.common.analyticaldb.config.AnalyticalDBFlywayProperties;
|
||||||
|
import tech.easyflow.common.analyticaldb.core.AnalyticalDBOperations;
|
||||||
|
import tech.easyflow.common.analyticaldb.page.AnalyticalDBPageRequest;
|
||||||
|
import tech.easyflow.common.analyticaldb.page.AnalyticalDBPageResult;
|
||||||
|
import tech.easyflow.common.analyticaldb.support.AnalyticalDBHealthSupport;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ChatAnalyticalDBRepository} 测试。
|
||||||
|
*/
|
||||||
|
public class ChatAnalyticalDBRepositoryTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证工作台汇总使用跨天去重的 session 口径。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldUseDistinctSessionSqlForDashboardSummary() {
|
||||||
|
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
|
||||||
|
operations.queryOneResult = new ChatDashboardSummary(2L, 5L, 1L, 1L);
|
||||||
|
|
||||||
|
ChatAnalyticalDBRepository repository = newRepository(operations);
|
||||||
|
repository.queryDashboardSummary(LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 8), BigInteger.ONE);
|
||||||
|
|
||||||
|
Assert.assertNotNull(operations.lastQueryOneSql);
|
||||||
|
Assert.assertTrue(operations.lastQueryOneSql.contains("FROM dws_chat_session_day agg"));
|
||||||
|
Assert.assertTrue(operations.lastQueryOneSql.contains("uniqExact(agg.dimension_id) AS session_total"));
|
||||||
|
Assert.assertTrue(operations.lastQueryOneSql.contains("uniqExact(agg.user_id) AS active_user_total"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证智能体使用榜按去重会话数排序,并同时统计用户数。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldUseDistinctSessionSqlForAssistantUsageRanks() {
|
||||||
|
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
|
||||||
|
|
||||||
|
ChatAnalyticalDBRepository repository = newRepository(operations);
|
||||||
|
repository.queryAssistantUsageRanks(LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 8), BigInteger.ONE, 5);
|
||||||
|
|
||||||
|
Assert.assertNotNull(operations.lastQuerySql);
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("FROM dws_chat_session_day agg"));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("uniqExact(agg.user_id) AS user_total"));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("uniqExact(agg.dimension_id) AS session_total"));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains(
|
||||||
|
"ORDER BY agg.session_total DESC, agg.user_total DESC, agg.message_total DESC, agg.assistant_id ASC"
|
||||||
|
));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id AS assistant_id"));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("snapshot.assistant_name AS assistant_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证智能体趋势查询显式返回 assistant_id 别名,避免 ClickHouse JDBC 无法按列名映射。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAliasAssistantIdForAssistantTrendQueries() {
|
||||||
|
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
|
||||||
|
|
||||||
|
ChatAnalyticalDBRepository repository = newRepository(operations);
|
||||||
|
repository.queryAssistantSessionTrends(
|
||||||
|
LocalDate.of(2026, 4, 1),
|
||||||
|
LocalDate.of(2026, 4, 8),
|
||||||
|
BigInteger.ONE,
|
||||||
|
List.of(BigInteger.ONE, BigInteger.TWO)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertNotNull(operations.lastQuerySql);
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id AS assistant_id"));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("snapshot.assistant_name AS assistant_name"));
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("agg.assistant_id IN (?, ?)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证智能体趋势查询在 Top 列表包含空 assistant_id 时会补上 IS NULL 条件。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldSupportNullAssistantIdInAssistantTrendQueries() {
|
||||||
|
RecordingAnalyticalDBOperations operations = new RecordingAnalyticalDBOperations();
|
||||||
|
|
||||||
|
ChatAnalyticalDBRepository repository = newRepository(operations);
|
||||||
|
repository.queryAssistantSessionTrends(
|
||||||
|
LocalDate.of(2026, 4, 1),
|
||||||
|
LocalDate.of(2026, 4, 8),
|
||||||
|
BigInteger.ONE,
|
||||||
|
Arrays.asList(BigInteger.ONE, null)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertNotNull(operations.lastQuerySql);
|
||||||
|
Assert.assertTrue(operations.lastQuerySql.contains("(agg.assistant_id IN (?) OR agg.assistant_id IS NULL)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造仓储实例。
|
||||||
|
*
|
||||||
|
* @param operations 分析库操作桩
|
||||||
|
* @return 仓储实例
|
||||||
|
*/
|
||||||
|
private ChatAnalyticalDBRepository newRepository(RecordingAnalyticalDBOperations operations) {
|
||||||
|
StaticListableBeanFactory beanFactory = new StaticListableBeanFactory();
|
||||||
|
beanFactory.addBean("analyticalDBOperations", operations);
|
||||||
|
ObjectProvider<AnalyticalDBOperations> provider = beanFactory.getBeanProvider(AnalyticalDBOperations.class);
|
||||||
|
AnalyticalDBHealthSupport healthSupport =
|
||||||
|
new AnalyticalDBHealthSupport(provider, new AnalyticalDBFlywayProperties());
|
||||||
|
ChatJsonSupport jsonSupport = new ChatJsonSupport(new ObjectMapper());
|
||||||
|
return new ChatAnalyticalDBRepository(provider, healthSupport, jsonSupport);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 SQL 的分析库桩实现。
|
||||||
|
*/
|
||||||
|
private static class RecordingAnalyticalDBOperations implements AnalyticalDBOperations {
|
||||||
|
|
||||||
|
private String lastQueryOneSql;
|
||||||
|
private String lastQuerySql;
|
||||||
|
private ChatDashboardSummary queryOneResult;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean available() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void assertAvailable() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
|
||||||
|
this.lastQuerySql = sql;
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T queryOne(String sql, Class<T> requiredType, Object... args) {
|
||||||
|
this.lastQueryOneSql = sql;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Override
|
||||||
|
public <T> T queryOne(String sql, RowMapper<T> rowMapper, Object... args) {
|
||||||
|
this.lastQueryOneSql = sql;
|
||||||
|
return (T) queryOneResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> List<T> queryForList(String sql, Class<T> elementType, Object... args) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(String sql, Object... args) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> int[][] batchUpdate(String sql,
|
||||||
|
List<T> items,
|
||||||
|
int batchSize,
|
||||||
|
ParameterizedPreparedStatementSetter<T> setter) {
|
||||||
|
return new int[0][];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> AnalyticalDBPageResult<T> page(String countSql,
|
||||||
|
Object[] countArgs,
|
||||||
|
String dataSql,
|
||||||
|
Object[] dataArgs,
|
||||||
|
AnalyticalDBPageRequest pageRequest,
|
||||||
|
RowMapper<T> rowMapper) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export type DashboardRange = '7d' | '30d' | 'today';
|
export type DashboardRange = '7d' | '30d' | 'custom' | 'today';
|
||||||
|
|
||||||
export interface DashboardOverviewQuery {
|
export interface DashboardOverviewQuery {
|
||||||
|
endDate?: string;
|
||||||
range?: DashboardRange;
|
range?: DashboardRange;
|
||||||
|
startDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
activeAssistantTotal: number;
|
activeAssistantTotal: number;
|
||||||
botTotal: number;
|
botTotal: number;
|
||||||
|
chatActiveUserTotal: number;
|
||||||
chatMessageTotal: number;
|
chatMessageTotal: number;
|
||||||
chatSessionTotal: number;
|
chatSessionTotal: number;
|
||||||
knowledgeBaseTotal: number;
|
knowledgeBaseTotal: number;
|
||||||
@@ -30,10 +33,24 @@ export interface DashboardTrendItem {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardAssistantTrendPoint {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sessionTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAssistantTrendSeries {
|
||||||
|
assistantId?: number | string;
|
||||||
|
label: string;
|
||||||
|
points: DashboardAssistantTrendPoint[];
|
||||||
|
totalSessionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardDistributionItem {
|
export interface DashboardDistributionItem {
|
||||||
activeUserTotal: number;
|
activeUserTotal: number;
|
||||||
assistantId?: number | string;
|
assistantId?: number | string;
|
||||||
avgMessagePerSession?: number;
|
avgMessagePerSession?: number;
|
||||||
|
avgSessionPerUser?: number;
|
||||||
botTotal: number;
|
botTotal: number;
|
||||||
key: string;
|
key: string;
|
||||||
knowledgeBaseTotal: number;
|
knowledgeBaseTotal: number;
|
||||||
@@ -45,13 +62,23 @@ export interface DashboardDistributionItem {
|
|||||||
workflowTotal: number;
|
workflowTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardUserRankItem {
|
||||||
|
assistantTotal: number;
|
||||||
|
label: string;
|
||||||
|
messageTotal: number;
|
||||||
|
sessionTotal: number;
|
||||||
|
userId?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardOverviewResponse {
|
export interface DashboardOverviewResponse {
|
||||||
|
assistantTrends: DashboardAssistantTrendSeries[];
|
||||||
chatStatus: DashboardChatStatus;
|
chatStatus: DashboardChatStatus;
|
||||||
distribution: DashboardDistributionItem[];
|
distribution: DashboardDistributionItem[];
|
||||||
query: DashboardOverviewQuery;
|
query: DashboardOverviewQuery;
|
||||||
summary: DashboardSummary;
|
summary: DashboardSummary;
|
||||||
trends: DashboardTrendItem[];
|
trends: DashboardTrendItem[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
userRanks: DashboardUserRankItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardOverview(params: DashboardOverviewQuery) {
|
export async function getDashboardOverview(params: DashboardOverviewQuery) {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"embedUsageTip2": "Embed only in trusted sites and combine with rate limiting.",
|
"embedUsageTip2": "Embed only in trusted sites and combine with rate limiting.",
|
||||||
"publicPageBlocked": "Anonymous access is disabled for this bot",
|
"publicPageBlocked": "Anonymous access is disabled for this bot",
|
||||||
"publicPageBlockedTip": "Enable anonymous access in publish settings before using external links.",
|
"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",
|
"publicChatTitle": "Public Chat Assistant",
|
||||||
"publicChatSubtitle": "Powered by EasyFlow",
|
"publicChatSubtitle": "Powered by EasyFlow",
|
||||||
"publicChatPlaceholder": "Type your question and press Enter to send",
|
"publicChatPlaceholder": "Type your question and press Enter to send",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
|
"embedUsageTip2": "建议仅在可信站点嵌入,并结合限流策略使用。",
|
||||||
"publicPageBlocked": "该助手未开放匿名访问",
|
"publicPageBlocked": "该助手未开放匿名访问",
|
||||||
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
|
"publicPageBlockedTip": "请在发布设置中开启“允许匿名访问”后再通过外链访问。",
|
||||||
|
"publicPageLoginRequired": "用户未认证,请登录",
|
||||||
|
"publicPageLoginRequiredTip": "当前聊天页需要有效登录态或访问令牌后才可继续使用。",
|
||||||
"publicChatTitle": "公开聊天助手",
|
"publicChatTitle": "公开聊天助手",
|
||||||
"publicChatSubtitle": "由 EasyFlow 驱动",
|
"publicChatSubtitle": "由 EasyFlow 驱动",
|
||||||
"publicChatPlaceholder": "输入你的问题,按 Enter 发送",
|
"publicChatPlaceholder": "输入你的问题,按 Enter 发送",
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
import type { EchartsUIType } from '@easyflow/plugins/echarts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
DashboardAssistantTrendSeries,
|
||||||
DashboardDistributionItem,
|
DashboardDistributionItem,
|
||||||
DashboardOverviewQuery,
|
DashboardOverviewQuery,
|
||||||
DashboardOverviewResponse,
|
DashboardOverviewResponse,
|
||||||
DashboardRange,
|
DashboardRange,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
DashboardTrendItem,
|
DashboardTrendItem,
|
||||||
|
DashboardUserRankItem,
|
||||||
} from '#/api/dashboard';
|
} from '#/api/dashboard';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
onMounted,
|
onMounted,
|
||||||
reactive,
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import { AnalysisChartCard } from '@easyflow/common-ui';
|
import { AnalysisChartCard } from '@easyflow/common-ui';
|
||||||
@@ -25,10 +28,26 @@ import { useUserStore } from '@easyflow/stores';
|
|||||||
import { convertToRgb } from '@easyflow/utils';
|
import { convertToRgb } from '@easyflow/utils';
|
||||||
|
|
||||||
import { RefreshRight } from '@element-plus/icons-vue';
|
import { RefreshRight } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElEmpty, ElRadioButton, ElRadioGroup } from 'element-plus';
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDatePicker,
|
||||||
|
ElEmpty,
|
||||||
|
ElPopover,
|
||||||
|
ElRadioButton,
|
||||||
|
ElRadioGroup,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
import { getDashboardOverview } from '#/api/dashboard';
|
import { getDashboardOverview } from '#/api/dashboard';
|
||||||
|
|
||||||
|
type DashboardTrendMode = 'assistantActive' | 'usage' | 'userActive';
|
||||||
|
|
||||||
|
interface AssistantTrendSelectionItem {
|
||||||
|
assistantKey: string;
|
||||||
|
color: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
series: DashboardAssistantTrendSeries;
|
||||||
|
}
|
||||||
|
|
||||||
interface SummaryCardItem {
|
interface SummaryCardItem {
|
||||||
available?: boolean;
|
available?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -39,16 +58,23 @@ let greetingTimer: null | ReturnType<typeof setInterval> = null;
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const now = ref(new Date());
|
const now = ref(new Date());
|
||||||
|
|
||||||
const filters = reactive<Required<DashboardOverviewQuery>>({
|
const filters = reactive<DashboardOverviewQuery>({
|
||||||
range: '7d',
|
range: '7d',
|
||||||
});
|
});
|
||||||
|
const customDateRange = ref<string[]>([]);
|
||||||
|
|
||||||
const overview = ref<DashboardOverviewResponse | null>(null);
|
const overview = ref<DashboardOverviewResponse | null>(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const trendMode = ref<DashboardTrendMode>('usage');
|
||||||
|
const selectedAssistantTrendKeys = ref<string[]>([]);
|
||||||
|
|
||||||
const trendChartRef = ref<EchartsUIType>();
|
const trendChartRef = ref<EchartsUIType>();
|
||||||
const { renderEcharts: renderTrendEcharts } = useEcharts(trendChartRef);
|
const {
|
||||||
|
getChartInstance: getTrendChartInstance,
|
||||||
|
renderEcharts: renderTrendEcharts,
|
||||||
|
resize: resizeTrendChart,
|
||||||
|
} = useEcharts(trendChartRef);
|
||||||
|
|
||||||
const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
||||||
{ label: '今日', value: 'today' },
|
{ label: '今日', value: 'today' },
|
||||||
@@ -56,10 +82,17 @@ const rangeOptions: Array<{ label: string; value: DashboardRange }> = [
|
|||||||
{ label: '近 30 天', value: '30d' },
|
{ label: '近 30 天', value: '30d' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const trendModeOptions: Array<{ label: string; value: DashboardTrendMode }> = [
|
||||||
|
{ label: '使用量趋势', value: 'usage' },
|
||||||
|
{ label: '用户活跃', value: 'userActive' },
|
||||||
|
{ label: '智能体活跃', value: 'assistantActive' },
|
||||||
|
];
|
||||||
|
|
||||||
const emptySummary: DashboardSummary = {
|
const emptySummary: DashboardSummary = {
|
||||||
activeAssistantTotal: 0,
|
activeAssistantTotal: 0,
|
||||||
activeUserTotal: 0,
|
activeUserTotal: 0,
|
||||||
botTotal: 0,
|
botTotal: 0,
|
||||||
|
chatActiveUserTotal: 0,
|
||||||
chatMessageTotal: 0,
|
chatMessageTotal: 0,
|
||||||
chatSessionTotal: 0,
|
chatSessionTotal: 0,
|
||||||
knowledgeBaseTotal: 0,
|
knowledgeBaseTotal: 0,
|
||||||
@@ -68,36 +101,67 @@ const emptySummary: DashboardSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
const summary = computed(() => overview.value?.summary ?? emptySummary);
|
||||||
const trends = computed<DashboardTrendItem[]>(() => overview.value?.trends ?? []);
|
const trends = computed<DashboardTrendItem[]>(
|
||||||
|
() => overview.value?.trends ?? [],
|
||||||
|
);
|
||||||
|
const assistantTrends = computed<DashboardAssistantTrendSeries[]>(
|
||||||
|
() => overview.value?.assistantTrends ?? [],
|
||||||
|
);
|
||||||
const distribution = computed<DashboardDistributionItem[]>(
|
const distribution = computed<DashboardDistributionItem[]>(
|
||||||
() => overview.value?.distribution ?? [],
|
() => overview.value?.distribution ?? [],
|
||||||
);
|
);
|
||||||
const chatAvailable = computed(() => overview.value?.chatStatus?.available !== false);
|
const userRanks = computed<DashboardUserRankItem[]>(
|
||||||
|
() => overview.value?.userRanks ?? [],
|
||||||
|
);
|
||||||
|
const chatAvailable = computed(
|
||||||
|
() => overview.value?.chatStatus?.available !== false,
|
||||||
|
);
|
||||||
const chatStatusMessage = computed(
|
const chatStatusMessage = computed(
|
||||||
() => overview.value?.chatStatus?.message || '聊天数据不可用',
|
() => overview.value?.chatStatus?.message || '聊天数据不可用',
|
||||||
);
|
);
|
||||||
|
const isUsageTrend = computed(() => trendMode.value === 'usage');
|
||||||
|
const isUserActiveTrend = computed(() => trendMode.value === 'userActive');
|
||||||
|
const isAssistantActiveTrend = computed(
|
||||||
|
() => trendMode.value === 'assistantActive',
|
||||||
|
);
|
||||||
|
|
||||||
const summaryCards = computed<SummaryCardItem[]>(() => [
|
const summaryCards = computed<SummaryCardItem[]>(() => [
|
||||||
{ label: '助手数量', value: formatCount(summary.value.botTotal) },
|
|
||||||
{ label: '工作流数量', value: formatCount(summary.value.workflowTotal) },
|
|
||||||
{
|
{
|
||||||
label: '知识库数量',
|
available: chatAvailable.value,
|
||||||
value: formatCount(summary.value.knowledgeBaseTotal),
|
label: 'AI活跃用户',
|
||||||
|
value: formatOptionalCount(
|
||||||
|
summary.value.chatActiveUserTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
available: chatAvailable.value,
|
available: chatAvailable.value,
|
||||||
label: '聊天消息总数',
|
label: '活跃智能体',
|
||||||
value: formatOptionalCount(summary.value.chatMessageTotal, chatAvailable.value),
|
value: formatOptionalCount(
|
||||||
|
summary.value.activeAssistantTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
available: chatAvailable.value,
|
available: chatAvailable.value,
|
||||||
label: '聊天会话总数',
|
label: '聊天会话总数',
|
||||||
value: formatOptionalCount(summary.value.chatSessionTotal, chatAvailable.value),
|
value: formatOptionalCount(
|
||||||
|
summary.value.chatSessionTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
available: chatAvailable.value,
|
available: chatAvailable.value,
|
||||||
label: '活跃智能体数',
|
label: '聊天消息总数',
|
||||||
value: formatOptionalCount(summary.value.activeAssistantTotal, chatAvailable.value),
|
value: formatOptionalCount(
|
||||||
|
summary.value.chatMessageTotal,
|
||||||
|
chatAvailable.value,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: '智能体总数', value: formatCount(summary.value.botTotal) },
|
||||||
|
{
|
||||||
|
label: '知识库总数',
|
||||||
|
value: formatCount(summary.value.knowledgeBaseTotal),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -134,15 +198,99 @@ const greetingTitle = computed(
|
|||||||
() => `${greetingText.value},${displayName.value}`,
|
() => `${greetingText.value},${displayName.value}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const trendDescription = computed(() => {
|
||||||
|
if (isUsageTrend.value) {
|
||||||
|
return '消息与会话趋势';
|
||||||
|
}
|
||||||
|
if (isUserActiveTrend.value) {
|
||||||
|
return '用户活跃趋势';
|
||||||
|
}
|
||||||
|
return '智能体活跃趋势';
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedAssistantTrendSet = computed(
|
||||||
|
() => new Set(selectedAssistantTrendKeys.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assistantTrendColors = computed(() =>
|
||||||
|
buildAssistantTrendPalette(assistantTrends.value.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assistantTrendSelectionItems = computed<AssistantTrendSelectionItem[]>(
|
||||||
|
() =>
|
||||||
|
assistantTrends.value.map((series, index) => {
|
||||||
|
const assistantKey = getAssistantTrendKey(
|
||||||
|
series.assistantId,
|
||||||
|
series.label,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
assistantKey,
|
||||||
|
color:
|
||||||
|
assistantTrendColors.value[index] || getChartTokenColor('--primary'),
|
||||||
|
isSelected: selectedAssistantTrendSet.value.has(assistantKey),
|
||||||
|
series,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAssistantTrends = computed<AssistantTrendSelectionItem[]>(() =>
|
||||||
|
assistantTrendSelectionItems.value.filter((item) => item.isSelected),
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAssistantTrendCount = computed(
|
||||||
|
() => selectedAssistantTrends.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAssistantTrendPreviewItems = computed(() =>
|
||||||
|
selectedAssistantTrends.value.slice(0, 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assistantTrendSelectorLabel = computed(() => {
|
||||||
|
const total = assistantTrendSelectionItems.value.length;
|
||||||
|
if (total === 0) {
|
||||||
|
return '无可选智能体';
|
||||||
|
}
|
||||||
|
return `${selectedAssistantTrendCount.value}/${total} 已选`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantTrendSelectorSummary = computed(() => {
|
||||||
|
const count = selectedAssistantTrendCount.value;
|
||||||
|
if (count === 0) {
|
||||||
|
return '未选择智能体';
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return selectedAssistantTrends.value[0]?.series.label || '已选择 1 个';
|
||||||
|
}
|
||||||
|
return `${selectedAssistantTrends.value[0]?.series.label || '已选择'} 等 ${count} 个`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAssistantTrendNoData = computed(
|
||||||
|
() =>
|
||||||
|
isAssistantActiveTrend.value &&
|
||||||
|
assistantTrendSelectionItems.value.length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showAssistantTrendEmptySelection = computed(
|
||||||
|
() =>
|
||||||
|
isAssistantActiveTrend.value &&
|
||||||
|
assistantTrendSelectionItems.value.length > 0 &&
|
||||||
|
selectedAssistantTrends.value.length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showTrendChart = computed(() => chatAvailable.value);
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getDashboardOverview({
|
const data = await getDashboardOverview({
|
||||||
|
endDate: filters.range === 'custom' ? filters.endDate : undefined,
|
||||||
range: filters.range,
|
range: filters.range,
|
||||||
|
startDate: filters.range === 'custom' ? filters.startDate : undefined,
|
||||||
});
|
});
|
||||||
overview.value = data;
|
overview.value = data;
|
||||||
|
resetAssistantTrendSelection();
|
||||||
await renderCharts();
|
await renderCharts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
overview.value = null;
|
overview.value = null;
|
||||||
@@ -155,24 +303,197 @@ async function loadOverview() {
|
|||||||
|
|
||||||
async function renderCharts() {
|
async function renderCharts() {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (!chatAvailable.value) {
|
if (!showTrendChart.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderTrendChart();
|
renderTrendChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTrendChart() {
|
function renderTrendChart() {
|
||||||
const xAxisData = trends.value.map((item) => item.label);
|
|
||||||
const messageData = trends.value.map((item) => item.chatMessageTotal);
|
|
||||||
const sessionData = trends.value.map((item) => item.chatSessionTotal);
|
|
||||||
const primaryColor = getChartTokenColor('--primary');
|
const primaryColor = getChartTokenColor('--primary');
|
||||||
const successColor = getChartTokenColor('--success');
|
const successColor = getChartTokenColor('--success');
|
||||||
|
const warningColor = getChartTokenColor('--warning');
|
||||||
|
const destructiveColor = getChartTokenColor('--destructive');
|
||||||
const axisColor = getChartTokenColor('--border');
|
const axisColor = getChartTokenColor('--border');
|
||||||
const tooltipLineColor = getChartTokenColor('--accent');
|
const tooltipLineColor = getChartTokenColor('--accent');
|
||||||
const textColor = getChartTokenColor('--foreground');
|
const textColor = getChartTokenColor('--foreground');
|
||||||
|
|
||||||
|
if (isAssistantActiveTrend.value) {
|
||||||
|
const xAxisData =
|
||||||
|
assistantTrendSelectionItems.value[0]?.series.points.map(
|
||||||
|
(point) => point.label,
|
||||||
|
) ?? [];
|
||||||
|
const assistantSeries = assistantTrendSelectionItems.value.map((item) => ({
|
||||||
|
data: item.series.points.map((point) => point.sessionTotal),
|
||||||
|
emphasis: {
|
||||||
|
focus: 'series' as const,
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: item.color,
|
||||||
|
borderWidth: 2,
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
color: item.color,
|
||||||
|
width: 3,
|
||||||
|
},
|
||||||
|
name: item.series.label,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 8,
|
||||||
|
type: 'line' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderTrendEcharts({
|
||||||
|
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({
|
renderTrendEcharts({
|
||||||
color: [primaryColor, successColor],
|
color: isUsageTrend.value
|
||||||
|
? [primaryColor, successColor]
|
||||||
|
: [primaryColor, warningColor, destructiveColor],
|
||||||
grid: {
|
grid: {
|
||||||
bottom: 18,
|
bottom: 18,
|
||||||
containLabel: true,
|
containLabel: true,
|
||||||
@@ -231,62 +552,7 @@ function renderTrendChart() {
|
|||||||
},
|
},
|
||||||
type: 'value',
|
type: 'value',
|
||||||
},
|
},
|
||||||
series: [
|
series: isUsageTrend.value ? usageSeries : activeSeries,
|
||||||
{
|
|
||||||
data: messageData,
|
|
||||||
emphasis: {
|
|
||||||
focus: 'none',
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: '#ffffff',
|
|
||||||
borderWidth: 3,
|
|
||||||
color: primaryColor,
|
|
||||||
},
|
|
||||||
scale: true,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: primaryColor,
|
|
||||||
borderWidth: 2,
|
|
||||||
color: '#ffffff',
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
color: primaryColor,
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
name: '消息数',
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 9,
|
|
||||||
type: 'line',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
data: sessionData,
|
|
||||||
emphasis: {
|
|
||||||
focus: 'none',
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: '#ffffff',
|
|
||||||
borderWidth: 3,
|
|
||||||
color: successColor,
|
|
||||||
},
|
|
||||||
scale: true,
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
borderColor: successColor,
|
|
||||||
borderWidth: 2,
|
|
||||||
color: '#ffffff',
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
color: successColor,
|
|
||||||
width: 3,
|
|
||||||
},
|
|
||||||
name: '会话数',
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 9,
|
|
||||||
type: 'line',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,9 +561,130 @@ function handleRangeChange(value: boolean | number | string | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filters.range = value;
|
filters.range = value;
|
||||||
|
customDateRange.value = [];
|
||||||
|
filters.startDate = undefined;
|
||||||
|
filters.endDate = undefined;
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCustomRangeChange(value: string[] | undefined) {
|
||||||
|
if (!value || value.length !== 2 || !value[0] || !value[1]) {
|
||||||
|
customDateRange.value = [];
|
||||||
|
filters.range = '7d';
|
||||||
|
filters.startDate = undefined;
|
||||||
|
filters.endDate = undefined;
|
||||||
|
void loadOverview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filters.range = 'custom';
|
||||||
|
filters.startDate = value[0];
|
||||||
|
filters.endDate = value[1];
|
||||||
|
void loadOverview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrendModeChange(value: boolean | number | string | undefined) {
|
||||||
|
if (
|
||||||
|
value !== 'usage' &&
|
||||||
|
value !== 'userActive' &&
|
||||||
|
value !== 'assistantActive'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trendMode.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAssistantTrendSelection() {
|
||||||
|
selectedAssistantTrendKeys.value = assistantTrends.value.map((series) =>
|
||||||
|
getAssistantTrendKey(series.assistantId, series.label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAssistantTrend(assistantKey: string) {
|
||||||
|
const currentItem = assistantTrendSelectionItems.value.find(
|
||||||
|
(item) => item.assistantKey === assistantKey,
|
||||||
|
);
|
||||||
|
if (!currentItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedKeys = new Set(selectedAssistantTrendKeys.value);
|
||||||
|
let actionType: 'legendSelect' | 'legendUnSelect' = 'legendSelect';
|
||||||
|
if (selectedKeys.has(assistantKey)) {
|
||||||
|
selectedKeys.delete(assistantKey);
|
||||||
|
actionType = 'legendUnSelect';
|
||||||
|
} else {
|
||||||
|
selectedKeys.add(assistantKey);
|
||||||
|
}
|
||||||
|
selectedAssistantTrendKeys.value = [...selectedKeys];
|
||||||
|
if (!isAssistantActiveTrend.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void nextTick(() => {
|
||||||
|
const chartInstance = getTrendChartInstance();
|
||||||
|
if (!chartInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chartInstance.dispatchAction({
|
||||||
|
name: currentItem.series.label,
|
||||||
|
type: actionType,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllAssistantTrends() {
|
||||||
|
selectedAssistantTrendKeys.value = assistantTrendSelectionItems.value.map(
|
||||||
|
(item) => item.assistantKey,
|
||||||
|
);
|
||||||
|
if (!isAssistantActiveTrend.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void nextTick(() => {
|
||||||
|
renderTrendChart();
|
||||||
|
resizeTrendChart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAssistantTrendSelection() {
|
||||||
|
const previousItems = [...selectedAssistantTrends.value];
|
||||||
|
selectedAssistantTrendKeys.value = [];
|
||||||
|
if (!isAssistantActiveTrend.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void nextTick(() => {
|
||||||
|
const chartInstance = getTrendChartInstance();
|
||||||
|
if (!chartInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of previousItems) {
|
||||||
|
chartInstance.dispatchAction({
|
||||||
|
name: item.series.label,
|
||||||
|
type: 'legendUnSelect',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssistantTrendSelectedMap() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
assistantTrendSelectionItems.value.map((item) => [
|
||||||
|
item.series.label,
|
||||||
|
item.isSelected,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[trendMode, chatAvailable, trends, assistantTrends],
|
||||||
|
() => {
|
||||||
|
if (!showTrendChart.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void renderCharts();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
flush: 'post',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function formatCount(value?: number) {
|
function formatCount(value?: number) {
|
||||||
return Number(value || 0).toLocaleString('zh-CN');
|
return Number(value || 0).toLocaleString('zh-CN');
|
||||||
}
|
}
|
||||||
@@ -307,13 +694,54 @@ function formatOptionalCount(value: number | undefined, available: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getChartTokenColor(variableName: string) {
|
function getChartTokenColor(variableName: string) {
|
||||||
|
return convertToRgb(`hsl(${getChartTokenHsl(variableName)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartTokenHsl(variableName: string) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return '#3b82f6';
|
return '211 100% 50%';
|
||||||
}
|
}
|
||||||
const value = getComputedStyle(document.documentElement)
|
const value = getComputedStyle(document.documentElement)
|
||||||
.getPropertyValue(variableName)
|
.getPropertyValue(variableName)
|
||||||
.trim();
|
.trim();
|
||||||
return value ? convertToRgb(`hsl(${value})`) : '#3b82f6';
|
return value || '211 100% 50%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssistantTrendPalette(total: number) {
|
||||||
|
const palette = [
|
||||||
|
getChartTokenColor('--primary'),
|
||||||
|
getChartTokenColor('--success'),
|
||||||
|
getChartTokenColor('--warning'),
|
||||||
|
getChartTokenColor('--destructive'),
|
||||||
|
];
|
||||||
|
if (total <= palette.length) {
|
||||||
|
return palette.slice(0, total);
|
||||||
|
}
|
||||||
|
const primaryHue = extractHue(getChartTokenHsl('--primary')) ?? 211;
|
||||||
|
const derivedHues = [46, 92, 138, 184, 230, 276].map(
|
||||||
|
(offset) => `hsl(${normalizeHue(primaryHue + offset)} 68% 52%)`,
|
||||||
|
);
|
||||||
|
return [...palette, ...derivedHues].slice(0, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantTrendKey(
|
||||||
|
assistantId: number | string | undefined,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
return assistantId === undefined
|
||||||
|
? `label:${label}`
|
||||||
|
: `assistant:${assistantId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHue(hslValue: string) {
|
||||||
|
const [hue] = hslValue.split(/\s+/);
|
||||||
|
const parsed = Number.parseFloat(hue || '');
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHue(value: number) {
|
||||||
|
const normalized = value % 360;
|
||||||
|
return normalized < 0 ? normalized + 360 : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAvg(value?: number) {
|
function formatAvg(value?: number) {
|
||||||
@@ -363,6 +791,16 @@ onBeforeUnmount(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
|
class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
|
||||||
>
|
>
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="customDateRange"
|
||||||
|
class="w-full sm:w-[280px]"
|
||||||
|
type="daterange"
|
||||||
|
unlink-panels
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
@change="handleCustomRangeChange"
|
||||||
|
/>
|
||||||
<ElRadioGroup
|
<ElRadioGroup
|
||||||
:model-value="filters.range"
|
:model-value="filters.range"
|
||||||
size="default"
|
size="default"
|
||||||
@@ -392,7 +830,7 @@ onBeforeUnmount(() => {
|
|||||||
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in 8"
|
v-for="item in 6"
|
||||||
:key="item"
|
:key="item"
|
||||||
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
|
class="border-border bg-muted/50 h-28 animate-pulse rounded-3xl border"
|
||||||
></div>
|
></div>
|
||||||
@@ -436,14 +874,134 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
<section
|
||||||
<AnalysisChartCard title="聊天趋势">
|
class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]"
|
||||||
|
>
|
||||||
|
<AnalysisChartCard title="趋势概览">
|
||||||
<template v-if="chatAvailable">
|
<template v-if="chatAvailable">
|
||||||
<div class="space-y-2">
|
<div class="space-y-4">
|
||||||
<p class="text-muted-foreground text-sm">
|
<div
|
||||||
观察所选时间范围内消息数与会话数的趋势变化。
|
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
</p>
|
>
|
||||||
<EchartsUI ref="trendChartRef" height="360px" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
@@ -451,9 +1009,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</AnalysisChartCard>
|
</AnalysisChartCard>
|
||||||
|
|
||||||
<AnalysisChartCard title="智能体排行">
|
<AnalysisChartCard title="智能体使用榜">
|
||||||
<template v-if="chatAvailable">
|
<template v-if="chatAvailable">
|
||||||
<div v-if="distribution.length" class="space-y-3">
|
<div v-if="distribution.length > 0" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in distribution"
|
v-for="(item, index) in distribution"
|
||||||
:key="item.key || item.label"
|
:key="item.key || item.label"
|
||||||
@@ -471,24 +1029,24 @@ onBeforeUnmount(() => {
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground mt-1 text-xs">
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
消息 {{ formatCount(item.messageTotal) }} · 会话
|
用户 {{ formatCount(item.userTotal) }} · 消息
|
||||||
{{ formatCount(item.sessionTotal) }}
|
{{ formatCount(item.messageTotal) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-foreground text-lg font-semibold">
|
<div class="text-foreground text-lg font-semibold">
|
||||||
{{ formatAvg(item.avgMessagePerSession) }}
|
{{ formatCount(item.sessionTotal) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground mt-1 text-xs">
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
平均每会话消息数
|
会话数 · 人均 {{ formatAvg(item.avgSessionPerUser) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
<ElEmpty description="暂无聊天排行数据" />
|
<ElEmpty description="暂无智能体使用数据" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="flex min-h-[360px] items-center justify-center">
|
<div v-else class="flex min-h-[360px] items-center justify-center">
|
||||||
@@ -496,6 +1054,66 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</AnalysisChartCard>
|
</AnalysisChartCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<AnalysisChartCard title="用户活跃榜">
|
||||||
|
<template v-if="chatAvailable">
|
||||||
|
<div v-if="userRanks.length > 0" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in userRanks"
|
||||||
|
:key="item.userId || item.label"
|
||||||
|
class="border-border/60 bg-card flex flex-col gap-4 rounded-2xl border px-4 py-4 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-semibold">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
|
消息 {{ formatCount(item.messageTotal) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
|
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
||||||
|
<div class="text-foreground text-base font-semibold">
|
||||||
|
{{ formatCount(item.sessionTotal) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-xs">会话数</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-muted/30 rounded-2xl px-4 py-3 text-right">
|
||||||
|
<div class="text-foreground text-base font-semibold">
|
||||||
|
{{ formatCount(item.assistantTotal) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-xs">
|
||||||
|
使用智能体数
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-muted/30 col-span-2 rounded-2xl px-4 py-3 text-right md:col-span-1"
|
||||||
|
>
|
||||||
|
<div class="text-foreground text-base font-semibold">
|
||||||
|
{{ formatCount(item.messageTotal) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground mt-1 text-xs">消息数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex min-h-[220px] items-center justify-center">
|
||||||
|
<ElEmpty description="暂无用户活跃数据" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex min-h-[220px] items-center justify-center">
|
||||||
|
<ElEmpty :description="chatStatusMessage" />
|
||||||
|
</div>
|
||||||
|
</AnalysisChartCard>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
|||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||||
|
import { LOGIN_PATH } from '@easyflow/constants';
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
import { useAccessStore } from '@easyflow/stores';
|
||||||
import { uuid } from '@easyflow/utils';
|
import { uuid } from '@easyflow/utils';
|
||||||
|
|
||||||
import { useTitle } from '@vueuse/core';
|
import { useTitle } from '@vueuse/core';
|
||||||
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
|
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
|
||||||
|
|
||||||
|
import { refreshTokenApi } from '#/api/core';
|
||||||
import { baseRequestClient, sseClient } from '#/api/request';
|
import { baseRequestClient, sseClient } from '#/api/request';
|
||||||
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||||||
|
|
||||||
@@ -106,12 +109,21 @@ interface PublicChatSessionRestoreResult {
|
|||||||
messages?: PublicChatMessageRecord[];
|
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_API_KEY_QUERY_KEYS = ['token', 'apikey', 'apiKey'] as const;
|
||||||
const PUBLIC_CHAT_CONTEXT_VERSION = 1;
|
const PUBLIC_CHAT_CONTEXT_VERSION = 1;
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pageTitle = useTitle();
|
const pageTitle = useTitle();
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
|
||||||
const botInfo = ref<BotInfo | null>(null);
|
const botInfo = ref<BotInfo | null>(null);
|
||||||
const conversationId = ref<string>('');
|
const conversationId = ref<string>('');
|
||||||
@@ -149,6 +161,28 @@ const setPageTitle = () => {
|
|||||||
pageTitle.value = botTitle || $t('bot.publicChatTitle');
|
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 = () => {
|
const isAtBottom = () => {
|
||||||
if (!messageContainerRef.value) return true;
|
if (!messageContainerRef.value) return true;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
|
const { scrollTop, scrollHeight, clientHeight } = messageContainerRef.value;
|
||||||
@@ -438,34 +472,109 @@ const exchangeApiKeyToAccessToken = async (apiKey: string) => {
|
|||||||
return accessToken;
|
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 {
|
try {
|
||||||
const profileResp = await getResponseBody(
|
const profileResp = await getResponseBody(
|
||||||
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
|
baseRequestClient.get('/api/v1/sysAccount/myProfile', {
|
||||||
headers: buildRequestHeaders(token),
|
headers: buildRequestHeaders(token),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return profileResp.errorCode === 0 && !!profileResp.data;
|
if (profileResp.errorCode === 0 && profileResp.data) {
|
||||||
} catch {
|
return {
|
||||||
return false;
|
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 () => {
|
const ensureRequestAccessToken = async () => {
|
||||||
requestAccessToken.value = '';
|
requestAccessToken.value = '';
|
||||||
authenticatedAccess.value = false;
|
authenticatedAccess.value = false;
|
||||||
const urlApiKey = readUrlApiKey();
|
const urlApiKey = readUrlApiKey();
|
||||||
if (urlApiKey) {
|
if (urlApiKey) {
|
||||||
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
|
const exchangedToken = await exchangeApiKeyToAccessToken(urlApiKey);
|
||||||
const authenticated = await resolveAuthenticatedAccess(exchangedToken);
|
const authResult = await resolveAuthenticatedAccess(exchangedToken);
|
||||||
if (!authenticated) {
|
if (authResult.status === 'error') {
|
||||||
|
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||||||
|
}
|
||||||
|
if (authResult.status !== 'authenticated') {
|
||||||
throw new Error($t('bot.publicChatTokenInvalid'));
|
throw new Error($t('bot.publicChatTokenInvalid'));
|
||||||
}
|
}
|
||||||
requestAccessToken.value = exchangedToken;
|
requestAccessToken.value = authResult.nextToken || exchangedToken;
|
||||||
authenticatedAccess.value = true;
|
authenticatedAccess.value = true;
|
||||||
upsertPublicChatContext(
|
upsertPublicChatContext(
|
||||||
{
|
{
|
||||||
accessToken: exchangedToken,
|
accessToken: requestAccessToken.value,
|
||||||
authenticatedAccess: true,
|
authenticatedAccess: true,
|
||||||
},
|
},
|
||||||
{ resetConversation: true },
|
{ resetConversation: true },
|
||||||
@@ -476,36 +585,45 @@ const ensureRequestAccessToken = async () => {
|
|||||||
|
|
||||||
const storedContext = readPublicChatContext(botId.value);
|
const storedContext = readPublicChatContext(botId.value);
|
||||||
if (storedContext?.accessToken) {
|
if (storedContext?.accessToken) {
|
||||||
const tokenValid = await resolveAuthenticatedAccess(
|
const authResult = await resolveAuthenticatedAccess(
|
||||||
storedContext.accessToken,
|
storedContext.accessToken,
|
||||||
);
|
);
|
||||||
if (tokenValid) {
|
if (authResult.status === 'error') {
|
||||||
requestAccessToken.value = storedContext.accessToken;
|
throw new Error(authResult.message || $t('bot.publicChatInitError'));
|
||||||
authenticatedAccess.value = storedContext.authenticatedAccess;
|
}
|
||||||
|
if (authResult.status === 'authenticated') {
|
||||||
|
requestAccessToken.value =
|
||||||
|
authResult.nextToken || storedContext.accessToken;
|
||||||
|
authenticatedAccess.value = true;
|
||||||
upsertPublicChatContext({
|
upsertPublicChatContext({
|
||||||
accessToken: storedContext.accessToken,
|
accessToken: requestAccessToken.value,
|
||||||
authenticatedAccess: storedContext.authenticatedAccess,
|
authenticatedAccess: true,
|
||||||
conversationId: storedContext.conversationId,
|
conversationId: storedContext.conversationId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearPublicChatContext(botId.value);
|
clearPublicChatContext(botId.value);
|
||||||
}
|
}
|
||||||
|
const currentAccessToken = String(accessStore.accessToken || '').trim();
|
||||||
const tokenResp = await getResponseBody<string>(
|
if (currentAccessToken) {
|
||||||
baseRequestClient.get('/api/temp-token/create'),
|
const authResult = await resolveAuthenticatedAccess(currentAccessToken, {
|
||||||
);
|
allowRefresh: true,
|
||||||
if (tokenResp.errorCode !== 0 || !tokenResp.data) {
|
});
|
||||||
throw new Error($t('bot.publicChatInitError'));
|
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 = () => {
|
const resetConversationState = () => {
|
||||||
@@ -1116,7 +1234,7 @@ const initPublicChat = async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
initError.value =
|
initError.value =
|
||||||
error instanceof Error && error.message
|
error instanceof Error && error.message
|
||||||
? error.message
|
? normalizePublicChatErrorMessage(error.message)
|
||||||
: $t('bot.publicChatInitError');
|
: $t('bot.publicChatInitError');
|
||||||
setPageTitle();
|
setPageTitle();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1221,9 +1339,16 @@ onBeforeUnmount(() => {
|
|||||||
class="public-chat-banner public-chat-banner-warning"
|
class="public-chat-banner public-chat-banner-warning"
|
||||||
>
|
>
|
||||||
<p class="public-chat-banner-title">
|
<p class="public-chat-banner-title">
|
||||||
{{ $t('bot.publicPageBlocked') }}
|
{{ $t('bot.publicPageLoginRequired') }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
@@ -1596,6 +1721,10 @@ onBeforeUnmount(() => {
|
|||||||
border: 1px solid #fed7aa;
|
border: 1px solid #fed7aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-chat-banner-action {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.public-chat-message-row {
|
.public-chat-message-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<svelte:options customElement={{
|
<svelte:options customElement={{
|
||||||
tag: "tinyflow-component",
|
tag: "tinyflow-component",
|
||||||
shadow: "none",
|
shadow: "none",
|
||||||
// props: {
|
props: {
|
||||||
// options: { reflect: true, type: 'Object', attribute: 'options' },
|
options: { type: 'Object', attribute: 'options' },
|
||||||
// onInit: { reflect: true, type: 'Object', attribute: 'onInit' }
|
onInit: { type: 'Object', attribute: 'on-init' }
|
||||||
// },
|
},
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -14,31 +14,50 @@
|
|||||||
import type {TinyflowData, TinyflowOptions} from '#types';
|
import type {TinyflowData, TinyflowOptions} from '#types';
|
||||||
import {setContext} from 'svelte';
|
import {setContext} from 'svelte';
|
||||||
|
|
||||||
const { options, onInit }: {
|
const props = $props<{
|
||||||
options: TinyflowOptions,
|
options: TinyflowOptions,
|
||||||
onInit: (svelteFlow: ReturnType<typeof useSvelteFlow>) => void,
|
onInit: (svelteFlow: ReturnType<typeof useSvelteFlow>) => void,
|
||||||
} = $props();
|
}>();
|
||||||
|
|
||||||
let { data } = options;
|
const parseData = (source: TinyflowOptions['data']) => {
|
||||||
let initialViewport = null;
|
let nextData = source;
|
||||||
|
if (typeof nextData === 'string') {
|
||||||
if (typeof data === 'string') {
|
try {
|
||||||
try {
|
nextData = JSON.parse(nextData.trim());
|
||||||
data = JSON.parse(data.trim());
|
} catch (error) {
|
||||||
} catch (e) {
|
console.error('Invalid JSON data:', nextData, error);
|
||||||
console.error('Invalid JSON data:', data);
|
}
|
||||||
}
|
}
|
||||||
}
|
return nextData as TinyflowData | null | undefined;
|
||||||
initialViewport = (data as TinyflowData)?.viewport || null;
|
};
|
||||||
|
|
||||||
|
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(
|
store.init(
|
||||||
(data as TinyflowData)?.nodes || [],
|
data?.nodes || [],
|
||||||
(data as TinyflowData)?.edges || [],
|
data?.edges || [],
|
||||||
initialViewport,
|
initialViewport,
|
||||||
);
|
);
|
||||||
setContext('tinyflow_options', options);
|
setContext('tinyflow_options', contextOptions);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<SvelteFlowProvider>
|
<SvelteFlowProvider>
|
||||||
<TinyflowCore {onInit} />
|
<TinyflowCore onInit={props.onInit} />
|
||||||
</SvelteFlowProvider>
|
</SvelteFlowProvider>
|
||||||
|
|||||||
@@ -46,8 +46,6 @@
|
|||||||
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||||
const svelteFlow = useSvelteFlow();
|
const svelteFlow = useSvelteFlow();
|
||||||
|
|
||||||
onInit(svelteFlow);
|
|
||||||
|
|
||||||
let showEdgePanel = $state(false);
|
let showEdgePanel = $state(false);
|
||||||
let currentEdge = $state<Edge | null>(null);
|
let currentEdge = $state<Edge | null>(null);
|
||||||
let nodePickerVisible = $state(false);
|
let nodePickerVisible = $state(false);
|
||||||
@@ -342,7 +340,7 @@
|
|||||||
{ duration: 180 }
|
{ duration: 180 }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
svelteFlow.zoomTo(zoom, { duration: 180 });
|
(svelteFlow as any).zoomTo?.(zoom, { duration: 180 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,6 +724,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
onInit(svelteFlow);
|
||||||
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
||||||
repairOrphanParentNodes();
|
repairOrphanParentNodes();
|
||||||
if (!readonly) {
|
if (!readonly) {
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import type {MyHTMLButtonAttributes} from './types';
|
import type {MyHTMLButtonAttributes} from './types';
|
||||||
import type {Snippet} from 'svelte';
|
import type {Snippet} from 'svelte';
|
||||||
|
|
||||||
const { children, primary, onclick, ...rest }: MyHTMLButtonAttributes & {
|
const {
|
||||||
children?: Snippet;
|
children,
|
||||||
primary?: boolean;
|
primary,
|
||||||
} = $props();
|
onclick,
|
||||||
|
class: className = '',
|
||||||
|
style = '',
|
||||||
|
...rest
|
||||||
|
}: Omit<MyHTMLButtonAttributes, 'class' | 'style'> & {
|
||||||
|
children?: Snippet;
|
||||||
|
primary?: boolean;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
{...rest}
|
{...rest}
|
||||||
onclick={onclick}
|
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?.()}
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {MyHTMLInputAttributes} from './types';
|
import type {MyHTMLInputAttributes} from './types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {Button, Input} from './index';
|
import {Button, Input} from './index';
|
||||||
import type {MyHTMLAttributes} from './types';
|
import type {MyHTMLAttributes} from './types';
|
||||||
|
|
||||||
const { placeholder, label, value, buttonText = "选择...",onChosen, ...rest }: {
|
const {
|
||||||
placeholder?: string;
|
placeholder,
|
||||||
label?: any;
|
label,
|
||||||
value?: any;
|
value,
|
||||||
buttonText?:string
|
buttonText = "选择...",
|
||||||
onChosen?: (value?: any, label?: any, event?: Event) => void,
|
onChosen,
|
||||||
} & MyHTMLAttributes = $props();
|
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>
|
</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 type="hidden" value={value}>
|
||||||
<Input value={label} {placeholder} style="flex-grow: 1;" disabled/>
|
<Input value={label} {placeholder} style="flex-grow: 1;" disabled/>
|
||||||
<Button onclick={(e)=>{
|
<Button onclick={(e)=>{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {Snippet} from 'svelte';
|
import type {Snippet} from 'svelte';
|
||||||
import {Render} from './index';
|
import {Render} from './index';
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {MyHTMLInputAttributes} from './types';
|
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>
|
</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}"
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from './button.svelte';
|
import Button from './button.svelte';
|
||||||
import type {MyHTMLButtonAttributes} from './types';
|
import type {MyHTMLButtonAttributes} from './types';
|
||||||
|
|
||||||
const { ...rest }: MyHTMLButtonAttributes = $props();
|
const {
|
||||||
|
class: className = '',
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: MyHTMLButtonAttributes & {
|
||||||
|
class?: string | null;
|
||||||
|
style?: string | null;
|
||||||
|
} = $props();
|
||||||
</script>
|
</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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path
|
<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>
|
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>
|
||||||
|
|||||||
@@ -29,8 +29,9 @@
|
|||||||
let floatingRef: any = $state();
|
let floatingRef: any = $state();
|
||||||
let hoveredItem: SelectItem | null = $state(null);
|
let hoveredItem: SelectItem | null = $state(null);
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
|
let selectedItem = $state<SelectItem | null>(null);
|
||||||
|
|
||||||
let selectedItem = $derived.by(() => {
|
$effect(() => {
|
||||||
let found: SelectItem | null = null;
|
let found: SelectItem | null = null;
|
||||||
const findItem = (items: SelectItem[]) => {
|
const findItem = (items: SelectItem[]) => {
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
@@ -41,8 +42,15 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
findItem(refOptions);
|
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() {
|
function closeMenu() {
|
||||||
floatingRef?.hide();
|
floatingRef?.hide();
|
||||||
@@ -111,12 +119,12 @@
|
|||||||
<div class="tf-mixed-box tf-mixed-ref-box">
|
<div class="tf-mixed-box tf-mixed-ref-box">
|
||||||
{#if selectedItem}
|
{#if selectedItem}
|
||||||
<div class="tf-mixed-sel-val">
|
<div class="tf-mixed-sel-val">
|
||||||
{#if selectedItem.nodeType && nodeIcons[selectedItem.nodeType]}
|
{#if selectedNodeType && selectedNodeIcon}
|
||||||
<span class="tf-mixed-val-icon">
|
<span class="tf-mixed-val-icon">
|
||||||
{@html nodeIcons[selectedItem.nodeType]}
|
{@html selectedNodeIcon}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="tf-mixed-val-name">{selectedItem.displayLabel || selectedItem.label}</span>
|
<span class="tf-mixed-val-name">{selectedLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="tf-mixed-placeholder">{placeholder}</div>
|
<div class="tf-mixed-placeholder">{placeholder}</div>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { target } = $props();
|
const props = $props<{ target?: string | (() => unknown) }>();
|
||||||
if (typeof target === 'undefined') target = "undefined";
|
const target = $derived(
|
||||||
|
typeof props.target === 'undefined' ? 'undefined' : props.target
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -9,4 +13,3 @@
|
|||||||
{:else }
|
{:else }
|
||||||
{@html target}
|
{@html target}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {FloatingTrigger} from './index';
|
import {FloatingTrigger} from './index';
|
||||||
import type {SelectItem} from '#types';
|
import type {SelectItem} from '#types';
|
||||||
@@ -541,6 +543,7 @@
|
|||||||
color: var(--tf-text-secondary);
|
color: var(--tf-text-secondary);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
line-clamp: 2;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {MyHTMLTextareaAttributes} from './types';
|
import type {MyHTMLTextareaAttributes} from './types';
|
||||||
|
|
||||||
const { value, height, autoHeight = true, maxHeight, onHeightChange, ...rest }: MyHTMLTextareaAttributes & {
|
const {
|
||||||
value?: any;
|
value,
|
||||||
height?: string | number;
|
height,
|
||||||
autoHeight?: boolean;
|
autoHeight = true,
|
||||||
rows?: number;
|
maxHeight,
|
||||||
maxHeight?: string | number;
|
onHeightChange,
|
||||||
onHeightChange?: (height: string) => void;
|
class: className = '',
|
||||||
} = $props();
|
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 textareaEl: HTMLTextAreaElement;
|
||||||
let defaultHeight: number;
|
let defaultHeight: number;
|
||||||
@@ -69,6 +82,7 @@
|
|||||||
bind:this={textareaEl}
|
bind:this={textareaEl}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
{...rest}
|
{...rest}
|
||||||
|
{style}
|
||||||
oninput={(e)=>{
|
oninput={(e)=>{
|
||||||
adjustHeight();
|
adjustHeight();
|
||||||
rest.oninput?.(e);
|
rest.oninput?.(e);
|
||||||
@@ -77,5 +91,5 @@
|
|||||||
adjustHeight();
|
adjustHeight();
|
||||||
rest.onchange?.(e);
|
rest.onchange?.(e);
|
||||||
}}
|
}}
|
||||||
class="tf-textarea nodrag nowheel {rest.class}"
|
class="tf-textarea nodrag nowheel {className}"
|
||||||
>{value || ""}</textarea>
|
>{String(value ?? '')}</textarea>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import {Compartment, EditorState, type Transaction} from '@codemirror/state';
|
import {Compartment, EditorState, type Transaction} from '@codemirror/state';
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
});
|
});
|
||||||
triggerObject?.hide();
|
triggerObject?.hide();
|
||||||
};
|
};
|
||||||
let selectItems = useRefOptions(useChildrenOnly);
|
let selectItems = useRefOptions(() => useChildrenOnly === true);
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -175,4 +175,3 @@
|
|||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -275,13 +275,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-item-inline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--tf-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-more-setting {
|
.input-more-setting {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import {useCopyNode} from '../utils/useCopyNode.svelte';
|
import {useCopyNode} from '../utils/useCopyNode.svelte';
|
||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
wrapperClass = '',
|
wrapperClass = '',
|
||||||
onCollapse
|
onCollapse
|
||||||
}: {
|
}: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
id?: NodeProps['id'],
|
id?: NodeProps['id'],
|
||||||
icon?: Snippet,
|
icon?: Snippet,
|
||||||
handle?: Snippet,
|
handle?: Snippet,
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
onCollapse?: (key: string) => void,
|
onCollapse?: (key: string) => void,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let activeKeys = data.expand ? ['key'] : [];
|
const activeKeys = $derived.by(() => data.expand ? ['key'] : []);
|
||||||
const { updateNodeData, getNode } = useSvelteFlow();
|
const { updateNodeData, getNode } = useSvelteFlow();
|
||||||
const updateNodeInternals = useUpdateNodeInternals();
|
const updateNodeInternals = useUpdateNodeInternals();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {defaultKeymap, history, historyKeymap} from '@codemirror/commands';
|
import {defaultKeymap, history, historyKeymap} from '@codemirror/commands';
|
||||||
import {Compartment, EditorState, type Extension} from '@codemirror/state';
|
import {Compartment, EditorState, type Extension} from '@codemirror/state';
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
});
|
});
|
||||||
triggerObject?.hide();
|
triggerObject?.hide();
|
||||||
};
|
};
|
||||||
let selectItems = useRefOptions(useChildrenOnly);
|
let selectItems = useRefOptions(() => useChildrenOnly === true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -7,11 +9,11 @@
|
|||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import type {SelectItem} from '#types';
|
import type {SelectItem, TinyflowNodeData} from '#types';
|
||||||
import CodeScriptEditor from '../core/CodeScriptEditor.svelte';
|
import CodeScriptEditor from '../core/CodeScriptEditor.svelte';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -669,7 +671,20 @@
|
|||||||
style="flex: 1; height: 100%; min-width: 0;"
|
style="flex: 1; height: 100%; min-width: 0;"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{: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}`}
|
{branch.label || `条件分支${index + 1}`}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1181,26 +1196,6 @@
|
|||||||
height: 32px;
|
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 {
|
.condition-expression-panel {
|
||||||
min-height: 140px;
|
min-height: 140px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -7,12 +9,12 @@
|
|||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
|
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
|
||||||
import type {Parameter} from '#types';
|
import type {Parameter, TinyflowNodeData} from '#types';
|
||||||
import {deepEqual} from '#components/utils/deepEqual';
|
import {deepEqual} from '#components/utils/deepEqual';
|
||||||
|
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
message: e.target.value
|
message: e.target.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}} value={data.message as string||""} />
|
}} value={String(data.message || '')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -128,5 +130,3 @@
|
|||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type Node, type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {type Node, type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -8,17 +10,24 @@
|
|||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const props = $props<{
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
}>();
|
||||||
|
const data = $derived(props.data);
|
||||||
|
|
||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
let currentNode = useNodesData(currentNodeId);
|
let currentNode = useNodesData(currentNodeId);
|
||||||
const { addParameter } = useAddParameter();
|
const { addParameter } = useAddParameter();
|
||||||
const flowInstance = useSvelteFlow();
|
const flowInstance = useSvelteFlow();
|
||||||
const { updateNodeData: updateNodeDataInner } = flowInstance;
|
const { updateNodeData: updateNodeDataInner } = flowInstance;
|
||||||
|
const getRestProps = () => {
|
||||||
|
const { data: _data, ...rest } = props;
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
const editorParameters = $derived.by(() => {
|
const editorParameters = $derived.by(() => {
|
||||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||||
});
|
});
|
||||||
@@ -49,19 +58,22 @@
|
|||||||
updateFormValue(form, (event.target as any)?.value);
|
updateFormValue(form, (event.target as any)?.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const node = {
|
const buildNode = (nextData = data) => ({
|
||||||
...rest,
|
...getRestProps(),
|
||||||
id: currentNodeId,
|
id: currentNodeId,
|
||||||
data
|
data: nextData
|
||||||
} as Node;
|
} as Node);
|
||||||
|
|
||||||
const externalElement = document.createElement('div') as HTMLElement;
|
const externalElement = document.createElement('div') as HTMLElement;
|
||||||
const options = getOptions();
|
const options = getOptions();
|
||||||
const customNode = options.customNodes![rest.type as string];
|
const customNode = $derived.by(() => options.customNodes![getRestProps().type as string]);
|
||||||
customNode.render?.(externalElement, node, flowInstance);
|
const forms = $derived.by(() => customNode.forms);
|
||||||
const forms = customNode.forms;
|
|
||||||
|
|
||||||
let container: HTMLElement;
|
onMount(() => {
|
||||||
|
customNode.render?.(externalElement, buildNode(), flowInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
let container = $state<HTMLElement | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
|
// 注意:由于 $effect 的 state 自动追踪问题,需要 data.expand 方在 if 里的最前面
|
||||||
if (data.expand && container) {
|
if (data.expand && container) {
|
||||||
@@ -71,7 +83,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
customNode.onUpdate?.(externalElement, { ...node, data });
|
customNode.onUpdate?.(externalElement, buildNode(data));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,7 +106,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<NodeWrapper data={{...data, description: customNode.description}} {...rest}>
|
<NodeWrapper data={{...data, description: customNode.description}} {...getRestProps()}>
|
||||||
|
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
{@html customNode.icon}
|
{@html customNode.icon}
|
||||||
@@ -285,6 +297,7 @@
|
|||||||
background: var(--tf-slider-track-bg);
|
background: var(--tf-slider-track-bg);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps} from '@xyflow/svelte';
|
import {type NodeProps} from '@xyflow/svelte';
|
||||||
@@ -5,9 +7,10 @@
|
|||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import RefParameterList from '../core/RefParameterList.svelte';
|
import RefParameterList from '../core/RefParameterList.svelte';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -46,5 +49,3 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -10,9 +12,10 @@
|
|||||||
// 添加生命周期函数
|
// 添加生命周期函数
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -77,7 +80,7 @@
|
|||||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||||
});
|
});
|
||||||
const bodyMethods = new Set(['post', 'put', 'delete', 'patch']);
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +218,7 @@
|
|||||||
rows={5}
|
rows={5}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
placeholder="请输入 json 信息" value={data.bodyJson}
|
placeholder="请输入 json 信息" value={String(data.bodyJson || '')}
|
||||||
oninput={(e:any)=>{
|
oninput={(e:any)=>{
|
||||||
updateNodeData(currentNodeId,{
|
updateNodeData(currentNodeId,{
|
||||||
bodyJson: e.target.value,
|
bodyJson: e.target.value,
|
||||||
@@ -233,7 +236,7 @@
|
|||||||
rows={5}
|
rows={5}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
placeholder="请输入请求信息" value={data.bodyRaw}
|
placeholder="请输入请求信息" value={String(data.bodyRaw || '')}
|
||||||
oninput={(e:any)=>{
|
oninput={(e:any)=>{
|
||||||
updateNodeData(currentNodeId,{
|
updateNodeData(currentNodeId,{
|
||||||
bodyRaw: e.target.value,
|
bodyRaw: e.target.value,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -7,7 +9,7 @@
|
|||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.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 ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
import {
|
import {
|
||||||
buildEditorReferenceParameters,
|
buildEditorReferenceParameters,
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
} from '../../utils/workflowNodeFields';
|
} from '../../utils/workflowNodeFields';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/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 toggleQueryContextNode = (nodeId: string) => {
|
||||||
const currentIds = [...queryContextNodeIds];
|
const currentIds = [...queryContextNodeIds];
|
||||||
const exists = currentIds.includes(nodeId);
|
const exists = currentIds.includes(nodeId);
|
||||||
@@ -404,6 +396,7 @@
|
|||||||
background: var(--tf-slider-track-bg);
|
background: var(--tf-slider-track-bg);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {Handle, type NodeProps, Position} from '@xyflow/svelte';
|
import {Handle, type NodeProps, Position} from '@xyflow/svelte';
|
||||||
@@ -5,9 +7,10 @@
|
|||||||
import RefParameterList from '../core/RefParameterList.svelte';
|
import RefParameterList from '../core/RefParameterList.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -8,11 +10,11 @@
|
|||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import type {SelectItem} from '#types';
|
import type {SelectItem, TinyflowNodeData} from '#types';
|
||||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -109,9 +111,9 @@
|
|||||||
placeholder="请输入关键字"
|
placeholder="请输入关键字"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
value={data.keyword || ''}
|
value={String(data.keyword || '')}
|
||||||
oninput={(e)=>{
|
oninput={(e: Event)=>{
|
||||||
const newValue = e.target.value;
|
const newValue = (e.target as HTMLInputElement).value;
|
||||||
updateNodeData(currentNodeId, ()=>{
|
updateNodeData(currentNodeId, ()=>{
|
||||||
return {
|
return {
|
||||||
keyword: newValue
|
keyword: newValue
|
||||||
@@ -127,10 +129,10 @@
|
|||||||
mode="input"
|
mode="input"
|
||||||
placeholder="搜索的数据条数"
|
placeholder="搜索的数据条数"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
value={data.limit || ''}
|
value={String(data.limit || '')}
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
oninput={(e)=>{
|
oninput={(e: Event)=>{
|
||||||
const newValue = e.target.value;
|
const newValue = (e.target as HTMLInputElement).value;
|
||||||
updateNodeData(currentNodeId, ()=>{
|
updateNodeData(currentNodeId, ()=>{
|
||||||
return {
|
return {
|
||||||
limit: newValue
|
limit: newValue
|
||||||
@@ -170,4 +172,3 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {Heading, Input, Textarea} from '../base';
|
import {Heading, Input, Textarea} from '../base';
|
||||||
@@ -12,9 +14,10 @@
|
|||||||
normalizeStartNodeData,
|
normalizeStartNodeData,
|
||||||
normalizeStartFormMeta,
|
normalizeStartFormMeta,
|
||||||
} from '../../utils/workflowNodeFields';
|
} from '../../utils/workflowNodeFields';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<svelte:options customElement={{ props: {} }} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
|
||||||
@@ -7,9 +9,10 @@
|
|||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
|
import type {TinyflowNodeData} from '#types';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: TinyflowNodeData,
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@
|
|||||||
template: e.target.value
|
template: e.target.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}} value={data.template ||""} />
|
}} value={String(data.template || '')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -89,5 +92,3 @@
|
|||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 currentNodeId = getCurrentNodeId();
|
||||||
const currentNode = useNodesData(currentNodeId);
|
const currentNode = useNodesData(currentNodeId);
|
||||||
const { nodes, edges, nodeLookup } = $derived(useStore());
|
const { nodes, edges, nodeLookup } = $derived(useStore());
|
||||||
|
const isChildrenOnly = () =>
|
||||||
|
typeof useChildrenOnly === 'function'
|
||||||
|
? useChildrenOnly()
|
||||||
|
: useChildrenOnly;
|
||||||
|
|
||||||
let selectItems = $derived.by(() => {
|
let selectItems = $derived.by(() => {
|
||||||
const resultOptions = [];
|
const resultOptions = [];
|
||||||
@@ -158,7 +164,7 @@ export const useRefOptions: any = (useChildrenOnly: boolean = false) => {
|
|||||||
//通过 nodeLookup.get 才会得到有 parentId 的 node
|
//通过 nodeLookup.get 才会得到有 parentId 的 node
|
||||||
const cNode = nodeLookup.get(currentNodeId)!;
|
const cNode = nodeLookup.get(currentNodeId)!;
|
||||||
|
|
||||||
if (useChildrenOnly) {
|
if (isChildrenOnly()) {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const nodeIsChildren = node.parentId === currentNode.current.id;
|
const nodeIsChildren = node.parentId === currentNode.current.id;
|
||||||
if (nodeIsChildren) {
|
if (nodeIsChildren) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Node, useSvelteFlow } from '@xyflow/svelte';
|
|||||||
export type TinyflowData = Partial<
|
export type TinyflowData = Partial<
|
||||||
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
||||||
>;
|
>;
|
||||||
|
export type TinyflowNodeData = Record<string, any>;
|
||||||
export type TinyflowTheme = 'light' | 'dark';
|
export type TinyflowTheme = 'light' | 'dark';
|
||||||
|
|
||||||
export type SelectItem = {
|
export type SelectItem = {
|
||||||
|
|||||||
@@ -897,7 +897,10 @@ describe('workflow node fields', () => {
|
|||||||
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
||||||
true,
|
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',
|
'user_input',
|
||||||
);
|
);
|
||||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||||
|
|||||||
@@ -1237,7 +1237,10 @@ function replaceStartFieldReferenceValue(
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
|
export function collectDownstreamNodeIds(
|
||||||
|
rootNodeId: string,
|
||||||
|
edges: Edge[],
|
||||||
|
) {
|
||||||
const nodeIds = new Set<string>();
|
const nodeIds = new Set<string>();
|
||||||
|
|
||||||
const visit = (nodeId: string) => {
|
const visit = (nodeId: string) => {
|
||||||
@@ -1281,7 +1284,7 @@ export function renameStartFieldReferencesInNodes(
|
|||||||
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
|
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
|
||||||
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
|
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
|
||||||
|
|
||||||
const nextNodes = nodes.map((node) => {
|
const nextNodes: Node[] = nodes.map((node) => {
|
||||||
if (node.id === normalizedStartNodeId) {
|
if (node.id === normalizedStartNodeId) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@@ -1295,7 +1298,7 @@ export function renameStartFieldReferencesInNodes(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
data: nextData,
|
data: nextData ?? {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user