mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(platform): Phase 3 Sprint 1-3 — permission, agent binding, dashboard
This commit is contained in:
parent
3d58a48eae
commit
3506c55bc8
@ -51,6 +51,7 @@ import vip.mate.agent.graph.plan.edge.StepProgressDispatcher;
|
||||
import vip.mate.agent.graph.plan.node.*;
|
||||
import vip.mate.agent.graph.plan.state.PlanStateKeys;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.config.GraphObservationProperties;
|
||||
import vip.mate.exception.MateClawException;
|
||||
@ -92,6 +93,7 @@ import java.util.Set;
|
||||
public class AgentGraphBuilder {
|
||||
|
||||
private final ToolRegistry toolRegistry;
|
||||
private final AgentBindingService agentBindingService;
|
||||
private final SkillService skillService;
|
||||
private final vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
|
||||
private final ConversationService conversationService;
|
||||
@ -126,6 +128,10 @@ public class AgentGraphBuilder {
|
||||
// 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema)
|
||||
toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools());
|
||||
|
||||
// Per-agent tool 绑定过滤:如果 agent 有自定义 tool 绑定,则只保留绑定的工具
|
||||
Set<String> boundTools = agentBindingService.getBoundToolNames(entity.getId());
|
||||
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
|
||||
|
||||
// 统一使用全局默认模型(AgentEntity.modelName 为历史残留字段,不参与运行时选择)
|
||||
ModelConfigEntity runtimeModel;
|
||||
try {
|
||||
@ -533,8 +539,9 @@ public class AgentGraphBuilder {
|
||||
? workspacePrompt
|
||||
: (entity.getSystemPrompt() != null ? entity.getSystemPrompt() : "");
|
||||
|
||||
// 使用 skill runtime 构建技能增强(分层注入,不再全量拼接)
|
||||
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement();
|
||||
// 使用 skill runtime 构建技能增强(per-agent 绑定过滤)
|
||||
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());
|
||||
String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(boundSkillIds);
|
||||
|
||||
// 工具调用指导
|
||||
String toolGuidance = """
|
||||
|
||||
@ -81,6 +81,13 @@ public class AgentService {
|
||||
agentInstances.remove(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Agent 运行时缓存(绑定变更后需调用,使下次对话重新构建 Agent)
|
||||
*/
|
||||
public void invalidateAgentCache(Long agentId) {
|
||||
agentInstances.remove(agentId);
|
||||
}
|
||||
|
||||
// ==================== 运行时入口 ====================
|
||||
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
|
||||
@ -78,6 +78,20 @@ public class AgentToolSet {
|
||||
return new AgentToolSet(toolBeans, filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅保留指定名称的工具(白名单模式,用于 per-agent 绑定)
|
||||
*
|
||||
* @param allowedTools 允许的工具名集合(为 null 时直接返回 this,表示使用全局默认)
|
||||
*/
|
||||
public AgentToolSet withAllowedToolsOnly(Set<String> allowedTools) {
|
||||
if (allowedTools == null) {
|
||||
return this; // null = 无绑定,使用全局默认
|
||||
}
|
||||
List<ToolCallback> filtered = new ArrayList<>(callbacks);
|
||||
filtered.removeIf(cb -> !allowedTools.contains(cb.getToolDefinition().name()));
|
||||
return new AgentToolSet(toolBeans, filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 ToolCallback
|
||||
*/
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
package vip.mate.agent.binding.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent 能力绑定接口
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Tag(name = "Agent能力绑定")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents/{agentId}")
|
||||
@RequiredArgsConstructor
|
||||
public class AgentBindingController {
|
||||
|
||||
private final AgentBindingService bindingService;
|
||||
private final AgentService agentService;
|
||||
private final AuditEventService auditEventService;
|
||||
|
||||
// ==================== Skill Bindings ====================
|
||||
|
||||
@Operation(summary = "获取 Agent 已绑定的 Skills")
|
||||
@GetMapping("/skills")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<AgentSkillBinding>> listSkills(@PathVariable Long agentId) {
|
||||
return R.ok(bindingService.listSkillBindings(agentId));
|
||||
}
|
||||
|
||||
@Operation(summary = "批量设置 Agent 的 Skill 绑定")
|
||||
@PutMapping("/skills")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> setSkills(@PathVariable Long agentId, @RequestBody List<Long> skillIds) {
|
||||
bindingService.setSkillBindings(agentId, skillIds);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
|
||||
"skills=" + skillIds.size(), null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "绑定单个 Skill")
|
||||
@PostMapping("/skills/{skillId}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentSkillBinding> bindSkill(@PathVariable Long agentId, @PathVariable Long skillId) {
|
||||
AgentSkillBinding binding = bindingService.bindSkill(agentId, skillId);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
return R.ok(binding);
|
||||
}
|
||||
|
||||
@Operation(summary = "解绑单个 Skill")
|
||||
@DeleteMapping("/skills/{skillId}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> unbindSkill(@PathVariable Long agentId, @PathVariable Long skillId) {
|
||||
bindingService.unbindSkill(agentId, skillId);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// ==================== Tool Bindings ====================
|
||||
|
||||
@Operation(summary = "获取 Agent 已绑定的 Tools")
|
||||
@GetMapping("/tools")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<AgentToolBinding>> listTools(@PathVariable Long agentId) {
|
||||
return R.ok(bindingService.listToolBindings(agentId));
|
||||
}
|
||||
|
||||
@Operation(summary = "批量设置 Agent 的 Tool 绑定")
|
||||
@PutMapping("/tools")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<Void> setTools(@PathVariable Long agentId, @RequestBody List<String> toolNames) {
|
||||
bindingService.setToolBindings(agentId, toolNames);
|
||||
agentService.invalidateAgentCache(agentId);
|
||||
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
|
||||
"tools=" + toolNames.size(), null);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package vip.mate.agent.binding.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("mate_agent_skill")
|
||||
public class AgentSkillBinding {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long agentId;
|
||||
private Long skillId;
|
||||
private Boolean enabled;
|
||||
private String configJson;
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package vip.mate.agent.binding.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("mate_agent_tool")
|
||||
public class AgentToolBinding {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long agentId;
|
||||
private String toolName;
|
||||
private Boolean enabled;
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.agent.binding.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||
|
||||
@Mapper
|
||||
public interface AgentSkillBindingMapper extends BaseMapper<AgentSkillBinding> {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.agent.binding.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
|
||||
@Mapper
|
||||
public interface AgentToolBindingMapper extends BaseMapper<AgentToolBinding> {
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
package vip.mate.agent.binding.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.binding.model.AgentSkillBinding;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Agent 能力绑定服务
|
||||
* <p>
|
||||
* 管理 Agent 与 Skill/Tool 的关联关系。
|
||||
* 当 Agent 没有任何绑定记录时,默认使用全局 enabled 的 tool/skill(向后兼容)。
|
||||
* 一旦有绑定记录,则严格按绑定列表过滤。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentBindingService {
|
||||
|
||||
private final AgentSkillBindingMapper skillBindingMapper;
|
||||
private final AgentToolBindingMapper toolBindingMapper;
|
||||
|
||||
// ==================== Skill Bindings ====================
|
||||
|
||||
public List<AgentSkillBinding> listSkillBindings(Long agentId) {
|
||||
return skillBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId)
|
||||
.orderByAsc(AgentSkillBinding::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 绑定的 enabled skill ID 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
*/
|
||||
public Set<Long> getBoundSkillIds(Long agentId) {
|
||||
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
return null; // 无绑定 → 全局默认
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||
.map(AgentSkillBinding::getSkillId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
|
||||
// 检查是否已绑定
|
||||
AgentSkillBinding existing = skillBindingMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId)
|
||||
.eq(AgentSkillBinding::getSkillId, skillId));
|
||||
if (existing != null) {
|
||||
existing.setEnabled(true);
|
||||
skillBindingMapper.updateById(existing);
|
||||
return existing;
|
||||
}
|
||||
AgentSkillBinding binding = new AgentSkillBinding();
|
||||
binding.setAgentId(agentId);
|
||||
binding.setSkillId(skillId);
|
||||
binding.setEnabled(true);
|
||||
skillBindingMapper.insert(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
public void unbindSkill(Long agentId, Long skillId) {
|
||||
skillBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId)
|
||||
.eq(AgentSkillBinding::getSkillId, skillId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置 Agent 的 skill 绑定(替换模式)
|
||||
*/
|
||||
public void setSkillBindings(Long agentId, List<Long> skillIds) {
|
||||
// 删除旧绑定
|
||||
skillBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentSkillBinding>()
|
||||
.eq(AgentSkillBinding::getAgentId, agentId));
|
||||
// 创建新绑定
|
||||
if (skillIds != null) {
|
||||
for (Long skillId : skillIds) {
|
||||
AgentSkillBinding binding = new AgentSkillBinding();
|
||||
binding.setAgentId(agentId);
|
||||
binding.setSkillId(skillId);
|
||||
binding.setEnabled(true);
|
||||
skillBindingMapper.insert(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Tool Bindings ====================
|
||||
|
||||
public List<AgentToolBinding> listToolBindings(Long agentId) {
|
||||
return toolBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId)
|
||||
.orderByAsc(AgentToolBinding::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 绑定的 enabled tool name 集合。
|
||||
* 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。
|
||||
*/
|
||||
public Set<String> getBoundToolNames(Long agentId) {
|
||||
List<AgentToolBinding> bindings = listToolBindings(agentId);
|
||||
if (bindings.isEmpty()) {
|
||||
return null; // 无绑定 → 全局默认
|
||||
}
|
||||
return bindings.stream()
|
||||
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
|
||||
.map(AgentToolBinding::getToolName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public AgentToolBinding bindTool(Long agentId, String toolName) {
|
||||
AgentToolBinding existing = toolBindingMapper.selectOne(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId)
|
||||
.eq(AgentToolBinding::getToolName, toolName));
|
||||
if (existing != null) {
|
||||
existing.setEnabled(true);
|
||||
toolBindingMapper.updateById(existing);
|
||||
return existing;
|
||||
}
|
||||
AgentToolBinding binding = new AgentToolBinding();
|
||||
binding.setAgentId(agentId);
|
||||
binding.setToolName(toolName);
|
||||
binding.setEnabled(true);
|
||||
toolBindingMapper.insert(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
public void unbindTool(Long agentId, String toolName) {
|
||||
toolBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId)
|
||||
.eq(AgentToolBinding::getToolName, toolName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置 Agent 的 tool 绑定(替换模式)
|
||||
*/
|
||||
public void setToolBindings(Long agentId, List<String> toolNames) {
|
||||
toolBindingMapper.delete(
|
||||
new LambdaQueryWrapper<AgentToolBinding>()
|
||||
.eq(AgentToolBinding::getAgentId, agentId));
|
||||
if (toolNames != null) {
|
||||
for (String toolName : toolNames) {
|
||||
AgentToolBinding binding = new AgentToolBinding();
|
||||
binding.setAgentId(agentId);
|
||||
binding.setToolName(toolName);
|
||||
binding.setEnabled(true);
|
||||
toolBindingMapper.insert(binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,9 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@ -29,10 +31,12 @@ import java.util.concurrent.Executors;
|
||||
public class AgentController {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final AuditEventService auditEventService;
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@Operation(summary = "获取Agent列表")
|
||||
@GetMapping
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<AgentEntity>> list(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
if (workspaceId != null) {
|
||||
@ -43,32 +47,42 @@ public class AgentController {
|
||||
|
||||
@Operation(summary = "获取Agent详情")
|
||||
@GetMapping("/{id}")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<AgentEntity> get(@PathVariable Long id) {
|
||||
return R.ok(agentService.getAgent(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建Agent")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentEntity> create(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestBody AgentEntity agent) {
|
||||
if (workspaceId != null) {
|
||||
agent.setWorkspaceId(workspaceId);
|
||||
}
|
||||
return R.ok(agentService.createAgent(agent));
|
||||
AgentEntity created = agentService.createAgent(agent);
|
||||
auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null);
|
||||
return R.ok(created);
|
||||
}
|
||||
|
||||
@Operation(summary = "更新Agent")
|
||||
@PutMapping("/{id}")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent) {
|
||||
agent.setId(id);
|
||||
return R.ok(agentService.updateAgent(agent));
|
||||
AgentEntity updated = agentService.updateAgent(agent);
|
||||
auditEventService.record("UPDATE", "AGENT", String.valueOf(id), updated.getName(), null);
|
||||
return R.ok(updated);
|
||||
}
|
||||
|
||||
@Operation(summary = "删除Agent")
|
||||
@DeleteMapping("/{id}")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
AgentEntity agent = agentService.getAgent(id);
|
||||
agentService.deleteAgent(id);
|
||||
auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent != null ? agent.getName() : null, null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package vip.mate.audit.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.audit.model.AuditEventEntity;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 审计事件查询接口
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Tag(name = "审计事件")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/audit")
|
||||
@RequiredArgsConstructor
|
||||
public class AuditEventController {
|
||||
|
||||
private final AuditEventService auditEventService;
|
||||
|
||||
@Operation(summary = "分页查询审计事件")
|
||||
@GetMapping("/events")
|
||||
@RequireWorkspaceRole("admin")
|
||||
public R<IPage<AuditEventEntity>> listEvents(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestParam(required = false) String action,
|
||||
@RequestParam(required = false) String resourceType,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return R.ok(auditEventService.listEvents(workspaceId, action, resourceType, startTime, endTime, page, size));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package vip.mate.audit.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 操作审计事件实体
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_audit_event")
|
||||
public class AuditEventEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
private Long workspaceId;
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String username;
|
||||
|
||||
/** 操作类型:CREATE / UPDATE / DELETE / LOGIN / LOGOUT / ENABLE / DISABLE */
|
||||
private String action;
|
||||
|
||||
/** 资源类型:AGENT / CHANNEL / SKILL / WIKI / NODE / MEMBER / WORKSPACE */
|
||||
private String resourceType;
|
||||
|
||||
private String resourceId;
|
||||
|
||||
private String resourceName;
|
||||
|
||||
/** 变更详情 JSON */
|
||||
private String detailJson;
|
||||
|
||||
private String ipAddress;
|
||||
|
||||
private String userAgent;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.audit.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.audit.model.AuditEventEntity;
|
||||
|
||||
@Mapper
|
||||
public interface AuditEventMapper extends BaseMapper<AuditEventEntity> {
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
package vip.mate.audit.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import vip.mate.audit.model.AuditEventEntity;
|
||||
import vip.mate.audit.repository.AuditEventMapper;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 操作审计服务
|
||||
* <p>
|
||||
* 异步记录用户对资源的 CRUD 操作,不阻塞业务请求。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditEventService {
|
||||
|
||||
private final AuditEventMapper auditEventMapper;
|
||||
private final AuthService authService;
|
||||
|
||||
/**
|
||||
* 异步记录审计事件
|
||||
*/
|
||||
@Async
|
||||
public void record(String action, String resourceType, String resourceId,
|
||||
String resourceName, String detailJson) {
|
||||
try {
|
||||
AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson);
|
||||
if (event != null) {
|
||||
auditEventMapper.insert(event);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to record audit event: {}/{}/{}", action, resourceType, resourceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步记录(用于必须确保落库的场景,如登录/登出)
|
||||
*/
|
||||
public void recordSync(String action, String resourceType, String resourceId,
|
||||
String resourceName, String detailJson) {
|
||||
AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson);
|
||||
if (event != null) {
|
||||
auditEventMapper.insert(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询审计事件
|
||||
*/
|
||||
public IPage<AuditEventEntity> listEvents(Long workspaceId, String action, String resourceType,
|
||||
LocalDateTime startTime, LocalDateTime endTime,
|
||||
int page, int size) {
|
||||
LambdaQueryWrapper<AuditEventEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
if (workspaceId != null) {
|
||||
wrapper.eq(AuditEventEntity::getWorkspaceId, workspaceId);
|
||||
}
|
||||
if (action != null && !action.isBlank()) {
|
||||
wrapper.eq(AuditEventEntity::getAction, action);
|
||||
}
|
||||
if (resourceType != null && !resourceType.isBlank()) {
|
||||
wrapper.eq(AuditEventEntity::getResourceType, resourceType);
|
||||
}
|
||||
if (startTime != null) {
|
||||
wrapper.ge(AuditEventEntity::getCreateTime, startTime);
|
||||
}
|
||||
if (endTime != null) {
|
||||
wrapper.le(AuditEventEntity::getCreateTime, endTime);
|
||||
}
|
||||
wrapper.orderByDesc(AuditEventEntity::getCreateTime);
|
||||
return auditEventMapper.selectPage(new Page<>(page, size), wrapper);
|
||||
}
|
||||
|
||||
private AuditEventEntity buildEvent(String action, String resourceType, String resourceId,
|
||||
String resourceName, String detailJson) {
|
||||
AuditEventEntity event = new AuditEventEntity();
|
||||
event.setAction(action);
|
||||
event.setResourceType(resourceType);
|
||||
event.setResourceId(resourceId);
|
||||
event.setResourceName(resourceName);
|
||||
event.setDetailJson(detailJson);
|
||||
event.setCreateTime(LocalDateTime.now());
|
||||
|
||||
// 从 SecurityContext 获取用户信息
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
|
||||
String username = auth.getName();
|
||||
event.setUsername(username);
|
||||
UserEntity user = authService.findByUsername(username);
|
||||
if (user != null) {
|
||||
event.setUserId(user.getId());
|
||||
} else {
|
||||
event.setUserId(0L);
|
||||
}
|
||||
} else {
|
||||
event.setUsername("system");
|
||||
event.setUserId(0L);
|
||||
}
|
||||
|
||||
// 从 Request 获取 IP 和 User-Agent
|
||||
try {
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
HttpServletRequest request = attrs.getRequest();
|
||||
event.setIpAddress(getClientIp(request));
|
||||
event.setUserAgent(truncate(request.getHeader("User-Agent"), 256));
|
||||
|
||||
// 从 header 获取 workspace ID
|
||||
String wsHeader = request.getHeader("X-Workspace-Id");
|
||||
if (wsHeader != null && !wsHeader.isBlank()) {
|
||||
try {
|
||||
event.setWorkspaceId(Long.parseLong(wsHeader.trim()));
|
||||
} catch (NumberFormatException ignored) {
|
||||
event.setWorkspaceId(1L);
|
||||
}
|
||||
} else {
|
||||
event.setWorkspaceId(1L);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 异步上下文可能无法获取 request
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// X-Forwarded-For 可能包含多个 IP,取第一个
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return truncate(ip, 64);
|
||||
}
|
||||
|
||||
private String truncate(String s, int maxLen) {
|
||||
if (s == null) return null;
|
||||
return s.length() > maxLen ? s.substring(0, maxLen) : s;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,9 @@ import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.channel.ChannelManager;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.service.ChannelService;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -28,7 +30,9 @@ public class ChannelController {
|
||||
|
||||
private final ChannelService channelService;
|
||||
private final ChannelManager channelManager;
|
||||
private final AuditEventService auditEventService;
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取渠道列表")
|
||||
@GetMapping
|
||||
public R<List<ChannelEntity>> list(
|
||||
@ -39,18 +43,21 @@ public class ChannelController {
|
||||
return R.ok(channelService.listChannels());
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "按类型获取渠道列表")
|
||||
@GetMapping("/type/{channelType}")
|
||||
public R<List<ChannelEntity>> listByType(@PathVariable String channelType) {
|
||||
return R.ok(channelService.listChannelsByType(channelType));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取渠道详情")
|
||||
@GetMapping("/{id}")
|
||||
public R<ChannelEntity> get(@PathVariable Long id) {
|
||||
return R.ok(channelService.getChannel(id));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "创建渠道")
|
||||
@PostMapping
|
||||
public R<ChannelEntity> create(
|
||||
@ -59,9 +66,12 @@ public class ChannelController {
|
||||
if (workspaceId != null) {
|
||||
channel.setWorkspaceId(workspaceId);
|
||||
}
|
||||
return R.ok(channelService.createChannel(channel));
|
||||
ChannelEntity created = channelService.createChannel(channel);
|
||||
auditEventService.record("CREATE", "CHANNEL", String.valueOf(created.getId()), created.getName(), null);
|
||||
return R.ok(created);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "更新渠道")
|
||||
@PutMapping("/{id}")
|
||||
public R<ChannelEntity> update(@PathVariable Long id, @RequestBody ChannelEntity channel) {
|
||||
@ -69,18 +79,23 @@ public class ChannelController {
|
||||
ChannelEntity updated = channelService.updateChannel(channel);
|
||||
// 配置变更后热替换渠道(新 Adapter 就绪后才替换旧的,失败则保留旧的)
|
||||
channelManager.restartChannel(id);
|
||||
auditEventService.record("UPDATE", "CHANNEL", String.valueOf(id), updated.getName(), null);
|
||||
return R.ok(updated);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "删除渠道")
|
||||
@DeleteMapping("/{id}")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
// 先停止渠道再删除
|
||||
ChannelEntity channel = channelService.getChannel(id);
|
||||
channelManager.stopChannel(id);
|
||||
channelService.deleteChannel(id);
|
||||
auditEventService.record("DELETE", "CHANNEL", String.valueOf(id), channel != null ? channel.getName() : null, null);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "启用/禁用渠道")
|
||||
@PutMapping("/{id}/toggle")
|
||||
public R<ChannelEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
|
||||
@ -91,9 +106,11 @@ public class ChannelController {
|
||||
} else {
|
||||
channelManager.stopChannel(id);
|
||||
}
|
||||
auditEventService.record(enabled ? "ENABLE" : "DISABLE", "CHANNEL", String.valueOf(id), channel.getName(), null);
|
||||
return R.ok(channel);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取渠道运行状态")
|
||||
@GetMapping("/status")
|
||||
public R<Map<String, Object>> status() {
|
||||
|
||||
@ -1,19 +1,30 @@
|
||||
package vip.mate.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC 配置(跨域等)
|
||||
* Web MVC 配置(跨域、拦截器等)
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class})
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final WorkspaceAccessInterceptor workspaceAccessInterceptor;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(workspaceAccessInterceptor)
|
||||
.addPathPatterns("/api/**");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
package vip.mate.config;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
/**
|
||||
* Workspace 访问拦截器
|
||||
* <p>
|
||||
* 对标注了 {@link RequireWorkspaceRole} 的 Controller 方法,自动校验:
|
||||
* 1. 当前用户已认证
|
||||
* 2. 请求中有 X-Workspace-Id header(否则使用默认 workspace=1)
|
||||
* 3. 用户是该 workspace 的成员且角色 ≥ 注解要求的最低角色
|
||||
* <p>
|
||||
* 成员资格查询使用 Caffeine 缓存(60s TTL),避免每次请求查库。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class WorkspaceAccessInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final WorkspaceService workspaceService;
|
||||
private final AuthService authService;
|
||||
|
||||
/** 默认 workspace ID(未传 header 时使用) */
|
||||
private static final long DEFAULT_WORKSPACE_ID = 1L;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// 只拦截 Controller 方法
|
||||
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查方法是否标注了 @RequireWorkspaceRole
|
||||
RequireWorkspaceRole annotation = handlerMethod.getMethodAnnotation(RequireWorkspaceRole.class);
|
||||
if (annotation == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取当前认证用户
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
|
||||
// 未认证的请求由 Spring Security 处理,这里不拦截
|
||||
return true;
|
||||
}
|
||||
|
||||
String username = auth.getName();
|
||||
UserEntity user = authService.findByUsername(username);
|
||||
if (user == null) {
|
||||
sendForbidden(response, "User not found");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 系统管理员跳过 workspace 权限检查(全局 admin 角色)
|
||||
if ("admin".equalsIgnoreCase(user.getRole())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 解析 workspace ID
|
||||
long workspaceId = resolveWorkspaceId(request);
|
||||
|
||||
// 检查成员资格 + 角色
|
||||
String minRole = annotation.value();
|
||||
if (!workspaceService.hasPermissionCached(workspaceId, user.getId(), minRole)) {
|
||||
log.warn("Workspace access denied: user={}, workspaceId={}, requiredRole={}", username, workspaceId, minRole);
|
||||
sendForbidden(response, "Workspace permission denied: requires " + minRole + " role");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private long resolveWorkspaceId(HttpServletRequest request) {
|
||||
String header = request.getHeader("X-Workspace-Id");
|
||||
if (header != null && !header.isBlank()) {
|
||||
try {
|
||||
return Long.parseLong(header.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return DEFAULT_WORKSPACE_ID;
|
||||
}
|
||||
}
|
||||
return DEFAULT_WORKSPACE_ID;
|
||||
}
|
||||
|
||||
private void sendForbidden(HttpServletResponse response, String message) throws Exception {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":403,\"msg\":\"" + message + "\",\"data\":null}");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package vip.mate.dashboard.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.service.CronJobRunService;
|
||||
import vip.mate.dashboard.service.DashboardService;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Dashboard 统计接口
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Tag(name = "Dashboard")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/dashboard")
|
||||
@RequiredArgsConstructor
|
||||
public class DashboardController {
|
||||
|
||||
private final DashboardService dashboardService;
|
||||
private final CronJobRunService cronJobRunService;
|
||||
|
||||
@Operation(summary = "获取概览统计")
|
||||
@GetMapping("/overview")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<Map<String, Object>> overview(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
return R.ok(dashboardService.getOverview(workspaceId));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取日用量趋势")
|
||||
@GetMapping("/trend")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<Map<String, Object>>> trend(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestParam(defaultValue = "30") int days) {
|
||||
return R.ok(dashboardService.getTrend(workspaceId, Math.min(days, 90)));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取 CronJob 执行历史")
|
||||
@GetMapping("/cron-runs/{cronJobId}")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<CronJobRunEntity>> cronJobRuns(
|
||||
@PathVariable Long cronJobId,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
return R.ok(cronJobRunService.listByJobId(cronJobId, Math.min(limit, 100)));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取最近执行记录")
|
||||
@GetMapping("/cron-runs")
|
||||
@RequireWorkspaceRole("viewer")
|
||||
public R<List<CronJobRunEntity>> recentRuns(@RequestParam(defaultValue = "20") int limit) {
|
||||
return R.ok(cronJobRunService.listRecent(Math.min(limit, 100)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package vip.mate.dashboard.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("mate_cron_job_run")
|
||||
public class CronJobRunEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long cronJobId;
|
||||
private String conversationId;
|
||||
/** running / completed / failed */
|
||||
private String status;
|
||||
/** scheduled / manual */
|
||||
private String triggerType;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private String errorMessage;
|
||||
private Integer tokenUsage;
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package vip.mate.dashboard.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("mate_usage_daily")
|
||||
public class UsageDailyEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long workspaceId;
|
||||
private Long agentId;
|
||||
private LocalDate statDate;
|
||||
private Integer conversationCount;
|
||||
private Integer messageCount;
|
||||
private Long totalTokens;
|
||||
private Long promptTokens;
|
||||
private Long completionTokens;
|
||||
private Integer toolCallCount;
|
||||
private Integer errorCount;
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.dashboard.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
|
||||
@Mapper
|
||||
public interface CronJobRunMapper extends BaseMapper<CronJobRunEntity> {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package vip.mate.dashboard.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.dashboard.model.UsageDailyEntity;
|
||||
|
||||
@Mapper
|
||||
public interface UsageDailyMapper extends BaseMapper<UsageDailyEntity> {
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package vip.mate.dashboard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* CronJob 执行历史服务
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CronJobRunService {
|
||||
|
||||
private final CronJobRunMapper runMapper;
|
||||
|
||||
/**
|
||||
* 记录一次执行开始
|
||||
*/
|
||||
public CronJobRunEntity recordStart(Long cronJobId, String triggerType, String conversationId) {
|
||||
CronJobRunEntity run = new CronJobRunEntity();
|
||||
run.setCronJobId(cronJobId);
|
||||
run.setConversationId(conversationId);
|
||||
run.setStatus("running");
|
||||
run.setTriggerType(triggerType);
|
||||
run.setStartedAt(LocalDateTime.now());
|
||||
runMapper.insert(run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录执行完成
|
||||
*/
|
||||
public void recordComplete(Long runId, Integer tokenUsage) {
|
||||
CronJobRunEntity run = runMapper.selectById(runId);
|
||||
if (run != null) {
|
||||
run.setStatus("completed");
|
||||
run.setFinishedAt(LocalDateTime.now());
|
||||
run.setTokenUsage(tokenUsage);
|
||||
runMapper.updateById(run);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录执行失败
|
||||
*/
|
||||
public void recordFailed(Long runId, String errorMessage) {
|
||||
CronJobRunEntity run = runMapper.selectById(runId);
|
||||
if (run != null) {
|
||||
run.setStatus("failed");
|
||||
run.setFinishedAt(LocalDateTime.now());
|
||||
run.setErrorMessage(errorMessage);
|
||||
runMapper.updateById(run);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某个 CronJob 的执行历史
|
||||
*/
|
||||
public List<CronJobRunEntity> listByJobId(Long cronJobId, int limit) {
|
||||
return runMapper.selectList(
|
||||
new LambdaQueryWrapper<CronJobRunEntity>()
|
||||
.eq(CronJobRunEntity::getCronJobId, cronJobId)
|
||||
.orderByDesc(CronJobRunEntity::getStartedAt)
|
||||
.last("LIMIT " + limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询最近的执行记录
|
||||
*/
|
||||
public List<CronJobRunEntity> listRecent(int limit) {
|
||||
return runMapper.selectList(
|
||||
new LambdaQueryWrapper<CronJobRunEntity>()
|
||||
.orderByDesc(CronJobRunEntity::getStartedAt)
|
||||
.last("LIMIT " + limit));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
package vip.mate.dashboard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
import vip.mate.workspace.conversation.repository.MessageMapper;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Dashboard 统计服务
|
||||
* <p>
|
||||
* 直接实时查询 mate_message / mate_conversation 表,不依赖预聚合。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DashboardService {
|
||||
|
||||
private final MessageMapper messageMapper;
|
||||
private final ConversationMapper conversationMapper;
|
||||
|
||||
/**
|
||||
* 获取概览统计(今日/本周/本月)— 实时查询
|
||||
*/
|
||||
public Map<String, Object> getOverview(Long workspaceId) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate weekStart = today.minusDays(today.getDayOfWeek().getValue() - 1);
|
||||
LocalDate monthStart = today.withDayOfMonth(1);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("today", queryStats(workspaceId, today, today));
|
||||
result.put("thisWeek", queryStats(workspaceId, weekStart, today));
|
||||
result.put("thisMonth", queryStats(workspaceId, monthStart, today));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日趋势数据(最近 N 天,按天聚合)
|
||||
*/
|
||||
public List<Map<String, Object>> getTrend(Long workspaceId, int days) {
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
LocalDate today = LocalDate.now();
|
||||
for (int i = days - 1; i >= 0; i--) {
|
||||
LocalDate date = today.minusDays(i);
|
||||
Map<String, Object> dayStats = queryStats(workspaceId, date, date);
|
||||
dayStats.put("date", date.toString());
|
||||
trend.add(dayStats);
|
||||
}
|
||||
return trend;
|
||||
}
|
||||
|
||||
private Map<String, Object> queryStats(Long workspaceId, LocalDate startDate, LocalDate endDate) {
|
||||
LocalDateTime startTime = startDate.atStartOfDay();
|
||||
LocalDateTime endTime = endDate.atTime(LocalTime.MAX);
|
||||
|
||||
// 对话数
|
||||
LambdaQueryWrapper<ConversationEntity> convWrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||
.ge(ConversationEntity::getCreateTime, startTime)
|
||||
.le(ConversationEntity::getCreateTime, endTime);
|
||||
if (workspaceId != null) {
|
||||
convWrapper.eq(ConversationEntity::getWorkspaceId, workspaceId);
|
||||
}
|
||||
long conversations = conversationMapper.selectCount(convWrapper);
|
||||
|
||||
// 消息统计(只统计 assistant 消息的 token)
|
||||
LambdaQueryWrapper<MessageEntity> msgWrapper = new LambdaQueryWrapper<MessageEntity>()
|
||||
.ge(MessageEntity::getCreateTime, startTime)
|
||||
.le(MessageEntity::getCreateTime, endTime)
|
||||
.eq(MessageEntity::getDeleted, 0);
|
||||
|
||||
// 总消息数
|
||||
long messages = messageMapper.selectCount(msgWrapper);
|
||||
|
||||
// Token 统计(assistant 消息)
|
||||
LambdaQueryWrapper<MessageEntity> tokenWrapper = new LambdaQueryWrapper<MessageEntity>()
|
||||
.eq(MessageEntity::getRole, "assistant")
|
||||
.ge(MessageEntity::getCreateTime, startTime)
|
||||
.le(MessageEntity::getCreateTime, endTime)
|
||||
.eq(MessageEntity::getDeleted, 0)
|
||||
.select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens);
|
||||
|
||||
List<MessageEntity> assistantMessages = messageMapper.selectList(tokenWrapper);
|
||||
|
||||
long totalTokens = 0, promptTokens = 0, completionTokens = 0;
|
||||
for (MessageEntity m : assistantMessages) {
|
||||
int pt = m.getPromptTokens() != null ? m.getPromptTokens() : 0;
|
||||
int ct = m.getCompletionTokens() != null ? m.getCompletionTokens() : 0;
|
||||
promptTokens += pt;
|
||||
completionTokens += ct;
|
||||
totalTokens += pt + ct;
|
||||
}
|
||||
|
||||
// Tool 调用数(role = tool 的消息)
|
||||
LambdaQueryWrapper<MessageEntity> toolWrapper = new LambdaQueryWrapper<MessageEntity>()
|
||||
.eq(MessageEntity::getRole, "tool")
|
||||
.ge(MessageEntity::getCreateTime, startTime)
|
||||
.le(MessageEntity::getCreateTime, endTime)
|
||||
.eq(MessageEntity::getDeleted, 0);
|
||||
long toolCalls = messageMapper.selectCount(toolWrapper);
|
||||
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("conversations", conversations);
|
||||
stats.put("messages", messages);
|
||||
stats.put("totalTokens", totalTokens);
|
||||
stats.put("promptTokens", promptTokens);
|
||||
stats.put("completionTokens", completionTokens);
|
||||
stats.put("toolCalls", toolCalls);
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -114,10 +115,34 @@ public class SkillRuntimeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建技能 prompt 增强片段(分层注入)
|
||||
* 构建技能 prompt 增强片段(全局,向后兼容)
|
||||
*/
|
||||
public String buildSkillPromptEnhancement() {
|
||||
List<ResolvedSkill> activeSkills = getActiveSkills();
|
||||
return buildSkillPromptEnhancement(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建技能 prompt 增强片段(支持 per-agent 过滤)
|
||||
*
|
||||
* @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。
|
||||
* 非 null 时仅包含指定 ID 的 skill。
|
||||
*/
|
||||
public String buildSkillPromptEnhancement(Set<Long> boundSkillIds) {
|
||||
List<ResolvedSkill> activeSkills;
|
||||
if (boundSkillIds != null) {
|
||||
// Per-agent 过滤:从全局 enabled skills 中按 ID 过滤
|
||||
List<SkillEntity> enabledSkills = skillService.listEnabledSkills();
|
||||
activeSkills = enabledSkills.stream()
|
||||
.filter(s -> boundSkillIds.contains(s.getId()))
|
||||
.map(packageResolver::resolve)
|
||||
.filter(ResolvedSkill::isEnabled)
|
||||
.filter(ResolvedSkill::isRuntimeAvailable)
|
||||
.filter(s -> !s.isSecurityBlocked())
|
||||
.filter(ResolvedSkill::isDependencyReady)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
} else {
|
||||
activeSkills = getActiveSkills();
|
||||
}
|
||||
if (activeSkills.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
|
||||
import vip.mate.wiki.WikiProperties;
|
||||
import vip.mate.wiki.event.WikiProcessingEvent;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
@ -48,6 +49,7 @@ public class WikiController {
|
||||
|
||||
// ==================== Knowledge Base ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取所有知识库")
|
||||
@GetMapping("/knowledge-bases")
|
||||
public R<List<WikiKnowledgeBaseEntity>> listKBs(
|
||||
@ -58,6 +60,7 @@ public class WikiController {
|
||||
return R.ok(kbService.listAll());
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取知识库详情")
|
||||
@GetMapping("/knowledge-bases/{id}")
|
||||
public R<WikiKnowledgeBaseEntity> getKB(@PathVariable Long id) {
|
||||
@ -66,12 +69,14 @@ public class WikiController {
|
||||
return R.ok(kb);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "按 Agent 获取知识库")
|
||||
@GetMapping("/knowledge-bases/agent/{agentId}")
|
||||
public R<List<WikiKnowledgeBaseEntity>> listKBsByAgent(@PathVariable Long agentId) {
|
||||
return R.ok(kbService.listByAgentId(agentId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "创建知识库")
|
||||
@PostMapping("/knowledge-bases")
|
||||
public R<WikiKnowledgeBaseEntity> createKB(@RequestBody Map<String, Object> body) {
|
||||
@ -81,6 +86,7 @@ public class WikiController {
|
||||
return R.ok(kbService.create(name, description, agentId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "更新知识库")
|
||||
@PutMapping("/knowledge-bases/{id}")
|
||||
public R<WikiKnowledgeBaseEntity> updateKB(@PathVariable Long id, @RequestBody Map<String, Object> body) {
|
||||
@ -90,6 +96,7 @@ public class WikiController {
|
||||
return R.ok(kbService.update(id, name, description, agentId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "删除知识库")
|
||||
@DeleteMapping("/knowledge-bases/{id}")
|
||||
public R<Void> deleteKB(@PathVariable Long id) {
|
||||
@ -97,6 +104,7 @@ public class WikiController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取知识库配置")
|
||||
@GetMapping("/knowledge-bases/{id}/config")
|
||||
public R<Map<String, String>> getConfig(@PathVariable Long id) {
|
||||
@ -105,6 +113,7 @@ public class WikiController {
|
||||
return R.ok(Map.of("content", kb.getConfigContent() != null ? kb.getConfigContent() : ""));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "更新知识库配置")
|
||||
@PutMapping("/knowledge-bases/{id}/config")
|
||||
public R<Void> updateConfig(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||
@ -114,6 +123,7 @@ public class WikiController {
|
||||
|
||||
// ==================== Directory Scan ====================
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "设置知识库关联目录")
|
||||
@PutMapping("/knowledge-bases/{id}/source-directory")
|
||||
public R<Void> setSourceDirectory(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||
@ -122,6 +132,7 @@ public class WikiController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "扫描关联目录导入文件")
|
||||
@PostMapping("/knowledge-bases/{id}/scan")
|
||||
public R<Map<String, Object>> scanDirectory(@PathVariable Long id) {
|
||||
@ -136,12 +147,14 @@ public class WikiController {
|
||||
|
||||
// ==================== Raw Materials ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取原始材料列表")
|
||||
@GetMapping("/knowledge-bases/{kbId}/raw")
|
||||
public R<List<WikiRawMaterialEntity>> listRaw(@PathVariable Long kbId) {
|
||||
return R.ok(rawService.listByKbId(kbId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "添加文本材料")
|
||||
@PostMapping("/knowledge-bases/{kbId}/raw/text")
|
||||
public R<WikiRawMaterialEntity> addRawText(@PathVariable Long kbId, @RequestBody Map<String, String> body) {
|
||||
@ -150,6 +163,7 @@ public class WikiController {
|
||||
return R.ok(rawService.addText(kbId, title, content));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "上传文件材料")
|
||||
@PostMapping("/knowledge-bases/{kbId}/raw/upload")
|
||||
public R<WikiRawMaterialEntity> uploadRaw(@PathVariable Long kbId,
|
||||
@ -182,6 +196,7 @@ public class WikiController {
|
||||
}
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "删除原始材料")
|
||||
@DeleteMapping("/knowledge-bases/{kbId}/raw/{rawId}")
|
||||
public R<Void> deleteRaw(@PathVariable Long kbId, @PathVariable Long rawId) {
|
||||
@ -194,6 +209,7 @@ public class WikiController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "重新处理原始材料")
|
||||
@PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/reprocess")
|
||||
public R<Void> reprocessRaw(@PathVariable Long kbId, @PathVariable Long rawId) {
|
||||
@ -207,12 +223,14 @@ public class WikiController {
|
||||
|
||||
// ==================== Wiki Pages ====================
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取 Wiki 页面列表")
|
||||
@GetMapping("/knowledge-bases/{kbId}/pages")
|
||||
public R<List<WikiPageEntity>> listPages(@PathVariable Long kbId) {
|
||||
return R.ok(pageService.listByKbId(kbId));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取 Wiki 页面内容")
|
||||
@GetMapping("/knowledge-bases/{kbId}/pages/{slug}")
|
||||
public R<WikiPageEntity> getPage(@PathVariable Long kbId, @PathVariable String slug) {
|
||||
@ -221,6 +239,7 @@ public class WikiController {
|
||||
return R.ok(page);
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "手动编辑 Wiki 页面")
|
||||
@PutMapping("/knowledge-bases/{kbId}/pages/{slug}")
|
||||
public R<WikiPageEntity> updatePage(@PathVariable Long kbId, @PathVariable String slug,
|
||||
@ -228,6 +247,7 @@ public class WikiController {
|
||||
return R.ok(pageService.updatePageManually(kbId, slug, body.get("content"), body.get("summary")));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("admin")
|
||||
@Operation(summary = "删除 Wiki 页面")
|
||||
@DeleteMapping("/knowledge-bases/{kbId}/pages/{slug}")
|
||||
public R<Void> deletePage(@PathVariable Long kbId, @PathVariable String slug) {
|
||||
@ -236,6 +256,7 @@ public class WikiController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取反向链接")
|
||||
@GetMapping("/knowledge-bases/{kbId}/pages/{slug}/backlinks")
|
||||
public R<List<WikiPageEntity>> getBacklinks(@PathVariable Long kbId, @PathVariable String slug) {
|
||||
@ -244,6 +265,7 @@ public class WikiController {
|
||||
|
||||
// ==================== Processing ====================
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@Operation(summary = "触发知识库处理(异步)")
|
||||
@PostMapping("/knowledge-bases/{kbId}/process")
|
||||
public R<Map<String, Object>> processKB(@PathVariable Long kbId) {
|
||||
@ -254,6 +276,7 @@ public class WikiController {
|
||||
return R.ok(Map.of("queued", pending.size()));
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("viewer")
|
||||
@Operation(summary = "获取处理状态")
|
||||
@GetMapping("/knowledge-bases/{kbId}/processing-status")
|
||||
public R<Map<String, Object>> getProcessingStatus(@PathVariable Long kbId) {
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
package vip.mate.workspace.core.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 声明式 Workspace 权限检查注解。
|
||||
* <p>
|
||||
* 标注在 Controller 方法上,拦截器会自动校验当前用户在请求 Workspace 中的角色 ≥ value()。
|
||||
* 如果方法参数或请求 Header 中没有 workspace 信息,走默认 workspace(id=1)。
|
||||
* <p>
|
||||
* 角色等级: owner(4) > admin(3) > member(2) > viewer(1)
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface RequireWorkspaceRole {
|
||||
|
||||
/**
|
||||
* 最低角色要求,默认 viewer(即只要是成员就可以访问)
|
||||
*/
|
||||
String value() default "viewer";
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package vip.mate.workspace.core.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -11,6 +13,7 @@ import vip.mate.workspace.core.model.WorkspaceMemberEntity;
|
||||
import vip.mate.workspace.core.repository.WorkspaceMapper;
|
||||
import vip.mate.workspace.core.repository.WorkspaceMemberMapper;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -29,6 +32,12 @@ public class WorkspaceService {
|
||||
/** 默认工作区 slug */
|
||||
public static final String DEFAULT_SLUG = "default";
|
||||
|
||||
/** 成员资格缓存:key = "workspaceId:userId",value = role string(null 表示非成员) */
|
||||
private final Cache<String, String> membershipCache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(Duration.ofSeconds(60))
|
||||
.maximumSize(1000)
|
||||
.build();
|
||||
|
||||
// ==================== 工作区 CRUD ====================
|
||||
|
||||
public List<WorkspaceEntity> listAll() {
|
||||
@ -141,6 +150,7 @@ public class WorkspaceService {
|
||||
member.setUserId(userId);
|
||||
member.setRole(role != null ? role : "member");
|
||||
memberMapper.insert(member);
|
||||
evictMembershipCache(workspaceId, userId);
|
||||
log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole());
|
||||
return member;
|
||||
}
|
||||
@ -155,6 +165,7 @@ public class WorkspaceService {
|
||||
}
|
||||
member.setRole(role);
|
||||
memberMapper.updateById(member);
|
||||
evictMembershipCache(workspaceId, userId);
|
||||
return member;
|
||||
}
|
||||
|
||||
@ -167,6 +178,7 @@ public class WorkspaceService {
|
||||
throw new MateClawException("不能移除工作区拥有者");
|
||||
}
|
||||
memberMapper.deleteById(member.getId());
|
||||
evictMembershipCache(workspaceId, userId);
|
||||
log.info("Removed member from workspace: userId={}, workspaceId={}", userId, workspaceId);
|
||||
}
|
||||
|
||||
@ -197,6 +209,28 @@ public class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 带缓存的权限检查(拦截器高频调用,避免每次请求查库)
|
||||
*/
|
||||
public boolean hasPermissionCached(Long workspaceId, Long userId, String minRole) {
|
||||
String cacheKey = workspaceId + ":" + userId;
|
||||
String role = membershipCache.get(cacheKey, k -> {
|
||||
WorkspaceMemberEntity member = getMembership(workspaceId, userId);
|
||||
return member != null ? member.getRole() : "";
|
||||
});
|
||||
if (role == null || role.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return roleLevel(role) >= roleLevel(minRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 workspace + user 的成员资格缓存(成员变更时调用)
|
||||
*/
|
||||
public void evictMembershipCache(Long workspaceId, Long userId) {
|
||||
membershipCache.invalidate(workspaceId + ":" + userId);
|
||||
}
|
||||
|
||||
private int roleLevel(String role) {
|
||||
return switch (role) {
|
||||
case "owner" -> 4;
|
||||
|
||||
@ -564,3 +564,84 @@ DELIMITER ;
|
||||
|
||||
CALL mate_add_workspace_id();
|
||||
DROP PROCEDURE IF EXISTS mate_add_workspace_id;
|
||||
|
||||
-- =============================================
|
||||
-- Agent-Skill / Agent-Tool 绑定表(Phase 3 Sprint 2)
|
||||
-- =============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_skill (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
agent_id BIGINT NOT NULL,
|
||||
skill_id BIGINT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config_json TEXT,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uk_agent_skill (agent_id, skill_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_tool (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
agent_id BIGINT NOT NULL,
|
||||
tool_name VARCHAR(128) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uk_agent_tool (agent_id, tool_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- =============================================
|
||||
-- CronJob 执行历史(Phase 3 Sprint 3)
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS mate_cron_job_run (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
cron_job_id BIGINT NOT NULL,
|
||||
conversation_id VARCHAR(64),
|
||||
status VARCHAR(32) NOT NULL,
|
||||
trigger_type VARCHAR(32) NOT NULL DEFAULT 'scheduled',
|
||||
started_at DATETIME NOT NULL,
|
||||
finished_at DATETIME,
|
||||
error_message TEXT,
|
||||
token_usage INT DEFAULT 0,
|
||||
create_time DATETIME NOT NULL,
|
||||
INDEX idx_cron_run_job (cron_job_id, started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_usage_daily (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
workspace_id BIGINT NOT NULL,
|
||||
agent_id BIGINT,
|
||||
stat_date DATE NOT NULL,
|
||||
conversation_count INT DEFAULT 0,
|
||||
message_count INT DEFAULT 0,
|
||||
total_tokens BIGINT DEFAULT 0,
|
||||
prompt_tokens BIGINT DEFAULT 0,
|
||||
completion_tokens BIGINT DEFAULT 0,
|
||||
tool_call_count INT DEFAULT 0,
|
||||
error_count INT DEFAULT 0,
|
||||
create_time DATETIME NOT NULL,
|
||||
UNIQUE KEY uk_usage_daily (workspace_id, agent_id, stat_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- =============================================
|
||||
-- 操作审计事件表(Phase 3 Sprint 1)
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS mate_audit_event (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
workspace_id BIGINT,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
resource_type VARCHAR(64) NOT NULL,
|
||||
resource_id VARCHAR(128),
|
||||
resource_name VARCHAR(256),
|
||||
detail_json TEXT,
|
||||
ip_address VARCHAR(64),
|
||||
user_agent VARCHAR(256),
|
||||
create_time DATETIME NOT NULL,
|
||||
INDEX idx_audit_ws_time (workspace_id, create_time),
|
||||
INDEX idx_audit_user (user_id),
|
||||
INDEX idx_audit_resource (resource_type, resource_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@ -565,3 +565,87 @@ ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT N
|
||||
ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
|
||||
|
||||
-- =============================================
|
||||
-- Agent-Skill / Agent-Tool 绑定表(Phase 3 Sprint 2)
|
||||
-- =============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_skill (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
agent_id BIGINT NOT NULL,
|
||||
skill_id BIGINT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
config_json TEXT,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_skill ON mate_agent_skill(agent_id, skill_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mate_agent_tool (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
agent_id BIGINT NOT NULL,
|
||||
tool_name VARCHAR(128) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_tool ON mate_agent_tool(agent_id, tool_name);
|
||||
|
||||
-- =============================================
|
||||
-- CronJob 执行历史(Phase 3 Sprint 3)
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS mate_cron_job_run (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
cron_job_id BIGINT NOT NULL,
|
||||
conversation_id VARCHAR(64),
|
||||
status VARCHAR(32) NOT NULL,
|
||||
trigger_type VARCHAR(32) NOT NULL DEFAULT 'scheduled',
|
||||
started_at DATETIME NOT NULL,
|
||||
finished_at DATETIME,
|
||||
error_message TEXT,
|
||||
token_usage INT DEFAULT 0,
|
||||
create_time DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cron_run_job ON mate_cron_job_run(cron_job_id, started_at);
|
||||
|
||||
-- =============================================
|
||||
-- 用量日统计(Phase 3 Sprint 3)
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS mate_usage_daily (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
workspace_id BIGINT NOT NULL,
|
||||
agent_id BIGINT,
|
||||
stat_date DATE NOT NULL,
|
||||
conversation_count INT DEFAULT 0,
|
||||
message_count INT DEFAULT 0,
|
||||
total_tokens BIGINT DEFAULT 0,
|
||||
prompt_tokens BIGINT DEFAULT 0,
|
||||
completion_tokens BIGINT DEFAULT 0,
|
||||
tool_call_count INT DEFAULT 0,
|
||||
error_count INT DEFAULT 0,
|
||||
create_time DATETIME NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_usage_daily ON mate_usage_daily(workspace_id, agent_id, stat_date);
|
||||
|
||||
-- =============================================
|
||||
-- 操作审计事件表(Phase 3 Sprint 1)
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS mate_audit_event (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
workspace_id BIGINT,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
resource_type VARCHAR(64) NOT NULL,
|
||||
resource_id VARCHAR(128),
|
||||
resource_name VARCHAR(256),
|
||||
detail_json TEXT,
|
||||
ip_address VARCHAR(64),
|
||||
user_agent VARCHAR(256),
|
||||
create_time DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event(workspace_id, create_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON mate_audit_event(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_resource ON mate_audit_event(resource_type, resource_id);
|
||||
|
||||
@ -395,3 +395,34 @@ export const workspaceTeamApi = {
|
||||
removeMember: (id: string | number, memberId: string | number) =>
|
||||
http.delete(`/workspaces/${id}/members/${memberId}`),
|
||||
}
|
||||
|
||||
// ==================== Agent Binding ====================
|
||||
export const agentBindingApi = {
|
||||
listSkills: (agentId: string | number) => http.get(`/agents/${agentId}/skills`),
|
||||
setSkills: (agentId: string | number, skillIds: number[]) => http.put(`/agents/${agentId}/skills`, skillIds),
|
||||
bindSkill: (agentId: string | number, skillId: number) => http.post(`/agents/${agentId}/skills/${skillId}`),
|
||||
unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`),
|
||||
listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`),
|
||||
setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames),
|
||||
}
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
export const dashboardApi = {
|
||||
overview: () => http.get('/dashboard/overview'),
|
||||
trend: (days = 30) => http.get('/dashboard/trend', { params: { days } }),
|
||||
agentRanking: (days = 7, topN = 10) => http.get('/dashboard/agent-ranking', { params: { days, topN } }),
|
||||
cronJobRuns: (cronJobId: string | number, limit = 20) => http.get(`/dashboard/cron-runs/${cronJobId}`, { params: { limit } }),
|
||||
recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }),
|
||||
}
|
||||
|
||||
// ==================== Audit Events ====================
|
||||
export const auditApi = {
|
||||
listEvents: (params: {
|
||||
action?: string
|
||||
resourceType?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}) => http.get('/audit/events', { params }),
|
||||
}
|
||||
|
||||
@ -179,6 +179,7 @@ export default {
|
||||
timeHoursAgo: '{n}h ago',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
chat: 'Chat',
|
||||
control: 'Control',
|
||||
channels: 'Channels',
|
||||
@ -607,6 +608,23 @@ export default {
|
||||
fileGuard: 'File Guard',
|
||||
auditLogs: 'Audit Logs',
|
||||
members: 'Members',
|
||||
activity: 'Activity',
|
||||
},
|
||||
activity: {
|
||||
title: 'Activity Log',
|
||||
desc: 'View all create, update, and delete operations on workspace resources.',
|
||||
allActions: 'All Actions',
|
||||
allResources: 'All Resources',
|
||||
loading: 'Loading...',
|
||||
noEvents: 'No activity yet',
|
||||
columns: {
|
||||
time: 'Time',
|
||||
user: 'User',
|
||||
action: 'Action',
|
||||
resource: 'Resource',
|
||||
name: 'Name',
|
||||
ip: 'IP',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
title: 'Workspace Members',
|
||||
@ -1364,4 +1382,28 @@ export default {
|
||||
screenshot: 'screenshot',
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
desc: 'System usage overview and runtime status',
|
||||
conversations: 'Conversations',
|
||||
messages: 'Messages',
|
||||
tokens: 'Token Usage',
|
||||
toolCalls: 'Tool Calls',
|
||||
periodComparison: 'Period Comparison',
|
||||
periods: {
|
||||
today: 'Today',
|
||||
thisWeek: 'This Week',
|
||||
thisMonth: 'This Month',
|
||||
},
|
||||
recentRuns: 'Recent Cron Job Runs',
|
||||
noRuns: 'No runs yet',
|
||||
runColumns: {
|
||||
time: 'Time',
|
||||
job: 'Job',
|
||||
status: 'Status',
|
||||
trigger: 'Trigger',
|
||||
duration: 'Duration',
|
||||
tokens: 'Tokens',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -179,6 +179,7 @@ export default {
|
||||
timeHoursAgo: '{n} 小时前',
|
||||
},
|
||||
nav: {
|
||||
dashboard: '仪表盘',
|
||||
chat: '对话',
|
||||
control: '控制台',
|
||||
channels: '渠道',
|
||||
@ -607,6 +608,23 @@ export default {
|
||||
fileGuard: '文件防护',
|
||||
auditLogs: '审计日志',
|
||||
members: '成员管理',
|
||||
activity: '操作日志',
|
||||
},
|
||||
activity: {
|
||||
title: '操作日志',
|
||||
desc: '查看工作区内所有资源的创建、修改、删除操作记录。',
|
||||
allActions: '全部操作',
|
||||
allResources: '全部资源',
|
||||
loading: '加载中...',
|
||||
noEvents: '暂无操作记录',
|
||||
columns: {
|
||||
time: '时间',
|
||||
user: '用户',
|
||||
action: '操作',
|
||||
resource: '资源类型',
|
||||
name: '名称',
|
||||
ip: 'IP',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
title: '工作区成员',
|
||||
@ -1374,4 +1392,28 @@ export default {
|
||||
screenshot: '截图',
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
title: '仪表盘',
|
||||
desc: '系统用量概览与运行状态',
|
||||
conversations: '对话数',
|
||||
messages: '消息数',
|
||||
tokens: 'Token 消耗',
|
||||
toolCalls: '工具调用',
|
||||
periodComparison: '周期对比',
|
||||
periods: {
|
||||
today: '今日',
|
||||
thisWeek: '本周',
|
||||
thisMonth: '本月',
|
||||
},
|
||||
recentRuns: '最近定时任务执行',
|
||||
noRuns: '暂无执行记录',
|
||||
runColumns: {
|
||||
time: '时间',
|
||||
job: '任务',
|
||||
status: '状态',
|
||||
trigger: '触发方式',
|
||||
duration: '耗时',
|
||||
tokens: 'Token',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -15,6 +15,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/ChatConsole.vue'),
|
||||
meta: { title: 'Chat' },
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: 'Dashboard' },
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
name: 'Agents',
|
||||
@ -157,6 +163,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Security/AuditLogs/index.vue'),
|
||||
meta: { title: 'Security - Audit Logs' },
|
||||
},
|
||||
{
|
||||
path: 'activity',
|
||||
name: 'SecurityActivity',
|
||||
component: () => import('@/views/Security/Activity/index.vue'),
|
||||
meta: { title: 'Security - Activity' },
|
||||
},
|
||||
{
|
||||
path: 'members',
|
||||
name: 'SecurityMembers',
|
||||
|
||||
@ -112,7 +112,23 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-grid">
|
||||
<!-- Tab Bar -->
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab" :class="{ active: modalTab === 'basic' }" @click="modalTab = 'basic'">
|
||||
{{ t('agents.tabs.basic', 'Basic') }}
|
||||
</button>
|
||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'skills' }" @click="modalTab = 'skills'">
|
||||
{{ t('agents.tabs.skills', 'Skills') }}
|
||||
<span v-if="selectedSkillIds.length" class="tab-badge">{{ selectedSkillIds.length }}</span>
|
||||
</button>
|
||||
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'tools' }" @click="modalTab = 'tools'">
|
||||
{{ t('agents.tabs.tools', 'Tools') }}
|
||||
<span v-if="selectedToolNames.length" class="tab-badge">{{ selectedToolNames.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Basic Tab -->
|
||||
<div v-if="modalTab === 'basic'" class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('agents.fields.name') }} *</label>
|
||||
<input v-model="form.name" class="form-input" :placeholder="t('agents.placeholders.name')" />
|
||||
@ -152,6 +168,50 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Tab -->
|
||||
<div v-if="modalTab === 'skills'" class="binding-tab">
|
||||
<p class="binding-hint">{{ t('agents.binding.skillsHint', 'Select skills this agent can use. Leave empty to use all enabled skills.') }}</p>
|
||||
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills', 'No skills available') }}</div>
|
||||
<div v-else class="binding-list">
|
||||
<label
|
||||
v-for="skill in availableSkills"
|
||||
:key="skill.id"
|
||||
class="binding-item"
|
||||
:class="{ selected: selectedSkillIds.includes(skill.id) }"
|
||||
>
|
||||
<input type="checkbox" :value="skill.id" v-model="selectedSkillIds" class="binding-checkbox" />
|
||||
<span class="binding-icon">{{ skill.icon || '🧩' }}</span>
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.description" class="binding-desc">{{ skill.description?.slice(0, 80) }}</span>
|
||||
</div>
|
||||
<span v-if="skill.version" class="binding-version">v{{ skill.version }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tools Tab -->
|
||||
<div v-if="modalTab === 'tools'" class="binding-tab">
|
||||
<p class="binding-hint">{{ t('agents.binding.toolsHint', 'Select tools this agent can use. Leave empty to use all enabled tools.') }}</p>
|
||||
<div v-if="availableTools.length === 0" class="binding-empty">{{ t('agents.binding.noTools', 'No tools available') }}</div>
|
||||
<div v-else class="binding-list">
|
||||
<label
|
||||
v-for="tool in availableTools"
|
||||
:key="tool.name"
|
||||
class="binding-item"
|
||||
:class="{ selected: selectedToolNames.includes(tool.name) }"
|
||||
>
|
||||
<input type="checkbox" :value="tool.name" v-model="selectedToolNames" class="binding-checkbox" />
|
||||
<span class="binding-icon">{{ tool.icon || '🔧' }}</span>
|
||||
<div class="binding-info">
|
||||
<span class="binding-name">{{ tool.displayName || tool.name }}</span>
|
||||
<span v-if="tool.description" class="binding-desc">{{ tool.description?.slice(0, 80) }}</span>
|
||||
</div>
|
||||
<span class="binding-type-badge">{{ tool.toolType }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
|
||||
@ -168,7 +228,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { agentApi } from '@/api/index'
|
||||
import { agentApi, agentBindingApi, skillApi, toolApi } from '@/api/index'
|
||||
import type { Agent } from '@/types/index'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -177,6 +237,13 @@ const searchText = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const showModal = ref(false)
|
||||
const editingAgent = ref<Agent | null>(null)
|
||||
const modalTab = ref<'basic' | 'skills' | 'tools'>('basic')
|
||||
|
||||
// Binding state
|
||||
const availableSkills = ref<any[]>([])
|
||||
const availableTools = ref<any[]>([])
|
||||
const selectedSkillIds = ref<number[]>([])
|
||||
const selectedToolNames = ref<string[]>([])
|
||||
|
||||
const filterTabs = [
|
||||
{ key: 'agents.tabs.all', value: 'all' },
|
||||
@ -243,10 +310,13 @@ function formatTime(time?: string): string {
|
||||
function openCreateModal() {
|
||||
editingAgent.value = null
|
||||
form.value = defaultForm()
|
||||
modalTab.value = 'basic'
|
||||
selectedSkillIds.value = []
|
||||
selectedToolNames.value = []
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(agent: Agent) {
|
||||
async function openEditModal(agent: Agent) {
|
||||
editingAgent.value = agent
|
||||
form.value = {
|
||||
name: agent.name,
|
||||
@ -258,7 +328,28 @@ function openEditModal(agent: Agent) {
|
||||
tags: agent.tags || '',
|
||||
enabled: agent.enabled,
|
||||
}
|
||||
modalTab.value = 'basic'
|
||||
showModal.value = true
|
||||
|
||||
// Load available skills/tools and current bindings in parallel
|
||||
try {
|
||||
const [skillsRes, toolsRes, boundSkillsRes, boundToolsRes] = await Promise.all([
|
||||
skillApi.list(),
|
||||
toolApi.list(),
|
||||
agentBindingApi.listSkills(agent.id),
|
||||
agentBindingApi.listTools(agent.id),
|
||||
])
|
||||
availableSkills.value = (skillsRes as any).data || []
|
||||
availableTools.value = (toolsRes as any).data || []
|
||||
selectedSkillIds.value = ((boundSkillsRes as any).data || [])
|
||||
.filter((b: any) => b.enabled)
|
||||
.map((b: any) => b.skillId)
|
||||
selectedToolNames.value = ((boundToolsRes as any).data || [])
|
||||
.filter((b: any) => b.enabled)
|
||||
.map((b: any) => b.toolName)
|
||||
} catch {
|
||||
// Non-blocking: binding data load failure doesn't prevent editing basic info
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@ -268,11 +359,23 @@ function closeModal() {
|
||||
|
||||
async function saveAgent() {
|
||||
try {
|
||||
let agentId: string | number
|
||||
if (editingAgent.value) {
|
||||
await agentApi.update(editingAgent.value.id, form.value)
|
||||
agentId = editingAgent.value.id
|
||||
} else {
|
||||
await agentApi.create(form.value)
|
||||
const res: any = await agentApi.create(form.value)
|
||||
agentId = res.data?.id
|
||||
}
|
||||
|
||||
// Save bindings (only for existing agents or after create returns id)
|
||||
if (agentId && editingAgent.value) {
|
||||
await Promise.all([
|
||||
agentBindingApi.setSkills(agentId, selectedSkillIds.value),
|
||||
agentBindingApi.setTools(agentId, selectedToolNames.value),
|
||||
])
|
||||
}
|
||||
|
||||
ElMessage.success(t('agents.messages.saveSuccess'))
|
||||
closeModal()
|
||||
await loadAgents()
|
||||
@ -383,6 +486,47 @@ async function toggleAgent(agent: Agent) {
|
||||
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
|
||||
|
||||
/* Modal Tabs */
|
||||
.modal-tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--mc-border-light); padding-bottom: 0; }
|
||||
.modal-tab {
|
||||
padding: 8px 16px; border: none; background: none; cursor: pointer;
|
||||
font-size: 13px; font-weight: 500; color: var(--mc-text-tertiary);
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all 0.15s;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.modal-tab:hover { color: var(--mc-text-primary); }
|
||||
.modal-tab.active { color: var(--mc-primary); border-bottom-color: var(--mc-primary); }
|
||||
.tab-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 18px; height: 18px; padding: 0 5px;
|
||||
border-radius: 9px; background: var(--mc-primary); color: white;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
|
||||
/* Binding Tab */
|
||||
.binding-tab { min-height: 200px; }
|
||||
.binding-hint { font-size: 13px; color: var(--mc-text-tertiary); margin: 0 0 16px; }
|
||||
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
|
||||
.binding-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.binding-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||
border: 1px solid var(--mc-border-light); border-radius: 8px;
|
||||
cursor: pointer; transition: all 0.15s; background: var(--mc-bg);
|
||||
}
|
||||
.binding-item:hover { border-color: var(--mc-primary-light, rgba(217,119,87,0.3)); background: var(--mc-bg-elevated); }
|
||||
.binding-item.selected { border-color: var(--mc-primary); background: rgba(217,119,87,0.04); }
|
||||
.binding-checkbox { flex-shrink: 0; accent-color: var(--mc-primary); width: 16px; height: 16px; }
|
||||
.binding-icon { font-size: 20px; flex-shrink: 0; }
|
||||
.binding-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.binding-name { font-size: 14px; font-weight: 500; color: var(--mc-text-primary); }
|
||||
.binding-desc { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.binding-version { font-size: 11px; color: var(--mc-text-tertiary); flex-shrink: 0; }
|
||||
.binding-type-badge {
|
||||
font-size: 10px; padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
|
||||
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
|
||||
215
mateclaw-ui/src/views/Dashboard.vue
Normal file
215
mateclaw-ui/src/views/Dashboard.vue
Normal file
@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ t('dashboard.title') }}</h1>
|
||||
<p class="page-desc">{{ t('dashboard.desc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💬</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.conversations }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.conversations') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📝</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.messages }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.messages') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🎯</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ formatTokens(todayStats.totalTokens) }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.tokens') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🔧</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value">{{ todayStats.toolCalls }}</div>
|
||||
<div class="stat-label">{{ t('dashboard.toolCalls') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Comparison -->
|
||||
<div class="comparison-section">
|
||||
<h2 class="section-title">{{ t('dashboard.periodComparison') }}</h2>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-card" v-for="(period, key) in overview" :key="key">
|
||||
<h3 class="comparison-title">{{ t('dashboard.periods.' + key) }}</h3>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.conversations') }}</span>
|
||||
<span class="comparison-value">{{ period.conversations }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.messages') }}</span>
|
||||
<span class="comparison-value">{{ period.messages }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.tokens') }}</span>
|
||||
<span class="comparison-value">{{ formatTokens(period.totalTokens) }}</span>
|
||||
</div>
|
||||
<div class="comparison-row">
|
||||
<span class="comparison-label">{{ t('dashboard.toolCalls') }}</span>
|
||||
<span class="comparison-value">{{ period.toolCalls }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent CronJob Runs -->
|
||||
<div class="runs-section">
|
||||
<h2 class="section-title">{{ t('dashboard.recentRuns') }}</h2>
|
||||
<div class="runs-table-wrapper">
|
||||
<table v-if="recentRuns.length" class="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('dashboard.runColumns.time') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.job') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.status') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.trigger') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.duration') }}</th>
|
||||
<th>{{ t('dashboard.runColumns.tokens') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in recentRuns" :key="run.id">
|
||||
<td class="cell-time">{{ formatTime(run.startedAt) }}</td>
|
||||
<td class="cell-job">#{{ run.cronJobId }}</td>
|
||||
<td>
|
||||
<span class="status-badge" :class="'status-' + run.status">{{ run.status }}</span>
|
||||
</td>
|
||||
<td class="cell-trigger">{{ run.triggerType }}</td>
|
||||
<td class="cell-duration">{{ calcDuration(run) }}</td>
|
||||
<td class="cell-tokens">{{ run.tokenUsage || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="empty-state">{{ t('dashboard.noRuns') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { dashboardApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const overview = ref<Record<string, any>>({})
|
||||
const recentRuns = ref<any[]>([])
|
||||
|
||||
const todayStats = reactive({
|
||||
conversations: 0,
|
||||
messages: 0,
|
||||
totalTokens: 0,
|
||||
toolCalls: 0,
|
||||
errors: 0,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [overviewRes, runsRes] = await Promise.all([
|
||||
dashboardApi.overview(),
|
||||
dashboardApi.recentRuns(10),
|
||||
])
|
||||
overview.value = (overviewRes as any).data || {}
|
||||
const today = overview.value.today || {}
|
||||
Object.assign(todayStats, today)
|
||||
recentRuns.value = (runsRes as any).data || []
|
||||
} catch {
|
||||
// Dashboard data is non-critical
|
||||
}
|
||||
})
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (!n) return '0'
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function calcDuration(run: any): string {
|
||||
if (!run.startedAt || !run.finishedAt) return '-'
|
||||
const ms = new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()
|
||||
if (ms < 1000) return ms + 'ms'
|
||||
return (ms / 1000).toFixed(1) + 's'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
|
||||
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-title { font-size: 24px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.page-desc { font-size: 14px; color: var(--mc-text-tertiary); margin: 0; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px; }
|
||||
.stat-card {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light);
|
||||
border-radius: 12px; padding: 20px;
|
||||
}
|
||||
.stat-icon { font-size: 28px; }
|
||||
.stat-body { display: flex; flex-direction: column; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: var(--mc-text-primary); line-height: 1.2; }
|
||||
.stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 2px; }
|
||||
|
||||
/* Period Comparison */
|
||||
.section-title { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 16px; }
|
||||
.comparison-section { margin-bottom: 32px; }
|
||||
.comparison-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.comparison-card {
|
||||
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light);
|
||||
border-radius: 10px; padding: 16px;
|
||||
}
|
||||
.comparison-title { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.comparison-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.comparison-row:last-child { border-bottom: none; }
|
||||
.comparison-label { font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
.comparison-value { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
|
||||
|
||||
/* Runs Section */
|
||||
.runs-section { margin-bottom: 32px; }
|
||||
.runs-table-wrapper { border: 1px solid var(--mc-border-light); border-radius: 10px; overflow: hidden; }
|
||||
.runs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.runs-table th {
|
||||
padding: 10px 14px; text-align: left; font-weight: 600; font-size: 12px;
|
||||
color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;
|
||||
background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
.runs-table td { padding: 10px 14px; border-bottom: 1px solid var(--mc-border-light); color: var(--mc-text-primary); }
|
||||
.runs-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.cell-time { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; }
|
||||
.cell-job { font-family: 'SF Mono', monospace; font-size: 12px; color: var(--mc-text-secondary); }
|
||||
.cell-trigger { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
.cell-duration { font-family: 'SF Mono', monospace; font-size: 12px; }
|
||||
.cell-tokens { font-family: 'SF Mono', monospace; font-size: 12px; }
|
||||
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.status-running { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.status-completed { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.empty-state { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.comparison-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
194
mateclaw-ui/src/views/Security/Activity/index.vue
Normal file
194
mateclaw-ui/src/views/Security/Activity/index.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 class="section-title">{{ t('security.activity.title') }}</h2>
|
||||
<p class="section-desc">{{ t('security.activity.desc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-row">
|
||||
<select v-model="filters.action" class="filter-select" @change="loadEvents">
|
||||
<option value="">{{ t('security.activity.allActions') }}</option>
|
||||
<option value="CREATE">CREATE</option>
|
||||
<option value="UPDATE">UPDATE</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
<option value="ENABLE">ENABLE</option>
|
||||
<option value="DISABLE">DISABLE</option>
|
||||
</select>
|
||||
<select v-model="filters.resourceType" class="filter-select" @change="loadEvents">
|
||||
<option value="">{{ t('security.activity.allResources') }}</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
<option value="CHANNEL">Channel</option>
|
||||
<option value="SKILL">Skill</option>
|
||||
<option value="WIKI">Wiki</option>
|
||||
<option value="MEMBER">Member</option>
|
||||
<option value="WORKSPACE">Workspace</option>
|
||||
</select>
|
||||
<button class="btn-secondary" @click="loadEvents">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Event Timeline -->
|
||||
<div class="rules-table-wrapper">
|
||||
<table class="rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('security.activity.columns.time') }}</th>
|
||||
<th>{{ t('security.activity.columns.user') }}</th>
|
||||
<th>{{ t('security.activity.columns.action') }}</th>
|
||||
<th>{{ t('security.activity.columns.resource') }}</th>
|
||||
<th>{{ t('security.activity.columns.name') }}</th>
|
||||
<th>{{ t('security.activity.columns.ip') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="event in events" :key="event.id">
|
||||
<td class="cell-time">{{ formatTime(event.createTime) }}</td>
|
||||
<td>
|
||||
<span class="user-tag">{{ event.username }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="action-tag" :class="'action-' + event.action?.toLowerCase()">
|
||||
{{ event.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="resource-tag">{{ event.resourceType }}</span>
|
||||
</td>
|
||||
<td class="cell-name">{{ event.resourceName || event.resourceId || '-' }}</td>
|
||||
<td class="cell-ip">{{ event.ipAddress || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading" class="empty-state">{{ t('security.activity.loading') }}</div>
|
||||
<div v-else-if="!events.length" class="empty-state">{{ t('security.activity.noEvents') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="pagination">
|
||||
<button class="btn-secondary btn-sm" :disabled="page <= 1" @click="page--; loadEvents()">«</button>
|
||||
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||
<button class="btn-secondary btn-sm" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadEvents()">»</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { auditApi } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const filters = reactive({ action: '', resourceType: '' })
|
||||
|
||||
async function loadEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await auditApi.listEvents({
|
||||
action: filters.action || undefined,
|
||||
resourceType: filters.resourceType || undefined,
|
||||
page: page.value,
|
||||
size: pageSize,
|
||||
})
|
||||
events.value = res.data?.records || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch {
|
||||
events.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import '../shared.css';
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 6px;
|
||||
background: var(--mc-bg);
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cell-time { font-size: 12px; color: var(--mc-text-tertiary); white-space: nowrap; }
|
||||
.cell-name { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cell-ip { font-size: 12px; color: var(--mc-text-tertiary); font-family: 'SF Mono', monospace; }
|
||||
|
||||
.user-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
.action-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-create { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.action-update { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.action-delete { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.action-enable { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.action-disable { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
|
||||
.action-login { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||
.action-logout { background: rgba(107, 114, 128, 0.12); color: #6b7280; }
|
||||
|
||||
.resource-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-secondary);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.page-info { font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
</style>
|
||||
@ -47,6 +47,12 @@ const sections = computed(() => [
|
||||
label: t('security.sections.auditLogs'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
path: '/security/activity',
|
||||
label: t('security.sections.activity', 'Activity'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'members',
|
||||
path: '/security/members',
|
||||
|
||||
@ -221,6 +221,11 @@ const navGroups = computed(() => [
|
||||
key: 'core',
|
||||
label: t('nav.core'),
|
||||
items: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: t('nav.dashboard', 'Dashboard'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
label: t('nav.chat'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user