feat(platform): workspace isolation, info architecture, and UI redesign

This commit is contained in:
matevip 2026-04-09 16:29:02 +08:00
parent d44ba0cdd8
commit 5124842526
40 changed files with 2371 additions and 597 deletions

View File

@ -8,8 +8,10 @@ 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.agent.model.AgentEntity;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
@ -35,14 +37,18 @@ public class AgentBindingController {
@Operation(summary = "获取 Agent 已绑定的 Skills")
@GetMapping("/skills")
@RequireWorkspaceRole("viewer")
public R<List<AgentSkillBinding>> listSkills(@PathVariable Long agentId) {
public R<List<AgentSkillBinding>> listSkills(@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
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) {
public R<Void> setSkills(@PathVariable Long agentId, @RequestBody List<Long> skillIds,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setSkillBindings(agentId, skillIds);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
@ -53,7 +59,9 @@ public class AgentBindingController {
@Operation(summary = "绑定单个 Skill")
@PostMapping("/skills/{skillId}")
@RequireWorkspaceRole("member")
public R<AgentSkillBinding> bindSkill(@PathVariable Long agentId, @PathVariable Long skillId) {
public R<AgentSkillBinding> bindSkill(@PathVariable Long agentId, @PathVariable Long skillId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
AgentSkillBinding binding = bindingService.bindSkill(agentId, skillId);
agentService.invalidateAgentCache(agentId);
return R.ok(binding);
@ -62,7 +70,9 @@ public class AgentBindingController {
@Operation(summary = "解绑单个 Skill")
@DeleteMapping("/skills/{skillId}")
@RequireWorkspaceRole("member")
public R<Void> unbindSkill(@PathVariable Long agentId, @PathVariable Long skillId) {
public R<Void> unbindSkill(@PathVariable Long agentId, @PathVariable Long skillId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.unbindSkill(agentId, skillId);
agentService.invalidateAgentCache(agentId);
return R.ok();
@ -73,18 +83,35 @@ public class AgentBindingController {
@Operation(summary = "获取 Agent 已绑定的 Tools")
@GetMapping("/tools")
@RequireWorkspaceRole("viewer")
public R<List<AgentToolBinding>> listTools(@PathVariable Long agentId) {
public R<List<AgentToolBinding>> listTools(@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
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) {
public R<Void> setTools(@PathVariable Long agentId, @RequestBody List<String> toolNames,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setToolBindings(agentId, toolNames);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
"tools=" + toolNames.size(), null);
return R.ok();
}
// ==================== Workspace Verification ====================
private void verifyAgentWorkspace(Long agentId, Long headerWorkspaceId) {
AgentEntity agent = agentService.getAgent(agentId);
if (agent == null) {
throw new MateClawException("Agent not found");
}
long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (agent.getWorkspaceId() != null && !agent.getWorkspaceId().equals(requestedWs)) {
throw new MateClawException("资源不属于当前工作区");
}
}
}

View File

@ -96,10 +96,14 @@ public class AgentController {
@Operation(summary = "流式对话SSE")
@GetMapping("/{id}/chat/stream")
@RequireWorkspaceRole("viewer")
public SseEmitter chatStream(
@PathVariable Long id,
@RequestParam String message,
@RequestParam(defaultValue = "default") String conversationId) {
@RequestParam(defaultValue = "default") String conversationId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
sseExecutor.execute(() -> {
@ -131,23 +135,35 @@ public class AgentController {
@Operation(summary = "同步对话")
@PostMapping("/{id}/chat")
@RequireWorkspaceRole("viewer")
public R<String> chat(
@PathVariable Long id,
@RequestBody ChatRequest request) {
@RequestBody ChatRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
return R.ok(agentService.chat(id, request.getMessage(), request.getConversationId()));
}
@Operation(summary = "执行复杂任务Plan-Execute")
@PostMapping("/{id}/execute")
@RequireWorkspaceRole("viewer")
public R<String> execute(
@PathVariable Long id,
@RequestBody ChatRequest request) {
@RequestBody ChatRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
return R.ok(agentService.execute(id, request.getMessage(), request.getConversationId()));
}
@Operation(summary = "获取Agent运行状态")
@GetMapping("/{id}/state")
public R<AgentState> getState(@PathVariable Long id) {
@RequireWorkspaceRole("viewer")
public R<AgentState> getState(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
AgentEntity agent = agentService.getAgent(id);
verifyResourceWorkspace(agent != null ? agent.getWorkspaceId() : null, workspaceId);
return R.ok(agentService.getAgentState(id));
}

View File

@ -42,9 +42,23 @@ public class AuditEventService {
*/
public void record(String action, String resourceType, String resourceId,
String resourceName, String detailJson) {
record(action, resourceType, resourceId, resourceName, detailJson, null);
}
/**
* 异步记录审计事件显式指定 workspace ID
* <p>
* 当调用方已知 workspace ID 优先使用此方法以避免依赖 request header 解析
*/
public void record(String action, String resourceType, String resourceId,
String resourceName, String detailJson, Long workspaceId) {
// 在请求线程中构建事件可以访问 SecurityContext RequestContext
AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson);
if (event != null) {
// 显式传入的 workspaceId 优先于 header 解析结果
if (workspaceId != null) {
event.setWorkspaceId(workspaceId);
}
insertAsync(event);
}
}

View File

@ -378,8 +378,8 @@ public class ChannelMessageRouter {
}
// ======= 审批拦截层结束 =======
// 确保会话存在
conversationService.getOrCreateSharedConversation(conversationId, agentId);
// 确保会话存在workspace 感知
conversationService.getOrCreateSharedConversation(conversationId, agentId, channelEntity.getWorkspaceId());
// 更新渠道会话存储用于主动推送
String replyTarget = resolveReplyTarget(message);
@ -566,7 +566,7 @@ public class ChannelMessageRouter {
String conversationId = buildConversationId(message);
String username = message.getSenderName() != null ? message.getSenderName() : message.getSenderId();
conversationService.getOrCreateConversation(conversationId, agentId, username);
conversationService.getOrCreateConversation(conversationId, agentId, username, channelEntity.getWorkspaceId());
List<MessageContentPart> parts = message.getContentParts();
conversationService.saveMessage(conversationId, "user", message.getContent(), parts);

View File

@ -45,8 +45,10 @@ public class ChannelController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "按类型获取渠道列表")
@GetMapping("/type/{channelType}")
public R<List<ChannelEntity>> listByType(@PathVariable String channelType) {
return R.ok(channelService.listChannelsByType(channelType));
public R<List<ChannelEntity>> listByType(@PathVariable String channelType,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(channelService.listChannelsByTypeAndWorkspace(channelType, wsId));
}
@RequireWorkspaceRole("viewer")
@ -117,8 +119,8 @@ public class ChannelController {
return R.ok(channel);
}
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取渠道运行状态")
@RequireWorkspaceRole("admin")
@Operation(summary = "获取渠道运行状态(全局系统视图,仅管理员可见)")
@GetMapping("/status")
public R<Map<String, Object>> status() {
return R.ok(channelManager.getStatus());

View File

@ -54,7 +54,7 @@ public class ChannelService {
}
/**
* 按类型获取渠道列表
* 按类型获取渠道列表全局向后兼容
*/
public List<ChannelEntity> listChannelsByType(String channelType) {
return channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
@ -62,6 +62,16 @@ public class ChannelService {
.orderByDesc(ChannelEntity::getCreateTime));
}
/**
* 按类型和 workspace 获取渠道列表
*/
public List<ChannelEntity> listChannelsByTypeAndWorkspace(String channelType, Long workspaceId) {
return channelMapper.selectList(new LambdaQueryWrapper<ChannelEntity>()
.eq(ChannelEntity::getChannelType, channelType)
.eq(ChannelEntity::getWorkspaceId, workspaceId)
.orderByDesc(ChannelEntity::getCreateTime));
}
/**
* 获取渠道详情
*/

View File

@ -17,6 +17,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.common.result.R;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.PendingApproval;
import vip.mate.memory.event.ConversationCompletedEvent;
@ -73,6 +74,7 @@ public class ChatController {
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(
@RequestBody ChatStreamRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
String conversationId = request.getConversationId() != null ? request.getConversationId() : "default";
@ -125,6 +127,26 @@ public class ChatController {
String username = auth.getName();
log.info("SSE chat: agentId={}, conversationId={}, user={}", agentId, conversationId, username);
// ---- Workspace 边界校验确保 agent 属于当前 workspace ----
if (agentId != null) {
AgentEntity agent = agentService.getAgent(agentId);
if (agent != null && agent.getWorkspaceId() != null) {
long wsId = workspaceId != null ? workspaceId : 1L;
if (!agent.getWorkspaceId().equals(wsId)) {
log.warn("Chat workspace mismatch: agent {} belongs to workspace {}, request workspace {}",
agentId, agent.getWorkspaceId(), wsId);
try {
sendEvent(emitter, "error", Map.of("message", "Agent 不属于当前工作区"));
sendEvent(emitter, "done", Map.of("status", "completed"));
} catch (IOException e) {
log.warn("SSE workspace error send failed: {}", e.getMessage());
}
emitter.complete();
return emitter;
}
}
}
// ---- 审批命令拦截/approve/deny SSE 流式 replay ----
String normalizedMsg = message.trim().toLowerCase();
boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg);
@ -382,7 +404,7 @@ public class ChatController {
StreamAccumulator accumulator = new StreamAccumulator();
AtomicBoolean finalized = new AtomicBoolean(false);
try {
conversationService.getOrCreateConversation(conversationId, agentId, username);
conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId);
List<MessageContentPart> requestParts = normalizeRequestParts(request);
String promptText = buildPromptText(message, requestParts);
conversationService.saveMessage(conversationId, "user", message, requestParts);
@ -785,13 +807,14 @@ public class ChatController {
public R<String> chat(
@RequestParam Long agentId,
@RequestBody ChatRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
String username = auth != null ? auth.getName() : null;
if (username == null) {
return R.fail("未登录,请先登录");
}
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username);
conversationService.getOrCreateConversation(request.getConversationId(), agentId, username, workspaceId);
conversationService.saveMessage(request.getConversationId(), "user", request.getMessage(), request.getContentParts());
String promptText = buildPromptText(request.getMessage(), request.getContentParts());

View File

@ -130,9 +130,11 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
// 3. 推送转写结果
sendJson(session, Map.of("type", "transcript", "text", transcript));
// 4. 保存用户消息
// 4. 保存用户消息workspace agent 获取
var talkAgent = agentService.getAgent(talkSession.agentId);
Long talkWsId = talkAgent != null ? talkAgent.getWorkspaceId() : 1L;
conversationService.getOrCreateConversation(
talkSession.conversationId, talkSession.agentId, talkSession.username);
talkSession.conversationId, talkSession.agentId, talkSession.username, talkWsId);
conversationService.saveMessage(talkSession.conversationId, "user", transcript, List.of());
// 5. Agent 对话同步

View File

@ -95,8 +95,10 @@ public class WebChatController {
sseExecutor.execute(() -> {
try {
// 创建或获取会话
var conv = conversationService.getOrCreateConversation(conversationId, agentId, "webchat:" + visitorId);
// 创建或获取会话workspace agent 获取
var webAgent = agentService.getAgent(agentId);
Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L;
var conv = conversationService.getOrCreateConversation(conversationId, agentId, "webchat:" + visitorId, webWsId);
// 保存用户消息
conversationService.saveMessage(conversationId, "user", message, List.of());

View File

@ -0,0 +1,33 @@
package vip.mate.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor;
import java.util.concurrent.Executor;
/**
* 全局异步线程池配置确保 SecurityContext 传播到 @Async 线程
* <p>
* 使用 {@link DelegatingSecurityContextAsyncTaskExecutor} 包装线程池
* 使得审计记忆摘要等异步任务能正确获取调用线程的用户身份和权限上下文
*
* @author MateClaw Team
*/
@Configuration
@EnableAsync
public class AsyncSecurityConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-sec-");
executor.initialize();
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
}

View File

@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.cron.model.CronJobDTO;
import vip.mate.cron.service.CronJobService;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
@ -25,30 +26,35 @@ public class CronJobController {
@Operation(summary = "获取定时任务列表")
@GetMapping
@RequireWorkspaceRole("viewer")
public R<List<CronJobDTO>> list() {
return R.ok(cronJobService.list());
}
@Operation(summary = "获取定时任务详情")
@GetMapping("/{id}")
@RequireWorkspaceRole("viewer")
public R<CronJobDTO> get(@PathVariable Long id) {
return R.ok(cronJobService.getById(id));
}
@Operation(summary = "创建定时任务")
@PostMapping
@RequireWorkspaceRole("member")
public R<CronJobDTO> create(@RequestBody CronJobDTO dto) {
return R.ok(cronJobService.create(dto));
}
@Operation(summary = "更新定时任务")
@PutMapping("/{id}")
@RequireWorkspaceRole("member")
public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto) {
return R.ok(cronJobService.update(id, dto));
}
@Operation(summary = "删除定时任务")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id) {
cronJobService.delete(id);
return R.ok();
@ -56,6 +62,7 @@ public class CronJobController {
@Operation(summary = "启用/禁用定时任务")
@PutMapping("/{id}/toggle")
@RequireWorkspaceRole("member")
public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
cronJobService.toggle(id, enabled);
return R.ok();
@ -63,6 +70,7 @@ public class CronJobController {
@Operation(summary = "立即执行定时任务")
@PostMapping("/{id}/run")
@RequireWorkspaceRole("member")
public R<Void> runNow(@PathVariable Long id) {
cronJobService.runNow(id);
return R.ok();

View File

@ -256,8 +256,10 @@ public class CronJobService implements ApplicationRunner {
try {
log.info("[CronJob] Executing job {} ({}), type={}", job.getId(), job.getName(), job.getTaskType());
// 确保会话存在使用 SYSTEM_USER 作为定时触发的所有者标识
conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER);
// 确保会话存在使用 SYSTEM_USER 作为定时触发的所有者标识workspace agent 获取
AgentEntity cronAgent = agentMapper.selectById(job.getAgentId());
Long cronWorkspaceId = cronAgent != null ? cronAgent.getWorkspaceId() : 1L;
conversationService.getOrCreateConversation(conversationId, job.getAgentId(), SYSTEM_USER, cronWorkspaceId);
String userMessage;
String result;

View File

@ -4,10 +4,15 @@ 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.model.AgentEntity;
import vip.mate.common.result.R;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.repository.CronJobMapper;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.service.CronJobRunService;
import vip.mate.dashboard.service.DashboardService;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import java.util.List;
@ -26,6 +31,8 @@ public class DashboardController {
private final DashboardService dashboardService;
private final CronJobRunService cronJobRunService;
private final CronJobMapper cronJobMapper;
private final AgentService agentService;
@Operation(summary = "获取概览统计")
@GetMapping("/overview")
@ -49,8 +56,19 @@ public class DashboardController {
@RequireWorkspaceRole("viewer")
public R<List<CronJobRunEntity>> cronJobRuns(
@PathVariable Long cronJobId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
@RequestParam(defaultValue = "20") int limit) {
// TODO: 校验 cronJobId 对应的 agent 属于当前 workspace
// 校验 cronJobId 对应的 agent 属于当前 workspace
CronJobEntity job = cronJobMapper.selectById(cronJobId);
if (job != null && job.getAgentId() != null) {
AgentEntity agent = agentService.getAgent(job.getAgentId());
if (agent != null && agent.getWorkspaceId() != null) {
long wsId = workspaceId != null ? workspaceId : 1L;
if (!agent.getWorkspaceId().equals(wsId)) {
throw new MateClawException("资源不属于当前工作区");
}
}
}
return R.ok(cronJobRunService.listByJobId(cronJobId, Math.min(limit, 100)));
}

View File

@ -72,13 +72,37 @@ public class DashboardService {
}
long conversations = conversationMapper.selectCount(convWrapper);
// 消息统计只统计 assistant 消息的 token
// Workspace 级消息过滤通过 conversation 关联 workspace
// MessageEntity 没有 workspaceId 字段需通过所属 conversation 间接过滤
List<String> wsConversationIds = null;
if (workspaceId != null) {
List<ConversationEntity> wsConvs = conversationMapper.selectList(
new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getWorkspaceId, workspaceId)
.select(ConversationEntity::getConversationId));
wsConversationIds = wsConvs.stream()
.map(ConversationEntity::getConversationId).toList();
if (wsConversationIds.isEmpty()) {
// workspace 无任何对话直接返回零值
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("conversations", conversations);
empty.put("messages", 0L);
empty.put("totalTokens", 0L);
empty.put("promptTokens", 0L);
empty.put("completionTokens", 0L);
empty.put("toolCalls", 0L);
return empty;
}
}
// 总消息数
LambdaQueryWrapper<MessageEntity> msgWrapper = new LambdaQueryWrapper<MessageEntity>()
.ge(MessageEntity::getCreateTime, startTime)
.le(MessageEntity::getCreateTime, endTime)
.eq(MessageEntity::getDeleted, 0);
// 总消息数
if (wsConversationIds != null) {
msgWrapper.in(MessageEntity::getConversationId, wsConversationIds);
}
long messages = messageMapper.selectCount(msgWrapper);
// Token 统计assistant 消息
@ -88,6 +112,9 @@ public class DashboardService {
.le(MessageEntity::getCreateTime, endTime)
.eq(MessageEntity::getDeleted, 0)
.select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens);
if (wsConversationIds != null) {
tokenWrapper.in(MessageEntity::getConversationId, wsConversationIds);
}
List<MessageEntity> assistantMessages = messageMapper.selectList(tokenWrapper);
@ -106,6 +133,9 @@ public class DashboardService {
.ge(MessageEntity::getCreateTime, startTime)
.le(MessageEntity::getCreateTime, endTime)
.eq(MessageEntity::getDeleted, 0);
if (wsConversationIds != null) {
toolWrapper.in(MessageEntity::getConversationId, wsConversationIds);
}
long toolCalls = messageMapper.selectCount(toolWrapper);
Map<String, Object> stats = new LinkedHashMap<>();

View File

@ -2,7 +2,6 @@ package vip.mate.memory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* 记忆模块自动配置
@ -10,7 +9,6 @@ import org.springframework.scheduling.annotation.EnableAsync;
* @author MateClaw Team
*/
@Configuration
@EnableAsync
@EnableConfigurationProperties(MemoryProperties.class)
public class MemoryAutoConfiguration {
}

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.memory.service.MemoryEmergenceService;
import vip.mate.memory.service.MemoryRecallService;
import vip.mate.memory.service.MemorySummarizationService;
@ -39,6 +40,7 @@ public class MemoryController {
@Operation(summary = "手动触发记忆整合daily notes → MEMORY.md")
@PostMapping("/{agentId}/emergence")
@RequireWorkspaceRole("member")
public R<Map<String, String>> triggerEmergence(@PathVariable Long agentId) {
try {
emergenceService.consolidate(agentId);
@ -51,6 +53,7 @@ public class MemoryController {
@Operation(summary = "手动触发对话记忆提取")
@PostMapping("/{agentId}/summarize/{conversationId}")
@RequireWorkspaceRole("member")
public R<Map<String, String>> triggerSummarize(
@PathVariable Long agentId,
@PathVariable String conversationId) {
@ -68,6 +71,7 @@ public class MemoryController {
@Operation(summary = "查询 Dreaming 状态(配置、统计、上次运行时间)")
@GetMapping("/{agentId}/dreaming/status")
@RequireWorkspaceRole("viewer")
public R<Map<String, Object>> getDreamingStatus(@PathVariable Long agentId) {
Map<String, Object> status = recallService.getDreamingStatus(agentId);
status.put("lastRunTime", dreamingScheduler.getLastRunTime());
@ -76,12 +80,14 @@ public class MemoryController {
@Operation(summary = "查询召回候选列表(含评分详情)")
@GetMapping("/{agentId}/dreaming/candidates")
@RequireWorkspaceRole("viewer")
public R<List<Map<String, Object>>> getDreamingCandidates(@PathVariable Long agentId) {
return R.ok(recallService.listCandidatesWithDetails(agentId));
}
@Operation(summary = "查询 DREAMS.md 整合日记")
@GetMapping("/{agentId}/dreaming/dreams")
@RequireWorkspaceRole("viewer")
public R<Map<String, Object>> getDreams(@PathVariable Long agentId) {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "DREAMS.md");
Map<String, Object> result = new LinkedHashMap<>();

View File

@ -2,7 +2,6 @@ package vip.mate.skill.workspace;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import vip.mate.skill.installer.SkillHubProperties;
/**
@ -11,7 +10,6 @@ import vip.mate.skill.installer.SkillHubProperties;
* @author MateClaw Team
*/
@Configuration
@EnableAsync
@EnableConfigurationProperties({SkillWorkspaceProperties.class, SkillHubProperties.class})
public class SkillWorkspaceAutoConfiguration {
}

View File

@ -2,7 +2,6 @@ package vip.mate.wiki;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Wiki 知识库模块自动配置
@ -10,7 +9,6 @@ import org.springframework.scheduling.annotation.EnableAsync;
* @author MateClaw Team
*/
@Configuration
@EnableAsync
@EnableConfigurationProperties(WikiProperties.class)
public class WikiAutoConfiguration {
}

View File

@ -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.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
@ -54,16 +55,16 @@ public class WikiController {
@GetMapping("/knowledge-bases")
public R<List<WikiKnowledgeBaseEntity>> listKBs(
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
if (workspaceId != null) {
return R.ok(kbService.listByWorkspace(workspaceId));
}
return R.ok(kbService.listAll());
long wsId = workspaceId != null ? workspaceId : 1L;
return R.ok(kbService.listByWorkspace(wsId));
}
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取知识库详情")
@GetMapping("/knowledge-bases/{id}")
public R<WikiKnowledgeBaseEntity> getKB(@PathVariable Long id) {
public R<WikiKnowledgeBaseEntity> getKB(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
WikiKnowledgeBaseEntity kb = kbService.getById(id);
if (kb == null) return R.fail("Knowledge base not found");
return R.ok(kb);
@ -72,8 +73,14 @@ public class WikiController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "按 Agent 获取知识库")
@GetMapping("/knowledge-bases/agent/{agentId}")
public R<List<WikiKnowledgeBaseEntity>> listKBsByAgent(@PathVariable Long agentId) {
return R.ok(kbService.listByAgentId(agentId));
public R<List<WikiKnowledgeBaseEntity>> listKBsByAgent(@PathVariable Long agentId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
long wsId = workspaceId != null ? workspaceId : 1L;
// agent 查询后过滤出属于当前 workspace 的知识库
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
return R.ok(kbs.stream()
.filter(kb -> kb.getWorkspaceId() == null || kb.getWorkspaceId().equals(wsId))
.toList());
}
@RequireWorkspaceRole("member")
@ -84,19 +91,17 @@ public class WikiController {
String name = (String) body.get("name");
String description = (String) body.get("description");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
WikiKnowledgeBaseEntity kb = kbService.create(name, description, agentId);
// 注入 workspace_idcreate 方法内部不感知 workspace需要在 controller 层补充
if (kb.getWorkspaceId() == null || kb.getWorkspaceId() == 0) {
kb.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
kbService.updateWorkspaceId(kb.getId(), kb.getWorkspaceId());
}
long wsId = workspaceId != null ? workspaceId : 1L;
WikiKnowledgeBaseEntity kb = kbService.create(name, description, agentId, wsId);
return R.ok(kb);
}
@RequireWorkspaceRole("member")
@Operation(summary = "更新知识库")
@PutMapping("/knowledge-bases/{id}")
public R<WikiKnowledgeBaseEntity> updateKB(@PathVariable Long id, @RequestBody Map<String, Object> body) {
public R<WikiKnowledgeBaseEntity> updateKB(@PathVariable Long id, @RequestBody Map<String, Object> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
String name = (String) body.get("name");
String description = (String) body.get("description");
Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null;
@ -106,7 +111,9 @@ public class WikiController {
@RequireWorkspaceRole("admin")
@Operation(summary = "删除知识库")
@DeleteMapping("/knowledge-bases/{id}")
public R<Void> deleteKB(@PathVariable Long id) {
public R<Void> deleteKB(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
kbService.delete(id);
return R.ok();
}
@ -114,7 +121,9 @@ public class WikiController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取知识库配置")
@GetMapping("/knowledge-bases/{id}/config")
public R<Map<String, String>> getConfig(@PathVariable Long id) {
public R<Map<String, String>> getConfig(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
WikiKnowledgeBaseEntity kb = kbService.getById(id);
if (kb == null) return R.fail("Knowledge base not found");
return R.ok(Map.of("content", kb.getConfigContent() != null ? kb.getConfigContent() : ""));
@ -123,7 +132,9 @@ public class WikiController {
@RequireWorkspaceRole("member")
@Operation(summary = "更新知识库配置")
@PutMapping("/knowledge-bases/{id}/config")
public R<Void> updateConfig(@PathVariable Long id, @RequestBody Map<String, String> body) {
public R<Void> updateConfig(@PathVariable Long id, @RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
kbService.updateConfig(id, body.get("content"));
return R.ok();
}
@ -133,7 +144,9 @@ public class WikiController {
@RequireWorkspaceRole("member")
@Operation(summary = "设置知识库关联目录")
@PutMapping("/knowledge-bases/{id}/source-directory")
public R<Void> setSourceDirectory(@PathVariable Long id, @RequestBody Map<String, String> body) {
public R<Void> setSourceDirectory(@PathVariable Long id, @RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
String path = body.get("path");
kbService.updateSourceDirectory(id, path);
return R.ok();
@ -142,7 +155,9 @@ public class WikiController {
@RequireWorkspaceRole("member")
@Operation(summary = "扫描关联目录导入文件")
@PostMapping("/knowledge-bases/{id}/scan")
public R<Map<String, Object>> scanDirectory(@PathVariable Long id) {
public R<Map<String, Object>> scanDirectory(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
WikiDirectoryScanService.ScanResult result = scanService.scan(id);
Map<String, Object> response = new LinkedHashMap<>();
response.put("scanned", result.scanned());
@ -157,14 +172,18 @@ public class WikiController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取原始材料列表")
@GetMapping("/knowledge-bases/{kbId}/raw")
public R<List<WikiRawMaterialEntity>> listRaw(@PathVariable Long kbId) {
public R<List<WikiRawMaterialEntity>> listRaw(@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
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) {
public R<WikiRawMaterialEntity> addRawText(@PathVariable Long kbId, @RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
String title = body.get("title");
String content = body.get("content");
return R.ok(rawService.addText(kbId, title, content));
@ -174,7 +193,9 @@ public class WikiController {
@Operation(summary = "上传文件材料")
@PostMapping("/knowledge-bases/{kbId}/raw/upload")
public R<WikiRawMaterialEntity> uploadRaw(@PathVariable Long kbId,
@RequestParam("file") MultipartFile file) throws IOException {
@RequestParam("file") MultipartFile file,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) throws IOException {
verifyKBWorkspace(kbId, workspaceId);
String originalName = file.getOriginalFilename();
String extension = originalName != null && originalName.contains(".")
? originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase()
@ -206,7 +227,9 @@ public class WikiController {
@RequireWorkspaceRole("admin")
@Operation(summary = "删除原始材料")
@DeleteMapping("/knowledge-bases/{kbId}/raw/{rawId}")
public R<Void> deleteRaw(@PathVariable Long kbId, @PathVariable Long rawId) {
public R<Void> deleteRaw(@PathVariable Long kbId, @PathVariable Long rawId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
@ -219,7 +242,9 @@ public class WikiController {
@RequireWorkspaceRole("member")
@Operation(summary = "重新处理原始材料")
@PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/reprocess")
public R<Void> reprocessRaw(@PathVariable Long kbId, @PathVariable Long rawId) {
public R<Void> reprocessRaw(@PathVariable Long kbId, @PathVariable Long rawId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiRawMaterialEntity raw = rawService.getById(rawId);
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
@ -233,14 +258,18 @@ public class WikiController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取 Wiki 页面列表")
@GetMapping("/knowledge-bases/{kbId}/pages")
public R<List<WikiPageEntity>> listPages(@PathVariable Long kbId) {
public R<List<WikiPageEntity>> listPages(@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
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) {
public R<WikiPageEntity> getPage(@PathVariable Long kbId, @PathVariable String slug,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) return R.fail("Page not found");
return R.ok(page);
@ -250,14 +279,18 @@ public class WikiController {
@Operation(summary = "手动编辑 Wiki 页面")
@PutMapping("/knowledge-bases/{kbId}/pages/{slug}")
public R<WikiPageEntity> updatePage(@PathVariable Long kbId, @PathVariable String slug,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
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) {
public R<Void> deletePage(@PathVariable Long kbId, @PathVariable String slug,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
pageService.delete(kbId, slug);
kbService.setPageCount(kbId, pageService.countByKbId(kbId));
return R.ok();
@ -266,7 +299,9 @@ public class WikiController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取反向链接")
@GetMapping("/knowledge-bases/{kbId}/pages/{slug}/backlinks")
public R<List<WikiPageEntity>> getBacklinks(@PathVariable Long kbId, @PathVariable String slug) {
public R<List<WikiPageEntity>> getBacklinks(@PathVariable Long kbId, @PathVariable String slug,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
return R.ok(pageService.getBacklinks(kbId, slug));
}
@ -275,7 +310,9 @@ public class WikiController {
@RequireWorkspaceRole("member")
@Operation(summary = "触发知识库处理(异步)")
@PostMapping("/knowledge-bases/{kbId}/process")
public R<Map<String, Object>> processKB(@PathVariable Long kbId) {
public R<Map<String, Object>> processKB(@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<WikiRawMaterialEntity> pending = rawService.listPending(kbId);
for (WikiRawMaterialEntity raw : pending) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId));
@ -286,7 +323,9 @@ public class WikiController {
@RequireWorkspaceRole("viewer")
@Operation(summary = "获取处理状态")
@GetMapping("/knowledge-bases/{kbId}/processing-status")
public R<Map<String, Object>> getProcessingStatus(@PathVariable Long kbId) {
public R<Map<String, Object>> getProcessingStatus(@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null) return R.fail("Knowledge base not found");
@ -306,4 +345,17 @@ public class WikiController {
"totalPages", kb.getPageCount()
));
}
// ==================== Workspace Verification ====================
private void verifyKBWorkspace(Long kbId, Long headerWorkspaceId) {
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
if (kb == null) {
throw new MateClawException("Knowledge base not found");
}
long wsId = headerWorkspaceId != null ? headerWorkspaceId : 1L;
if (kb.getWorkspaceId() != null && !kb.getWorkspaceId().equals(wsId)) {
throw new MateClawException("资源不属于当前工作区");
}
}
}

View File

@ -79,16 +79,22 @@ public class WikiKnowledgeBaseService {
@Transactional
public WikiKnowledgeBaseEntity create(String name, String description, Long agentId) {
return create(name, description, agentId, 1L);
}
@Transactional
public WikiKnowledgeBaseEntity create(String name, String description, Long agentId, Long workspaceId) {
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
entity.setName(name);
entity.setDescription(description);
entity.setAgentId(agentId);
entity.setWorkspaceId(workspaceId);
entity.setConfigContent(DEFAULT_CONFIG);
entity.setStatus("active");
entity.setPageCount(0);
entity.setRawCount(0);
kbMapper.insert(entity);
log.info("[Wiki] Knowledge base created: id={}, name={}", entity.getId(), name);
log.info("[Wiki] Knowledge base created: id={}, name={}, workspaceId={}", entity.getId(), name, workspaceId);
return entity;
}

View File

@ -233,10 +233,6 @@ public class WikiTool {
*/
private Long resolveKbId(Long agentId) {
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
if (kbs.isEmpty()) {
// agentId null 时也尝试查公共 KB
kbs = kbService.listAll();
}
return kbs.isEmpty() ? null : kbs.get(0).getId();
}

View File

@ -96,10 +96,19 @@ public class ConversationService {
}
/**
* 获取或创建会话
* 获取或创建会话向后兼容默认 workspace 1
*/
@Transactional
public ConversationEntity getOrCreateConversation(String conversationId, Long agentId, String username) {
return getOrCreateConversation(conversationId, agentId, username, 1L);
}
/**
* 获取或创建会话workspace 感知
*/
@Transactional
public ConversationEntity getOrCreateConversation(String conversationId, Long agentId,
String username, Long workspaceId) {
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv == null) {
@ -107,6 +116,7 @@ public class ConversationService {
conv.setConversationId(conversationId);
conv.setAgentId(agentId);
conv.setUsername(username != null ? username : "anonymous");
conv.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
conv.setTitle("新对话");
conv.setMessageCount(0);
conv.setLastActiveTime(LocalDateTime.now());
@ -126,6 +136,14 @@ public class ConversationService {
*/
@Transactional
public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId) {
return getOrCreateSharedConversation(conversationId, agentId, null);
}
/**
* 获取或创建共享渠道会话workspace 感知
*/
@Transactional
public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId, Long workspaceId) {
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv == null) {
@ -133,6 +151,7 @@ public class ConversationService {
conv.setConversationId(conversationId);
conv.setAgentId(agentId);
conv.setUsername(SYSTEM_USER);
conv.setWorkspaceId(workspaceId != null ? workspaceId : 1L);
conv.setTitle("新对话");
conv.setMessageCount(0);
conv.setLastActiveTime(LocalDateTime.now());

View File

@ -280,7 +280,7 @@ export const settingsApi = {
const encodeFilePath = (filename: string) =>
filename.split('/').map(encodeURIComponent).join('/')
export const workspaceApi = {
export const agentContextApi = {
listFiles: (agentId: string | number) =>
http.get(`/agents/${agentId}/workspace/files`),
getFile: (agentId: string | number, filename: string) =>

View File

@ -1,30 +1,45 @@
@import "tailwindcss";
/* ================================================================
CSS Variables Light / Dark theme (Terracotta Earth-Tone)
CSS Variables Light / Dark theme
================================================================ */
:root {
/* brand */
--mc-primary: #D97757;
--mc-primary-light: #E08860;
--mc-primary-hover: #C1572B;
--mc-primary-bg: #F5E4D8;
--mc-primary: #d96d46;
--mc-primary-light: #ebb08f;
--mc-primary-hover: #bb4f27;
--mc-primary-bg: #f6e2d7;
--mc-accent: #184a45;
--mc-accent-soft: #dce8e4;
/* surfaces */
--mc-bg: #FAFAF8;
--mc-bg: #f6f1ea;
--mc-bg-elevated: #ffffff;
--mc-bg-sunken: #EDE8E3;
--mc-bg-sunken: #ebe3db;
--mc-bg-muted: #f1e8df;
--mc-surface-strong: #fdfaf6;
--mc-surface-overlay: rgba(255, 255, 255, 0.72);
--mc-panel-top: rgba(255, 255, 255, 0.94);
--mc-panel-bottom: rgba(255, 252, 248, 0.9);
--mc-panel-raised: rgba(255, 255, 255, 0.86);
/* borders */
--mc-border: #DDD5CC;
--mc-border-light: #EDE8E3;
--mc-border: #d9cec2;
--mc-border-light: #ebe3db;
--mc-border-strong: #cdbdad;
/* text */
--mc-text-primary: #1C1410;
--mc-text-secondary: #6B5344;
--mc-text-tertiary: #A08070;
--mc-text-primary: #1d1612;
--mc-text-secondary: #665245;
--mc-text-tertiary: #9b7d6c;
--mc-text-inverse: #ffffff;
/* shadows */
--mc-shadow-soft: 0 10px 30px rgba(58, 32, 19, 0.08);
--mc-shadow-medium: 0 18px 48px rgba(58, 32, 19, 0.12);
--mc-shadow-strong: 0 24px 70px rgba(58, 32, 19, 0.16);
--mc-glow: radial-gradient(circle at top, rgba(217, 109, 70, 0.18), transparent 55%);
/* sidebar */
--mc-sidebar-bg: #ffffff;
--mc-sidebar-border: #DDD5CC;
@ -34,6 +49,8 @@
--mc-sidebar-text-active: #D97757;
--mc-sidebar-group-title: #A08070;
--mc-sidebar-logo-name: #1C1410;
--mc-sidebar-footer-bg: rgba(245, 238, 230, 0.82);
--mc-sidebar-floating-bg: rgba(255, 252, 248, 0.95);
/* chat */
--mc-chat-bg: #FAFAF8;
@ -97,19 +114,28 @@
html.dark {
/* brand (lighter in dark bg context) */
--mc-primary: #E08860;
--mc-primary-light: #F0C4A0;
--mc-primary-hover: #D97757;
--mc-primary: #eb8f65;
--mc-primary-light: #f1c3ab;
--mc-primary-hover: #d96d46;
--mc-primary-bg: rgba(224, 136, 96, 0.15);
--mc-accent: #5ca69d;
--mc-accent-soft: rgba(92, 166, 157, 0.12);
/* surfaces */
--mc-bg: #1A1410;
--mc-bg-elevated: #231C17;
--mc-bg-sunken: #2A211C;
--mc-bg: #17110e;
--mc-bg-elevated: #221a16;
--mc-bg-sunken: #2a211c;
--mc-bg-muted: #201813;
--mc-surface-strong: #2a201a;
--mc-surface-overlay: rgba(34, 26, 22, 0.78);
--mc-panel-top: rgba(36, 28, 24, 0.96);
--mc-panel-bottom: rgba(27, 21, 18, 0.94);
--mc-panel-raised: rgba(42, 32, 26, 0.9);
/* borders */
--mc-border: #3D3028;
--mc-border-light: #2E2420;
--mc-border-strong: #4a392f;
/* text */
--mc-text-primary: #F0EAE4;
@ -117,6 +143,12 @@ html.dark {
--mc-text-tertiary: #8A7060;
--mc-text-inverse: #1C1410;
/* shadows */
--mc-shadow-soft: 0 14px 32px rgba(0, 0, 0, 0.22);
--mc-shadow-medium: 0 20px 60px rgba(0, 0, 0, 0.28);
--mc-shadow-strong: 0 28px 90px rgba(0, 0, 0, 0.35);
--mc-glow: radial-gradient(circle at top, rgba(235, 143, 101, 0.12), transparent 55%);
/* sidebar */
--mc-sidebar-bg: #1A1410;
--mc-sidebar-border: #3D3028;
@ -126,6 +158,8 @@ html.dark {
--mc-sidebar-text-active: #E08860;
--mc-sidebar-group-title: #8A7060;
--mc-sidebar-logo-name: #F0EAE4;
--mc-sidebar-footer-bg: rgba(34, 26, 22, 0.9);
--mc-sidebar-floating-bg: rgba(36, 28, 24, 0.96);
/* chat */
--mc-chat-bg: #1A1410;
@ -189,12 +223,48 @@ html.dark {
================================================================ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app {
min-height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-family: 'Avenir Next', 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background-color: var(--mc-bg);
color: var(--mc-text-primary);
-webkit-font-smoothing: antialiased;
transition: background-color 0.2s ease, color 0.2s ease;
background-image:
radial-gradient(circle at top left, rgba(217, 109, 70, 0.1), transparent 26%),
radial-gradient(circle at bottom right, rgba(24, 74, 69, 0.08), transparent 24%);
}
html.dark body {
background-image:
radial-gradient(circle at top left, rgba(235, 143, 101, 0.14), transparent 28%),
radial-gradient(circle at bottom right, rgba(92, 166, 157, 0.08), transparent 26%);
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.35;
mix-blend-mode: soft-light;
}
html.dark body::before {
opacity: 0.18;
mix-blend-mode: normal;
}
::selection {
background: var(--mc-primary-bg);
color: var(--mc-text-primary);
}
/* ================================================================
@ -320,6 +390,106 @@ body {
font-size: 13px;
}
/* ================================================================
Shared page shell
================================================================ */
.mc-page-shell {
height: 100%;
overflow-y: auto;
padding: 28px;
}
.mc-page-frame {
position: relative;
border: 1px solid var(--mc-border);
background: var(--mc-surface-overlay);
backdrop-filter: blur(18px);
border-radius: 28px;
box-shadow: var(--mc-shadow-soft);
}
.mc-page-frame::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--mc-glow);
opacity: 0.8;
pointer-events: none;
}
.mc-page-inner {
position: relative;
z-index: 1;
padding: 28px;
}
.mc-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 28px;
}
.mc-page-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
color: var(--mc-accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 12px;
}
.mc-page-title {
font-size: clamp(26px, 3vw, 40px);
line-height: 1;
font-weight: 800;
letter-spacing: -0.04em;
color: var(--mc-text-primary);
margin: 0 0 10px;
}
.mc-page-desc {
max-width: 760px;
color: var(--mc-text-secondary);
font-size: 15px;
line-height: 1.7;
}
.mc-surface-card {
border: 1px solid var(--mc-border-light);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.92));
border-radius: 22px;
box-shadow: var(--mc-shadow-soft);
}
html.dark .mc-surface-card {
background: linear-gradient(180deg, rgba(42, 32, 26, 0.86), rgba(34, 26, 22, 0.96));
}
@media (max-width: 900px) {
.mc-page-shell {
padding: 16px;
}
.mc-page-inner {
padding: 18px;
}
.mc-page-header {
flex-direction: column;
align-items: stretch;
}
}
/* highlight.js token colors (One Dark inspired — works on dark bg) */
.hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c678dd; }
.hljs-string, .hljs-attr { color: #98c379; }

View File

@ -35,6 +35,14 @@
</span>
{{ ws.name }}
</el-dropdown-item>
<el-dropdown-item divided command="__manage__">
<span class="ws-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
Manage Workspaces
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -43,6 +51,7 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
defineProps<{
@ -50,6 +59,7 @@ defineProps<{
}>()
const store = useWorkspaceStore()
const router = useRouter()
const workspaces = computed(() => store.workspaces)
const currentWorkspaceId = computed(() => store.currentWorkspaceId)
const currentLabel = computed(() => store.currentWorkspace?.name || 'Workspace')
@ -58,10 +68,12 @@ onMounted(() => {
store.fetchWorkspaces()
})
function onSwitch(id: number) {
store.switchWorkspace(id)
// Reload current page data by emitting event
window.dispatchEvent(new CustomEvent('workspace-changed', { detail: { workspaceId: id } }))
function onSwitch(id: number | string) {
if (id === '__manage__') {
router.push('/security/workspaces')
return
}
store.switchWorkspace(id as number)
}
</script>

View File

@ -152,10 +152,18 @@ export function useChat(options: UseChatOptions): UseChatReturn {
// 消息队列
const messageQueue = useMessageQueue()
// 流连接
// 流连接(注入 auth + workspace header与 axios interceptor 保持一致)
const streamHeaders: Record<string, string> = {}
if (token) {
streamHeaders['Authorization'] = `Bearer ${token}`
}
const wsId = localStorage.getItem('mc-workspace-id')
if (wsId) {
streamHeaders['X-Workspace-Id'] = wsId
}
const stream = useStream({
url: `${baseUrl}/api/v1/chat/stream`,
headers: token ? { Authorization: `Bearer ${token}` } : {},
headers: streamHeaders,
})
// ===== SSE 事件处理器 =====

View File

@ -186,6 +186,7 @@ export default {
sessions: 'Sessions',
agent: 'Agent',
workspace: 'Workspace',
agentContext: 'Agent Context',
skills: 'Skills',
wiki: 'Wiki KB',
tools: 'Tools',
@ -204,6 +205,9 @@ export default {
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
themeLabel: 'Appearance',
languageLabel: 'Language',
appearance: 'Appearance & Language',
roleUser: 'User',
roleAdmin: 'Admin',
},
@ -236,6 +240,44 @@ export default {
systemDesc: 'Language and runtime behavior settings',
aboutTitle: 'About MateClaw',
aboutDesc: 'Version and system information',
about: {
heroKicker: 'Product',
heroTitle: 'MateClaw is not a chat window. It is an AI operating system for ongoing work.',
heroDesc: 'The point is not to generate a few more answers. The point is to place models inside a continuous loop of context, memory, execution, knowledge, and delivery so the product behaves like a real system.',
manifestoKicker: 'Why It Exists',
manifestoTitle: 'This page should not stop at a version number. It should explain what this product is trying to become.',
manifestoDesc: 'MateClaw is not defined by how many features it exposes. It is defined by how tightly it pulls context, tools, memory, and output into one operating surface that can keep pace with real work.',
systemKicker: 'System Shape',
systemTitle: 'A valuable product is not a pile of capabilities. It is a set of capabilities that lock together.',
systemDesc: 'Agents, Wiki, Memory, Channels, Workspace, and governance are not parallel modules. They are supposed to form one system that users can understand, trust, and extend.',
foundationKicker: 'Foundation',
foundationTitle: 'The experience needs a backbone, and the backbone has to be strong.',
foundationDesc: 'The stack matters only if it can support a stable runtime, extensible agentic patterns, and boundaries that hold up under real work.',
pillars: {
contextTitle: 'Keep context continuous',
contextDesc: 'Models, knowledge, workspace, and the active task should live inside the same frame instead of starting over every turn.',
executionTitle: 'Make execution part of the product',
executionDesc: 'Browser actions, tools, channels, and async tasks should not feel bolted on. They need to be the real execution surface.',
memoryTitle: 'Make memory compound',
memoryDesc: 'Memory should not just be stored. It should be shaped, recalled, and turned into leverage for the next task.',
},
manifestoItems: {
runtimeTitle: 'It is a runtime before it is a feature list',
runtimeDesc: 'Strong products solve runtime coherence first: context, permissions, state, and outcomes need to speak the same language.',
knowledgeTitle: 'It must know how to organize knowledge',
knowledgeDesc: 'Wiki and memory matter because they let the system learn what is worth preserving and what should be available again later.',
multimodalTitle: 'It should handle multimodal work naturally',
multimodalDesc: 'Voice, image, video, and text should feel like one expression and execution system, not separate tricks.',
},
systemItems: {
workspaceTitle: 'Workspace is a boundary, not a decoration',
workspaceDesc: 'Every resource, state transition, and execution path needs to know which workspace it belongs to. Platform boundaries must be real.',
governanceTitle: 'Governance belongs in the core',
governanceDesc: 'Audit, approvals, guards, activity, and observability are not back-office extras. They define whether users trust the system.',
deliveryTitle: 'Delivery matters more than answers',
deliveryDesc: 'A mature AI product should not stop at generating a response. It should keep moving the task, expose state, and leave useful results behind.',
},
},
model: {
title: 'Model Management',
desc: 'Configure model providers, credentials, and model lists',
@ -499,9 +541,9 @@ export default {
enUS: 'English',
},
},
workspace: {
title: 'Workspace',
desc: 'Manage Agent Markdown system prompt files',
agentContext: {
title: 'Agent Context',
desc: 'Manage prompt files and memory for your agents',
selectAgent: 'Select Agent',
noAgent: 'Please select an agent first',
files: 'Files',
@ -608,6 +650,7 @@ export default {
fileGuard: 'File Guard',
auditLogs: 'Audit Logs',
members: 'Members',
workspaces: 'Workspaces',
activity: 'Activity',
},
activity: {
@ -665,6 +708,51 @@ export default {
removeFailed: 'Failed to remove member',
},
},
workspaces: {
title: 'Workspaces',
desc: 'Manage workspaces for your organization.',
newWorkspace: 'New Workspace',
loading: 'Loading workspaces...',
noWorkspaces: 'No workspaces found.',
current: 'Current',
columns: {
name: 'Name',
slug: 'Slug',
description: 'Description',
created: 'Created',
actions: 'Actions',
},
createDialog: {
title: 'New Workspace',
name: 'Name',
namePlaceholder: 'e.g. Engineering',
slug: 'Slug',
slugPlaceholder: 'e.g. engineering',
slugHint: 'Slug cannot be changed after creation.',
description: 'Description',
descriptionPlaceholder: 'Optional description',
},
editDialog: {
title: 'Edit Workspace',
},
deleteDialog: {
title: 'Delete Workspace',
confirm: 'Are you sure you want to delete {name}? This will remove all resources associated with this workspace.',
},
actions: {
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel',
create: 'Create',
save: 'Save',
},
messages: {
saveSuccess: 'Workspace saved successfully',
saveFailed: 'Failed to save workspace',
deleteSuccess: 'Workspace deleted',
deleteFailed: 'Failed to delete workspace',
},
},
toolGuard: {
title: 'Tool Guard',
desc: 'Manage tool invocation security rules and global guard settings',

View File

@ -189,6 +189,7 @@ export default {
system: '系统',
agent: '智能体',
workspace: '工作区',
agentContext: 'Agent 上下文',
skills: '技能',
wiki: 'Wiki 知识库',
tools: '工具',
@ -204,6 +205,9 @@ export default {
themeLight: '浅色',
themeDark: '深色',
themeSystem: '跟随系统',
themeLabel: '外观',
languageLabel: '语言',
appearance: '外观与语言',
roleUser: '用户',
roleAdmin: '管理员',
},
@ -226,6 +230,44 @@ export default {
systemDesc: '语言与运行行为配置',
aboutTitle: '关于 MateClaw',
aboutDesc: '版本与系统信息',
about: {
heroKicker: 'Product',
heroTitle: 'MateClaw 不是一个聊天窗口,而是一套持续运转的 AI 工作系统。',
heroDesc: '它的目标不是帮你多生成几段文本,而是让模型真正进入任务、知识、记忆、执行和协作的连续流程里,像一个系统一样稳定工作。',
manifestoKicker: 'Why It Exists',
manifestoTitle: '这页不该只是版本号,它应该回答这个产品到底想成为什么。',
manifestoDesc: 'MateClaw 的核心不是“功能很多”,而是把上下文、工具、记忆和交付收拢成一个统一运行面,让 AI 能持续理解你正在做什么,并把结果带回来。',
systemKicker: 'System Shape',
systemTitle: '真正有价值的产品,不是把能力摆满,而是让能力彼此咬合。',
systemDesc: 'Agent、Wiki、Memory、Channels、Workspace 和治理能力不是平行堆叠的模块,它们应该共同构成一个可被理解、可被信任、可被扩展的系统。',
foundationKicker: 'Foundation',
foundationTitle: '体验必须有骨架,骨架必须足够硬。',
foundationDesc: '底层技术栈的价值不在于名字响亮,而在于它们能否支撑稳定的 runtime、可扩展的 agentic patterns以及面向真实工作的产品边界。',
pillars: {
contextTitle: '让上下文保持连续',
contextDesc: '把模型、知识、工作区和当前任务放进同一个语境里,而不是每次重新开始。',
executionTitle: '让执行成为产品的一部分',
executionDesc: 'Browser、tools、channels 与异步任务不应是外挂,它们必须成为系统的真实执行面。',
memoryTitle: '让记忆真正产生复利',
memoryDesc: '记忆不只是被保存,而是被整理、被召回,并在下一次任务里变得更有价值。',
},
manifestoItems: {
runtimeTitle: '它首先是一套 runtime',
runtimeDesc: '任何强产品都必须先解决运行时的一致性:上下文、权限、状态和结果必须说同一种语言。',
knowledgeTitle: '它必须会组织知识',
knowledgeDesc: 'Wiki 与记忆系统的意义,不是多一个存储区,而是让系统逐渐知道什么值得保留、什么值得再次使用。',
multimodalTitle: '它应该自然处理多模态',
multimodalDesc: '语音、图片、视频与文本不能彼此割裂,用户看到的应该是一套统一的表达与执行能力。',
},
systemItems: {
workspaceTitle: 'Workspace 是边界,不是装饰',
workspaceDesc: '所有资源、状态与执行路径都必须知道自己属于哪个工作区,平台边界要真实,而不是靠页面暗示。',
governanceTitle: '治理能力必须进入核心',
governanceDesc: 'Audit、审批、guard、activity 和 observability 不是后台功能,它们决定用户是否真正信任这个系统。',
deliveryTitle: '交付比回答更重要',
deliveryDesc: '一个成熟的 AI 产品不该停在回答层,它应该能持续推进任务、暴露状态、沉淀结果。',
},
},
model: {
title: '模型管理',
desc: '配置模型 Provider、凭证和模型列表',
@ -499,9 +541,9 @@ export default {
enUS: 'English',
},
},
workspace: {
title: '工作区',
desc: '管理 Agent 的 Markdown 系统提示文件',
agentContext: {
title: 'Agent 上下文',
desc: '管理 Agent 的提示文件和记忆',
selectAgent: '选择 Agent',
noAgent: '请先选择一个 Agent',
files: '文件列表',
@ -608,6 +650,7 @@ export default {
fileGuard: '文件防护',
auditLogs: '审计日志',
members: '成员管理',
workspaces: '工作区',
activity: '操作日志',
},
activity: {
@ -665,6 +708,51 @@ export default {
removeFailed: '移除成员失败',
},
},
workspaces: {
title: '工作区管理',
desc: '管理组织的工作区。',
newWorkspace: '新建工作区',
loading: '加载中...',
noWorkspaces: '暂无工作区。',
current: '当前',
columns: {
name: '名称',
slug: '标识',
description: '描述',
created: '创建时间',
actions: '操作',
},
createDialog: {
title: '新建工作区',
name: '名称',
namePlaceholder: '例如:工程团队',
slug: '标识',
slugPlaceholder: '例如engineering',
slugHint: '标识创建后不可修改。',
description: '描述',
descriptionPlaceholder: '可选描述',
},
editDialog: {
title: '编辑工作区',
},
deleteDialog: {
title: '删除工作区',
confirm: '确定要删除 {name} 吗?这将移除该工作区下的所有资源。',
},
actions: {
edit: '编辑',
delete: '删除',
cancel: '取消',
create: '创建',
save: '保存',
},
messages: {
saveSuccess: '工作区保存成功',
saveFailed: '保存工作区失败',
deleteSuccess: '工作区已删除',
deleteFailed: '删除工作区失败',
},
},
toolGuard: {
title: '工具防护',
desc: '管理工具调用安全规则和全局防护配置',

View File

@ -102,10 +102,10 @@ const router = createRouter({
},
// Advanced (absorbed from top-level nav)
{
path: 'workspace',
name: 'SettingsWorkspace',
component: () => import('@/views/AgentWorkspace.vue'),
meta: { title: 'Settings - Workspace' },
path: 'agent-context',
name: 'SettingsAgentContext',
component: () => import('@/views/AgentContext.vue'),
meta: { title: 'Settings - Agent Context' },
},
{
path: 'cron-jobs',
@ -163,23 +163,29 @@ 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',
component: () => import('@/views/Security/Members/index.vue'),
meta: { title: 'Security - Members' },
},
{
path: 'workspaces',
name: 'SecurityWorkspaces',
component: () => import('@/views/Security/Workspaces/index.vue'),
meta: { title: 'Security - Workspaces' },
},
{
path: 'activity',
name: 'SecurityActivity',
component: () => import('@/views/Security/Activity/index.vue'),
meta: { title: 'Security - Activity' },
},
],
},
// ==================== Redirects (backward compatibility) ====================
{ path: 'sessions', redirect: '/chat' },
{ path: 'workspace', redirect: '/settings/workspace' },
{ path: 'workspace', redirect: '/settings/agent-context' },
{ path: 'cron-jobs', redirect: '/settings/cron-jobs' },
{ path: 'datasources', redirect: '/settings/datasources' },
{ path: 'mcp-servers', redirect: '/settings/mcp-servers' },

View File

@ -3,12 +3,12 @@
<!-- 顶部栏 -->
<div class="workspace-header">
<div>
<h1 class="page-title">{{ t('workspace.title') }}</h1>
<p class="page-desc">{{ t('workspace.desc') }}</p>
<h1 class="page-title">{{ t('agentContext.title') }}</h1>
<p class="page-desc">{{ t('agentContext.desc') }}</p>
</div>
<div class="header-actions">
<select v-model="selectedAgentId" class="agent-select" @change="onAgentChange">
<option value="" disabled>{{ t('workspace.selectAgent') }}</option>
<option value="" disabled>{{ t('agentContext.selectAgent') }}</option>
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
{{ agent.icon || '🤖' }} {{ agent.name }}
</option>
@ -19,7 +19,7 @@
<!-- Agent 提示 -->
<div v-if="!selectedAgentId" class="empty-state">
<div class="empty-icon">📂</div>
<h3>{{ t('workspace.noAgent') }}</h3>
<h3>{{ t('agentContext.noAgent') }}</h3>
</div>
<!-- 主体文件列表 + 编辑器 -->
@ -28,9 +28,9 @@
<div class="file-list-panel">
<div class="panel-card">
<div class="panel-header">
<h3 class="section-title">{{ t('workspace.coreFiles') }}</h3>
<h3 class="section-title">{{ t('agentContext.coreFiles') }}</h3>
<div class="panel-actions">
<button class="icon-btn" @click="showNewFileDialog = true" :title="t('workspace.newFile')">
<button class="icon-btn" @click="showNewFileDialog = true" :title="t('agentContext.newFile')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
@ -42,12 +42,12 @@
</button>
</div>
</div>
<p class="info-text">{{ t('workspace.coreFilesDesc') }}</p>
<p class="info-text">{{ t('agentContext.coreFilesDesc') }}</p>
<div class="divider"></div>
<div class="file-scroll">
<div v-if="sortedFiles.length === 0" class="empty-files">
{{ t('workspace.noFiles') }}
{{ t('agentContext.noFiles') }}
</div>
<div
v-for="file in sortedFiles"
@ -113,7 +113,7 @@
class="preview-mode-btn"
:class="{ active: previewMode === 'off' }"
@click="previewMode = 'off'"
:title="t('workspace.editOnly') || 'Edit'"
:title="t('agentContext.editOnly') || 'Edit'"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
@ -124,7 +124,7 @@
class="preview-mode-btn"
:class="{ active: previewMode === 'split' }"
@click="previewMode = 'split'"
:title="t('workspace.splitView') || 'Split'"
:title="t('agentContext.splitView') || 'Split'"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/>
@ -134,14 +134,14 @@
class="preview-mode-btn"
:class="{ active: previewMode === 'preview' }"
@click="previewMode = 'preview'"
:title="t('workspace.preview') || 'Preview'"
:title="t('agentContext.preview') || 'Preview'"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<span v-if="hasChanges" class="change-badge">{{ t('workspace.modified') || '已修改' }}</span>
<span v-if="hasChanges" class="change-badge">{{ t('agentContext.modified') || '已修改' }}</span>
</div>
<div class="editor-content" :class="'mode-' + previewMode">
@ -149,7 +149,7 @@
v-if="previewMode !== 'preview'"
v-model="fileContent"
class="editor-textarea"
:placeholder="t('workspace.fileContent')"
:placeholder="t('agentContext.fileContent')"
spellcheck="false"
></textarea>
<div
@ -162,7 +162,7 @@
</template>
<div v-else class="empty-editor">
{{ t('workspace.selectFile') }}
{{ t('agentContext.selectFile') }}
</div>
</div>
</div>
@ -172,7 +172,7 @@
<div v-if="showNewFileDialog" class="modal-overlay" @click.self="showNewFileDialog = false">
<div class="modal small-modal">
<div class="modal-header">
<h2>{{ t('workspace.newFileTitle') }}</h2>
<h2>{{ t('agentContext.newFileTitle') }}</h2>
<button class="modal-close" @click="showNewFileDialog = false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@ -181,14 +181,14 @@
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">{{ t('workspace.newFileTitle') }}</label>
<label class="form-label">{{ t('agentContext.newFileTitle') }}</label>
<input
v-model="newFilename"
class="form-input"
:placeholder="t('workspace.newFilePlaceholder')"
:placeholder="t('agentContext.newFilePlaceholder')"
@keyup.enter="createNewFile"
/>
<p class="field-hint">{{ t('workspace.newFileHint') }}</p>
<p class="field-hint">{{ t('agentContext.newFileHint') }}</p>
</div>
</div>
<div class="modal-footer">
@ -204,7 +204,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { agentApi, workspaceApi } from '@/api/index'
import { agentApi, agentContextApi } from '@/api/index'
import type { Agent, WorkspaceFile } from '@/types/index'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
@ -280,7 +280,7 @@ async function loadAgents() {
selectedAgentId.value = agents.value[0].id
}
} catch {
ElMessage.error(t('workspace.loadFailed'))
ElMessage.error(t('agentContext.loadFailed'))
}
}
@ -291,17 +291,17 @@ function onAgentChange() {
async function fetchFiles() {
if (!selectedAgentId.value) return
try {
const res: any = await workspaceApi.listFiles(selectedAgentId.value)
const res: any = await agentContextApi.listFiles(selectedAgentId.value)
files.value = res.data || []
} catch {
ElMessage.error(t('workspace.loadFailed'))
ElMessage.error(t('agentContext.loadFailed'))
}
}
async function fetchPromptFiles() {
if (!selectedAgentId.value) return
try {
const res: any = await workspaceApi.getPromptFiles(selectedAgentId.value)
const res: any = await agentContextApi.getPromptFiles(selectedAgentId.value)
enabledFiles.value = res.data || []
} catch {
enabledFiles.value = []
@ -328,12 +328,12 @@ function handlePreviewClick(e: MouseEvent) {
async function onFileClick(file: WorkspaceFile) {
selectedFile.value = file
try {
const res: any = await workspaceApi.getFile(selectedAgentId.value, file.filename)
const res: any = await agentContextApi.getFile(selectedAgentId.value, file.filename)
const data = res.data
fileContent.value = data?.content || ''
originalContent.value = fileContent.value
} catch {
ElMessage.error(t('workspace.loadFileFailed'))
ElMessage.error(t('agentContext.loadFileFailed'))
}
}
@ -341,12 +341,12 @@ async function saveContent() {
if (!selectedFile.value || !selectedAgentId.value) return
saving.value = true
try {
await workspaceApi.saveFile(selectedAgentId.value, selectedFile.value.filename, fileContent.value)
await agentContextApi.saveFile(selectedAgentId.value, selectedFile.value.filename, fileContent.value)
originalContent.value = fileContent.value
ElMessage.success(t('workspace.saveSuccess'))
ElMessage.success(t('agentContext.saveSuccess'))
await fetchFiles()
} catch {
ElMessage.error(t('workspace.saveFailed'))
ElMessage.error(t('agentContext.saveFailed'))
} finally {
saving.value = false
}
@ -363,14 +363,14 @@ async function toggleFileEnabled(file: WorkspaceFile) {
: enabledFiles.value.filter(f => f !== file.filename)
try {
await workspaceApi.setPromptFiles(selectedAgentId.value, newList)
await agentContextApi.setPromptFiles(selectedAgentId.value, newList)
enabledFiles.value = newList
// file
const f = files.value.find(x => x.filename === file.filename)
if (f) f.enabled = isEnabling
ElMessage.success(t('workspace.promptUpdated'))
ElMessage.success(t('agentContext.promptUpdated'))
} catch {
ElMessage.error(t('workspace.promptUpdateFailed'))
ElMessage.error(t('agentContext.promptUpdateFailed'))
}
}
@ -379,33 +379,33 @@ async function confirmDeleteFile() {
const name = selectedFile.value.filename
try {
await ElMessageBox.confirm(
t('workspace.deleteConfirm', { name }),
t('agentContext.deleteConfirm', { name }),
t('common.delete'),
{ type: 'warning' }
)
} catch { return }
try {
await workspaceApi.deleteFile(selectedAgentId.value, name)
ElMessage.success(t('workspace.deleteSuccess'))
await agentContextApi.deleteFile(selectedAgentId.value, name)
ElMessage.success(t('agentContext.deleteSuccess'))
selectedFile.value = null
fileContent.value = ''
originalContent.value = ''
await fetchFiles()
await fetchPromptFiles()
} catch {
ElMessage.error(t('workspace.deleteFailed'))
ElMessage.error(t('agentContext.deleteFailed'))
}
}
async function createNewFile() {
const name = newFilename.value.trim()
if (!isValidFilename.value) {
ElMessage.warning(t('workspace.invalidFilename'))
ElMessage.warning(t('agentContext.invalidFilename'))
return
}
try {
await workspaceApi.saveFile(selectedAgentId.value, name, '')
await agentContextApi.saveFile(selectedAgentId.value, name, '')
showNewFileDialog.value = false
newFilename.value = ''
await fetchFiles()
@ -415,7 +415,7 @@ async function createNewFile() {
onFileClick(newFile)
}
} catch {
ElMessage.error(t('workspace.saveFailed'))
ElMessage.error(t('agentContext.saveFailed'))
}
}

View File

@ -1,37 +1,41 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('agents.title') }}</h1>
<p class="page-desc">{{ t('agents.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('agents.newAgent') }}
</button>
</div>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner agents-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Agent Studio</div>
<h1 class="mc-page-title">{{ t('agents.title') }}</h1>
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('agents.newAgent') }}
</button>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input v-model="searchText" :placeholder="t('agents.search')" class="search-input" />
</div>
<div class="filter-tabs">
<button v-for="tab in filterTabs" :key="tab.value" class="filter-tab"
:class="{ active: activeFilter === tab.value }" @click="activeFilter = tab.value">
{{ t(tab.key) }}
</button>
</div>
</div>
<div class="agents-toolbar mc-surface-card">
<div class="filter-bar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input v-model="searchText" :placeholder="t('agents.search')" class="search-input" />
</div>
<div class="filter-tabs">
<button v-for="tab in filterTabs" :key="tab.value" class="filter-tab"
:class="{ active: activeFilter === tab.value }" @click="activeFilter = tab.value">
{{ t(tab.key) }}
</button>
</div>
</div>
</div>
<!-- Agent table -->
<div class="table-wrap" v-if="filteredAgents.length > 0">
<table class="agent-table">
<!-- Agent table -->
<div class="table-wrap mc-surface-card" v-if="filteredAgents.length > 0">
<table class="agent-table">
<thead>
<tr>
<th class="col-name">{{ t('agents.columns.name') }}</th>
@ -89,16 +93,19 @@
</td>
</tr>
</tbody>
</table>
</div>
</table>
</div>
<!-- Empty state -->
<div v-else class="empty-state">
<div class="empty-icon">🤖</div>
<h3>{{ t('agents.emptyTitle') }}</h3>
<p>{{ t('agents.emptyDesc') }}</p>
<button class="btn-primary" @click="openCreateModal">{{ t('agents.newAgent') }}</button>
<!-- Empty state -->
<div v-else class="empty-state mc-surface-card">
<div class="empty-icon">🤖</div>
<h3>{{ t('agents.emptyTitle') }}</h3>
<p>{{ t('agents.emptyDesc') }}</p>
<button class="btn-primary" @click="openCreateModal">{{ t('agents.newAgent') }}</button>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
@ -411,33 +418,31 @@ async function toggleAgent(agent: Agent) {
</script>
<style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.agents-page { gap: 18px; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.15s; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.15s; box-shadow: var(--mc-shadow-soft); }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.filter-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; gap: 8px; background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 8px; padding: 8px 12px; flex: 1; max-width: 300px; }
.agents-toolbar { padding: 18px; }
.filter-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.search-box { display: flex; align-items: center; gap: 8px; background: var(--mc-bg-muted); border: 1px solid var(--mc-border); border-radius: 14px; padding: 10px 12px; flex: 1; max-width: 360px; }
.search-box svg { color: var(--mc-text-tertiary); flex-shrink: 0; }
.search-input { border: none; outline: none; font-size: 14px; color: var(--mc-text-primary); flex: 1; background: transparent; }
.filter-tabs { display: flex; gap: 4px; }
.filter-tab { padding: 6px 14px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
.filter-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-tab { padding: 8px 14px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; font-weight: 600; }
.filter-tab:hover { background: var(--mc-bg-sunken); }
.filter-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; }
/* Table */
.table-wrap { overflow-x: auto; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-elevated); }
.table-wrap { overflow-x: auto; }
.agent-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.agent-table th { padding: 12px 16px; text-align: left; font-weight: 600; font-size: 13px; color: var(--mc-text-secondary); background: var(--mc-bg-sunken); border-bottom: 1px solid var(--mc-border); white-space: nowrap; }
.agent-table th { padding: 14px 16px; text-align: left; font-weight: 700; font-size: 12px; color: var(--mc-text-secondary); background: var(--mc-bg-muted); border-bottom: 1px solid var(--mc-border); white-space: nowrap; text-transform: uppercase; letter-spacing: 0.08em; }
.agent-table td { padding: 12px 16px; border-bottom: 1px solid var(--mc-border-light); vertical-align: middle; }
.agent-table tbody tr:last-child td { border-bottom: none; }
.agent-table tbody tr:hover { background: var(--mc-bg-sunken); }
.agent-table tbody tr:hover { background: var(--mc-bg-muted); }
.agent-table tbody tr.row-disabled { opacity: 0.55; }
.col-name { min-width: 200px; }
@ -535,4 +540,15 @@ async function toggleAgent(agent: Agent) {
.form-input:focus, .form-textarea:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-textarea { resize: vertical; font-family: inherit; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
@media (max-width: 900px) {
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.search-box {
max-width: none;
}
}
</style>

View File

@ -1,21 +1,24 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('channels.title') }}</h1>
<p class="page-desc">{{ t('channels.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('channels.newChannel') }}
</button>
</div>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner channels-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Connect</div>
<h1 class="mc-page-title">{{ t('channels.title') }}</h1>
<p class="mc-page-desc">{{ t('channels.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('channels.newChannel') }}
</button>
</div>
<!-- 渠道卡片 -->
<div class="channel-grid">
<div v-for="channel in channels" :key="channel.id" class="channel-card">
<!-- 渠道卡片 -->
<div class="channel-grid">
<div v-for="channel in channels" :key="channel.id" class="channel-card mc-surface-card">
<div class="channel-header">
<div class="channel-icon-wrap">
<img class="channel-icon-img" :src="getChannelIconPath(channel.channelType)" :alt="channel.channelType" />
@ -63,12 +66,14 @@
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<!-- 添加渠道卡片 -->
<div class="channel-card add-card" @click="openCreateModal">
<div class="add-icon">+</div>
<p class="add-label">{{ t('channels.addChannel') }}</p>
<!-- 添加渠道卡片 -->
<div class="channel-card add-card mc-surface-card" @click="openCreateModal">
<div class="add-icon">+</div>
<p class="add-label">{{ t('channels.addChannel') }}</p>
</div>
</div>
</div>
</div>
@ -1157,26 +1162,23 @@ function getChannelIconPath(type: string) {
</script>
<style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.channels-page { gap: 18px; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
/* 渠道卡片 */
.channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.channel-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 12px; padding: 16px; transition: all 0.15s; }
.channel-card:hover { border-color: var(--mc-primary-light); box-shadow: 0 4px 12px rgba(217,119,87,0.08); }
.channel-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 10px; }
.channel-icon-wrap { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; }
.channel-icon-img { width: 40px; height: 40px; border-radius: 10px; object-fit: cover; }
.channel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
.channel-card { padding: 20px; transition: all 0.15s; min-height: 238px; display: flex; flex-direction: column; }
.channel-card:hover { border-color: var(--mc-primary-light); box-shadow: var(--mc-shadow-medium); transform: translateY(-2px); }
.channel-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.channel-icon-wrap { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; background: linear-gradient(135deg, rgba(217,109,87,0.12), rgba(24,74,69,0.08)); }
.channel-icon-img { width: 42px; height: 42px; border-radius: 12px; object-fit: cover; }
.channel-meta { flex: 1; }
.channel-name { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 2px; }
.channel-name { font-size: 16px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 2px; }
.channel-type { font-size: 12px; color: var(--mc-text-tertiary); }
.channel-status { padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
@ -1190,13 +1192,13 @@ function getChannelIconPath(type: string) {
.conn-disconnected { color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
@keyframes pulse-reconnecting { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.channel-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 14px; line-height: 1.5; }
.channel-footer { display: flex; gap: 6px; border-top: 1px solid var(--mc-border-light); padding-top: 12px; }
.card-btn { display: flex; align-items: center; gap: 4px; padding: 5px 10px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 6px; font-size: 12px; color: var(--mc-text-primary); cursor: pointer; transition: all 0.15s; }
.channel-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 14px; line-height: 1.6; min-height: 42px; }
.channel-footer { display: flex; gap: 6px; border-top: 1px solid var(--mc-border-light); padding-top: 12px; margin-top: auto; flex-wrap: wrap; }
.card-btn { display: flex; align-items: center; gap: 4px; padding: 7px 11px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 10px; font-size: 12px; color: var(--mc-text-primary); cursor: pointer; transition: all 0.15s; font-weight: 600; }
.card-btn:hover { background: var(--mc-bg-sunken); }
.card-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
.add-card { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 160px; border: 2px dashed var(--mc-border); cursor: pointer; background: transparent; }
.add-card { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 238px; border: 2px dashed var(--mc-border); cursor: pointer; background: transparent; }
.add-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.add-icon { font-size: 28px; color: var(--mc-text-tertiary); margin-bottom: 8px; }
.add-label { font-size: 14px; color: var(--mc-text-tertiary); }

View File

@ -1,9 +1,11 @@
<template>
<div class="chat-layout">
<!-- 移动端会话面板遮罩 -->
<Transition name="fade">
<div v-if="isMobile && convPanelOpen" class="conv-backdrop" @click="convPanelOpen = false"></div>
</Transition>
<div class="mc-page-shell chat-console-shell">
<div class="mc-page-frame chat-console-frame">
<div class="chat-layout mc-surface-card">
<!-- 移动端会话面板遮罩 -->
<Transition name="fade">
<div v-if="isMobile && convPanelOpen" class="conv-backdrop" @click="convPanelOpen = false"></div>
</Transition>
<!-- 会话侧边栏 -->
<div class="conversation-panel" :class="{ 'mobile-open': convPanelOpen }">
@ -191,13 +193,15 @@
/>
</div>
<!-- Talk Mode 覆盖层 -->
<TalkMode
:visible="showTalkMode"
:agent-id="selectedAgentId"
:conversation-id="currentConversationId"
@close="showTalkMode = false"
/>
<!-- Talk Mode 覆盖层 -->
<TalkMode
:visible="showTalkMode"
:agent-id="selectedAgentId"
:conversation-id="currentConversationId"
@close="showTalkMode = false"
/>
</div>
</div>
</div>
</template>
@ -1131,17 +1135,26 @@ function handleCodeCopy(e: MouseEvent) {
</script>
<style scoped>
.chat-console-shell {
background: transparent;
}
.chat-console-frame {
height: calc(100vh - 28px);
}
.chat-layout {
display: flex;
height: 100%;
overflow: hidden;
min-height: 0;
}
.conversation-panel {
width: 260px;
min-width: 260px;
background: var(--mc-bg-elevated);
border-right: 1px solid var(--mc-border);
background: linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
border-right: 1px solid var(--mc-border-light);
display: flex;
flex-direction: column;
overflow: hidden;
@ -1166,8 +1179,8 @@ function handleCodeCopy(e: MouseEvent) {
width: 28px;
height: 28px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 6px;
background: var(--mc-panel-raised);
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
@ -1224,7 +1237,7 @@ function handleCodeCopy(e: MouseEvent) {
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 6px;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
@ -1316,7 +1329,7 @@ function handleCodeCopy(e: MouseEvent) {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--mc-chat-bg);
background: linear-gradient(180deg, var(--mc-chat-header-bg), var(--mc-chat-bg));
position: relative;
}
@ -1361,9 +1374,10 @@ function handleCodeCopy(e: MouseEvent) {
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--mc-chat-header-bg);
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
border-bottom: 1px solid var(--mc-border);
min-height: 52px;
backdrop-filter: blur(12px);
}
.chat-header-right {
@ -1419,8 +1433,8 @@ function handleCodeCopy(e: MouseEvent) {
width: 32px;
height: 32px;
border: 1px solid var(--mc-border);
background: var(--mc-bg-elevated);
border-radius: 6px;
background: var(--mc-panel-raised);
border-radius: 10px;
cursor: pointer;
display: flex;
align-items: center;
@ -1461,10 +1475,10 @@ function handleCodeCopy(e: MouseEvent) {
.btn-primary {
padding: 8px 16px;
background: var(--mc-primary);
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover));
color: white;
border: none;
border-radius: 8px;
border-radius: 12px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
@ -1485,6 +1499,11 @@ function handleCodeCopy(e: MouseEvent) {
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.chat-console-frame {
height: auto;
min-height: calc(100vh - 28px);
}
.conversation-panel {
position: fixed;
left: 0;

View File

@ -1,97 +1,112 @@
<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>
<div class="mc-page-shell dashboard-shell">
<div class="mc-page-frame">
<div class="mc-page-inner">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Operations Pulse</div>
<h1 class="mc-page-title">{{ t('dashboard.title') }}</h1>
<p class="mc-page-desc">{{ t('dashboard.desc') }}</p>
</div>
<div class="hero-note mc-surface-card">
<div class="hero-note__label">Today</div>
<div class="hero-note__value">{{ formatTokens(todayStats.totalTokens) }}</div>
<div class="hero-note__meta">{{ t('dashboard.tokens') }} · {{ todayStats.toolCalls }} {{ t('dashboard.toolCalls') }}</div>
</div>
</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 class="stats-grid">
<div class="stat-card mc-surface-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 mc-surface-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 mc-surface-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 mc-surface-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>
</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 class="comparison-section">
<div class="section-head">
<h2 class="section-title">{{ t('dashboard.periodComparison') }}</h2>
<p class="section-subtitle">A sharper view of how your system behaves across short, medium, and monthly horizons.</p>
</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 class="comparison-grid">
<div class="comparison-card mc-surface-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>
</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 class="runs-section">
<div class="section-head">
<h2 class="section-title">{{ t('dashboard.recentRuns') }}</h2>
<p class="section-subtitle">Execution should feel legible. If it runs, you should see its rhythm, cost, and outcome instantly.</p>
</div>
<div class="runs-table-wrapper mc-surface-card">
<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>
</div>
</div>
@ -151,49 +166,98 @@ function calcDuration(run: any): string {
</script>
<style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.dashboard-shell {
background: transparent;
}
.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; }
.hero-note {
min-width: 220px;
padding: 18px 20px;
}
/* Stats Grid */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px; }
.hero-note__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--mc-accent);
margin-bottom: 10px;
}
.hero-note__value {
font-size: 34px;
font-weight: 800;
letter-spacing: -0.05em;
color: var(--mc-text-primary);
}
.hero-note__meta {
margin-top: 8px;
color: var(--mc-text-secondary);
font-size: 13px;
line-height: 1.5;
}
.section-head {
margin-bottom: 16px;
}
.section-title {
font-size: 18px;
font-weight: 700;
color: var(--mc-text-primary);
letter-spacing: -0.03em;
margin: 0 0 4px;
}
.section-subtitle {
color: var(--mc-text-secondary);
font-size: 13px;
line-height: 1.6;
}
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 36px; }
.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;
padding: 22px;
}
.stat-icon {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: linear-gradient(135deg, rgba(217, 109, 70, 0.12), rgba(24, 74, 69, 0.08));
font-size: 25px;
}
.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; }
.stat-value { font-size: 30px; font-weight: 800; color: var(--mc-text-primary); line-height: 1; letter-spacing: -0.05em; }
.stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.08em; }
/* 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-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.comparison-card {
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light);
border-radius: 10px; padding: 16px;
padding: 18px 18px 12px;
}
.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-title { font-size: 12px; font-weight: 700; color: var(--mc-accent); margin: 0 0 12px; text-transform: uppercase; letter-spacing: 0.09em; }
.comparison-row { display: flex; justify-content: space-between; padding: 10px 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); }
.comparison-value { font-size: 14px; font-weight: 700; 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-wrapper { 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);
background: var(--mc-bg-muted); 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; }
.runs-table tbody tr:hover { background: rgba(217, 109, 70, 0.04); }
.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); }
@ -206,9 +270,13 @@ function calcDuration(run: any): string {
.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; }
.empty-state { padding: 48px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
@media (max-width: 768px) {
.hero-note {
width: 100%;
min-width: 0;
}
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.comparison-grid { grid-template-columns: 1fr; }
}

View File

@ -1,21 +1,29 @@
<template>
<div class="settings-layout">
<div class="settings-nav">
<h2 class="nav-title">{{ t('security.title') }}</h2>
<router-link
v-for="section in sections"
:key="section.id"
:to="section.path"
class="nav-item"
:class="{ active: isActive(section.path) }"
>
<span class="nav-icon" v-html="section.icon"></span>
{{ section.label }}
</router-link>
</div>
<div class="mc-page-shell security-shell">
<div class="mc-page-frame">
<div class="mc-page-inner settings-layout">
<div class="settings-nav mc-surface-card">
<div class="settings-nav__intro">
<div class="mc-page-kicker">Governance</div>
<h2 class="nav-title">{{ t('security.title') }}</h2>
<p class="nav-desc">This is where the product becomes trustworthy: boundaries, approvals, audit, and operational truth.</p>
</div>
<router-link
v-for="section in sections"
:key="section.id"
:to="section.path"
class="nav-item"
:class="{ active: isActive(section.path) }"
>
<span class="nav-icon" v-html="section.icon"></span>
{{ section.label }}
</router-link>
</div>
<div class="settings-content">
<router-view />
<div class="settings-content mc-surface-card">
<router-view />
</div>
</div>
</div>
</div>
</template>
@ -47,18 +55,24 @@ 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',
label: t('security.sections.members', 'Members'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
},
{
id: 'workspaces',
path: '/security/workspaces',
label: t('security.sections.workspaces', 'Workspaces'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></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>',
},
])
function isActive(path: string) {
@ -67,51 +81,68 @@ function isActive(path: string) {
</script>
<style scoped>
.security-shell {
background: transparent;
}
.settings-layout {
display: flex;
height: 100%;
overflow: hidden;
min-height: calc(100vh - 120px);
gap: 20px;
}
.settings-nav {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--mc-border-light);
padding: 20px 12px;
width: 270px;
min-width: 270px;
padding: 18px 14px;
overflow-y: auto;
align-self: flex-start;
}
.settings-nav__intro {
padding: 6px 8px 16px;
}
.nav-title {
font-size: 18px;
font-weight: 700;
font-size: 28px;
font-weight: 800;
color: var(--mc-text-primary);
margin: 0 0 16px 4px;
letter-spacing: -0.04em;
margin: 0 0 6px;
}
.nav-desc {
color: var(--mc-text-secondary);
font-size: 13px;
line-height: 1.65;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
width: 100%;
padding: 8px 12px;
padding: 10px 12px;
border: none;
background: transparent;
color: var(--mc-text-secondary);
font-size: 14px;
border-radius: 6px;
border-radius: 14px;
cursor: pointer;
text-align: left;
text-decoration: none;
margin-bottom: 2px;
font-weight: 500;
}
.nav-item:hover { background: var(--mc-bg-hover); color: var(--mc-text-primary); }
.nav-item.active { background: var(--mc-sidebar-active); color: var(--mc-text-primary); font-weight: 500; }
.nav-item:hover { background: var(--mc-bg-muted); color: var(--mc-text-primary); }
.nav-item.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 600; box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08); }
.nav-icon { display: flex; align-items: center; flex-shrink: 0; }
.settings-content {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
min-height: 720px;
}
</style>

View File

@ -0,0 +1,313 @@
<template>
<div class="settings-section">
<div class="section-header">
<div>
<h2 class="section-title">{{ t('security.workspaces.title') }}</h2>
<p class="section-desc">{{ t('security.workspaces.desc') }}</p>
</div>
<button class="btn-primary" @click="openCreateDialog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('security.workspaces.newWorkspace') }}
</button>
</div>
<!-- Workspaces Table -->
<div class="rules-table-wrapper">
<div v-if="loading" class="empty-state">{{ t('security.workspaces.loading') }}</div>
<div v-else-if="workspaces.length === 0" class="empty-state">{{ t('security.workspaces.noWorkspaces') }}</div>
<table v-else class="rules-table">
<thead>
<tr>
<th>{{ t('security.workspaces.columns.name') }}</th>
<th>{{ t('security.workspaces.columns.slug') }}</th>
<th>{{ t('security.workspaces.columns.description') }}</th>
<th>{{ t('security.workspaces.columns.created') }}</th>
<th style="width: 120px;">{{ t('security.workspaces.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="ws in workspaces" :key="ws.id" :class="{ 'current-ws': ws.id === currentWorkspaceId }">
<td>
<div class="ws-name-cell">
<span class="ws-name">{{ ws.name }}</span>
<span v-if="ws.id === currentWorkspaceId" class="ws-badge">{{ t('security.workspaces.current') }}</span>
</div>
</td>
<td class="slug-cell">{{ ws.slug }}</td>
<td class="desc-cell">{{ ws.description || '-' }}</td>
<td class="date-cell">{{ formatDate(ws.createTime) }}</td>
<td>
<div class="action-btns">
<button class="action-btn" @click="openEditDialog(ws)" :title="t('security.workspaces.actions.edit')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button
v-if="ws.slug !== 'default'"
class="action-btn danger"
@click="confirmDelete(ws)"
:title="t('security.workspaces.actions.delete')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14H7L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4h6v2"/>
</svg>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create / Edit Dialog -->
<Teleport to="body">
<div v-if="showDialog" class="modal-overlay" @click.self="showDialog = false">
<div class="modal">
<div class="modal-header">
<h3>{{ editingWs ? t('security.workspaces.editDialog.title') : t('security.workspaces.createDialog.title') }}</h3>
<button class="modal-close" @click="showDialog = false">&times;</button>
</div>
<div class="modal-body">
<div class="form-grid" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>{{ t('security.workspaces.createDialog.name') }} <span class="required">*</span></label>
<input v-model="form.name" class="form-input" :placeholder="t('security.workspaces.createDialog.namePlaceholder')" @input="autoSlug" />
</div>
<div class="form-group">
<label>{{ t('security.workspaces.createDialog.slug') }} <span class="required">*</span></label>
<input
v-model="form.slug"
class="form-input mono"
:placeholder="t('security.workspaces.createDialog.slugPlaceholder')"
:disabled="!!editingWs"
/>
<span v-if="editingWs" class="form-hint">{{ t('security.workspaces.createDialog.slugHint') }}</span>
</div>
<div class="form-group">
<label>{{ t('security.workspaces.createDialog.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('security.workspaces.createDialog.descriptionPlaceholder')" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showDialog = false">{{ t('security.workspaces.actions.cancel') }}</button>
<button class="btn-primary" @click="saveWorkspace" :disabled="!form.name || !form.slug">
{{ editingWs ? t('security.workspaces.actions.save') : t('security.workspaces.actions.create') }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Delete Confirmation -->
<Teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
<div class="modal">
<div class="modal-header">
<h3>{{ t('security.workspaces.deleteDialog.title') }}</h3>
<button class="modal-close" @click="showDeleteConfirm = false">&times;</button>
</div>
<div class="modal-body">
<p class="delete-warning">
{{ t('security.workspaces.deleteDialog.confirm', { name: deletingWs?.name }) }}
</p>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showDeleteConfirm = false">{{ t('security.workspaces.actions.cancel') }}</button>
<button class="btn-primary btn-danger-fill" @click="deleteWorkspace">{{ t('security.workspaces.actions.delete') }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { workspaceTeamApi } from '@/api/index'
import { useWorkspaceStore, type Workspace } from '@/stores/useWorkspaceStore'
const { t } = useI18n()
const wsStore = useWorkspaceStore()
const currentWorkspaceId = computed(() => wsStore.currentWorkspaceId)
const workspaces = ref<Workspace[]>([])
const loading = ref(false)
const showDialog = ref(false)
const showDeleteConfirm = ref(false)
const editingWs = ref<Workspace | null>(null)
const deletingWs = ref<Workspace | null>(null)
const form = ref({
name: '',
slug: '',
description: '',
})
onMounted(() => {
fetchWorkspaces()
})
async function fetchWorkspaces() {
loading.value = true
try {
const res: any = await workspaceTeamApi.list()
workspaces.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message || 'Failed to fetch workspaces')
} finally {
loading.value = false
}
}
function openCreateDialog() {
editingWs.value = null
form.value = { name: '', slug: '', description: '' }
showDialog.value = true
}
function openEditDialog(ws: Workspace) {
editingWs.value = ws
form.value = {
name: ws.name,
slug: ws.slug,
description: ws.description || '',
}
showDialog.value = true
}
function autoSlug() {
if (!editingWs.value) {
form.value.slug = form.value.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
}
async function saveWorkspace() {
try {
if (editingWs.value) {
await workspaceTeamApi.update(editingWs.value.id, {
name: form.value.name,
description: form.value.description,
})
} else {
await workspaceTeamApi.create({
name: form.value.name,
slug: form.value.slug,
description: form.value.description,
})
}
showDialog.value = false
ElMessage.success(t('security.workspaces.messages.saveSuccess'))
await fetchWorkspaces()
wsStore.fetchWorkspaces()
} catch (e: any) {
ElMessage.error(t('security.workspaces.messages.saveFailed'))
}
}
function confirmDelete(ws: Workspace) {
deletingWs.value = ws
showDeleteConfirm.value = true
}
async function deleteWorkspace() {
if (!deletingWs.value) return
try {
await workspaceTeamApi.delete(deletingWs.value.id)
showDeleteConfirm.value = false
deletingWs.value = null
ElMessage.success(t('security.workspaces.messages.deleteSuccess'))
await fetchWorkspaces()
wsStore.fetchWorkspaces()
} catch (e: any) {
ElMessage.error(t('security.workspaces.messages.deleteFailed'))
}
}
function formatDate(dateStr?: string) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString()
}
</script>
<style>
@import '../shared.css';
</style>
<style scoped>
.current-ws {
background: rgba(217, 119, 87, 0.06);
}
.ws-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.ws-name { font-weight: 500; }
.ws-badge {
font-size: 11px;
padding: 2px 8px;
background: var(--mc-primary, #D97757);
color: #fff;
border-radius: 10px;
font-weight: 500;
}
.slug-cell {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--mc-text-secondary);
}
.desc-cell {
color: var(--mc-text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-cell {
color: var(--mc-text-tertiary);
font-size: 13px;
}
.required { color: var(--mc-danger, #ef4444); }
.form-hint {
font-size: 12px;
color: var(--mc-text-tertiary);
margin-top: 4px;
}
.delete-warning {
font-size: 14px;
color: var(--mc-text-primary);
line-height: 1.6;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-danger-fill {
background: var(--mc-danger, #ef4444) !important;
}
.btn-danger-fill:hover { opacity: 0.9; }
</style>

View File

@ -1,69 +1,375 @@
<template>
<div class="settings-section about-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.aboutTitle') }}</h2>
<p class="section-desc">{{ t('settings.aboutDesc') }}</p>
</div>
<div class="settings-card">
<div class="about-info">
<div class="about-page">
<section class="about-hero mc-surface-card">
<div class="about-hero__brand">
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="about-logo" />
<h3 class="about-name">Mate<span class="about-name-highlight">Claw</span></h3>
<p class="about-version">Version 1.0.0</p>
<p class="about-desc">Java + Vue AI agent platform powered by Spring AI Alibaba.</p>
<div class="about-badge">v{{ appVersion }}</div>
</div>
<div class="tech-stack">
<div class="about-hero__content">
<div class="hero-kicker">{{ t('settings.about.heroKicker') }}</div>
<h2 class="hero-title">{{ t('settings.about.heroTitle') }}</h2>
<p class="hero-desc">{{ t('settings.about.heroDesc') }}</p>
<div class="hero-pillars">
<div v-for="pillar in pillars" :key="pillar.title" class="pillar-card">
<div class="pillar-icon">{{ pillar.icon }}</div>
<div class="pillar-title">{{ pillar.title }}</div>
<div class="pillar-desc">{{ pillar.desc }}</div>
</div>
</div>
</div>
</section>
<section class="about-grid">
<div class="about-manifesto mc-surface-card">
<div class="section-kicker">{{ t('settings.about.manifestoKicker') }}</div>
<h3 class="section-title">{{ t('settings.about.manifestoTitle') }}</h3>
<p class="section-desc">{{ t('settings.about.manifestoDesc') }}</p>
<div class="manifesto-list">
<div v-for="item in manifesto" :key="item.title" class="manifesto-item">
<div class="manifesto-title">{{ item.title }}</div>
<div class="manifesto-desc">{{ item.desc }}</div>
</div>
</div>
</div>
<div class="about-system mc-surface-card">
<div class="section-kicker">{{ t('settings.about.systemKicker') }}</div>
<h3 class="section-title">{{ t('settings.about.systemTitle') }}</h3>
<p class="section-desc">{{ t('settings.about.systemDesc') }}</p>
<div class="system-list">
<div v-for="item in systemBlocks" :key="item.title" class="system-item">
<div class="system-title">{{ item.title }}</div>
<div class="system-desc">{{ item.desc }}</div>
</div>
</div>
</div>
</section>
<section class="about-foundation mc-surface-card">
<div class="foundation-copy">
<div class="section-kicker">{{ t('settings.about.foundationKicker') }}</div>
<h3 class="section-title">{{ t('settings.about.foundationTitle') }}</h3>
<p class="section-desc">{{ t('settings.about.foundationDesc') }}</p>
</div>
<div class="tech-grid">
<div class="tech-item" v-for="tech in techStack" :key="tech.name">
<span class="tech-icon">{{ tech.icon }}</span>
<div>
<div class="tech-icon">{{ tech.icon }}</div>
<div class="tech-meta">
<div class="tech-name">{{ tech.name }}</div>
<div class="tech-version">{{ tech.version }}</div>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { version as appVersion } from '../../../../package.json'
const { t } = useI18n()
const pillars = computed(() => [
{
icon: '01',
title: t('settings.about.pillars.contextTitle'),
desc: t('settings.about.pillars.contextDesc'),
},
{
icon: '02',
title: t('settings.about.pillars.executionTitle'),
desc: t('settings.about.pillars.executionDesc'),
},
{
icon: '03',
title: t('settings.about.pillars.memoryTitle'),
desc: t('settings.about.pillars.memoryDesc'),
},
])
const manifesto = computed(() => [
{
title: t('settings.about.manifestoItems.runtimeTitle'),
desc: t('settings.about.manifestoItems.runtimeDesc'),
},
{
title: t('settings.about.manifestoItems.knowledgeTitle'),
desc: t('settings.about.manifestoItems.knowledgeDesc'),
},
{
title: t('settings.about.manifestoItems.multimodalTitle'),
desc: t('settings.about.manifestoItems.multimodalDesc'),
},
])
const systemBlocks = computed(() => [
{
title: t('settings.about.systemItems.workspaceTitle'),
desc: t('settings.about.systemItems.workspaceDesc'),
},
{
title: t('settings.about.systemItems.governanceTitle'),
desc: t('settings.about.systemItems.governanceDesc'),
},
{
title: t('settings.about.systemItems.deliveryTitle'),
desc: t('settings.about.systemItems.deliveryDesc'),
},
])
const techStack = [
{ icon: '☕', name: 'Spring Boot', version: '3.3.x' },
{ icon: '🤖', name: 'Spring AI', version: '1.1.x' },
{ icon: '🌿', name: 'Spring AI Alibaba', version: '1.1.x' },
{ icon: '💚', name: 'Vue 3', version: '3.5.x' },
{ icon: '⚡', name: 'Vite', version: '6.x' },
{ icon: '⚡', name: 'Vite', version: '7.x' },
{ icon: '🗄️', name: 'MyBatis Plus', version: '3.5.x' },
]
</script>
<style scoped>
.settings-section { width: 100%; }
.settings-section.about-section { max-width: none; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card {
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px;
box-shadow: 0 8px 24px rgba(124, 63, 30, 0.04); width: 100%;
display: flex; flex-direction: column; align-items: center; gap: 20px;
.about-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.about-info { text-align: center; margin-bottom: 0; padding: 12px 8px; display: flex; flex-direction: column; align-items: center; }
.about-logo { width: 80px; height: 80px; object-fit: contain; filter: drop-shadow(0 6px 16px rgba(217, 119, 87, 0.3)); }
.about-name { margin: 12px 0 4px; font-size: 24px; color: var(--mc-text-primary); }
.about-name-highlight { color: var(--mc-primary); }
.about-version, .about-desc { margin: 0; color: var(--mc-text-secondary); }
.tech-stack { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; width: 100%; }
.tech-item { display: flex; gap: 10px; align-items: center; padding: 14px; background: var(--mc-bg-sunken); border-radius: 12px; }
.tech-icon { font-size: 20px; }
.tech-name { font-weight: 600; color: var(--mc-text-primary); }
.tech-version { font-size: 13px; color: var(--mc-text-secondary); }
@media (max-width: 900px) {
.settings-card { grid-template-columns: 1fr; }
.about-hero {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 24px;
padding: 28px;
overflow: hidden;
position: relative;
}
.about-hero::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at top left, rgba(217, 109, 70, 0.14), transparent 34%),
radial-gradient(circle at bottom right, rgba(24, 74, 69, 0.14), transparent 38%);
pointer-events: none;
}
.about-hero__brand,
.about-hero__content {
position: relative;
z-index: 1;
}
.about-hero__brand {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 18px;
border-radius: 24px;
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
box-shadow: inset 0 0 0 1px var(--mc-border-light);
}
.about-logo {
width: 110px;
height: 110px;
object-fit: contain;
filter: drop-shadow(0 12px 28px rgba(217, 109, 70, 0.28));
}
.about-badge {
padding: 8px 12px;
border-radius: 999px;
background: rgba(217, 109, 70, 0.12);
color: var(--mc-primary);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-kicker,
.section-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mc-accent);
}
.hero-title,
.section-title {
margin: 10px 0 0;
color: var(--mc-text-primary);
letter-spacing: -0.05em;
}
.hero-title {
font-size: clamp(34px, 5vw, 52px);
line-height: 0.96;
max-width: 760px;
}
.hero-desc,
.section-desc {
margin: 14px 0 0;
color: var(--mc-text-secondary);
line-height: 1.72;
}
.hero-desc {
max-width: 760px;
font-size: 15px;
}
.hero-pillars {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 24px;
}
.pillar-card {
padding: 18px;
border-radius: 20px;
background: var(--mc-panel-raised);
border: 1px solid var(--mc-border-light);
box-shadow: 0 10px 30px rgba(124, 63, 30, 0.08);
}
.pillar-icon {
color: var(--mc-primary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.pillar-title,
.manifesto-title,
.system-title,
.tech-name {
color: var(--mc-text-primary);
font-weight: 700;
}
.pillar-title {
margin-top: 14px;
font-size: 15px;
}
.pillar-desc,
.manifesto-desc,
.system-desc,
.tech-version {
margin-top: 8px;
color: var(--mc-text-secondary);
font-size: 13px;
line-height: 1.65;
}
.about-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 20px;
}
.about-manifesto,
.about-system,
.about-foundation {
padding: 24px;
}
.section-title {
font-size: 28px;
}
.manifesto-list,
.system-list {
display: grid;
gap: 14px;
margin-top: 22px;
}
.manifesto-item,
.system-item {
padding: 18px;
border-radius: 18px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
}
.about-foundation {
display: grid;
grid-template-columns: 1fr 1.1fr;
gap: 24px;
align-items: start;
}
.tech-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.tech-item {
display: flex;
gap: 12px;
align-items: center;
padding: 16px;
border-radius: 18px;
background: linear-gradient(180deg, var(--mc-panel-raised), var(--mc-surface-overlay));
border: 1px solid var(--mc-border-light);
}
.tech-icon {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgba(217, 109, 70, 0.1);
font-size: 19px;
flex-shrink: 0;
}
@media (max-width: 1080px) {
.about-hero,
.about-foundation,
.about-grid {
grid-template-columns: 1fr;
}
.about-hero__brand {
max-width: 320px;
}
}
@media (max-width: 760px) {
.about-hero {
padding: 22px;
}
.hero-pillars,
.tech-grid {
grid-template-columns: 1fr;
}
.hero-title {
font-size: 34px;
}
.section-title {
font-size: 24px;
}
}
</style>

View File

@ -1,23 +1,31 @@
<template>
<div class="settings-layout">
<div class="settings-nav">
<h2 class="nav-title">{{ t('settings.title') }}</h2>
<template v-for="section in sections" :key="section.id">
<div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div>
<router-link
v-else
:to="section.path"
class="nav-item"
:class="{ active: isActive(section.path) }"
>
<span class="nav-icon" v-html="section.icon"></span>
{{ section.label }}
</router-link>
</template>
</div>
<div class="mc-page-shell settings-shell">
<div class="mc-page-frame">
<div class="mc-page-inner settings-layout">
<div class="settings-nav mc-surface-card">
<div class="settings-nav__intro">
<div class="mc-page-kicker">System</div>
<h2 class="nav-title">{{ t('settings.title') }}</h2>
<p class="nav-desc">Tune the machine, shape the context, and keep the product coherent as it grows.</p>
</div>
<template v-for="section in sections" :key="section.id">
<div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div>
<router-link
v-else
:to="section.path"
class="nav-item"
:class="{ active: isActive(section.path) }"
>
<span class="nav-icon" v-html="section.icon"></span>
{{ section.label }}
</router-link>
</template>
</div>
<div class="settings-content">
<router-view />
<div class="settings-content mc-surface-card">
<router-view />
</div>
</div>
</div>
</div>
</template>
@ -76,9 +84,9 @@ const sections = computed(() => [
// Divider: Advanced
{ id: 'divider-advanced', path: '', label: t('settings.sections.advanced'), icon: '', isDivider: true },
{
id: 'workspace',
path: '/settings/workspace',
label: t('nav.workspace'),
id: 'agent-context',
path: '/settings/agent-context',
label: t('nav.agentContext', 'Agent Context'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
},
{
@ -119,20 +127,27 @@ function isActive(path: string) {
</script>
<style scoped>
.settings-layout { display: flex; height: 100%; overflow: hidden; background: var(--mc-bg); }
.settings-nav { width: 220px; min-width: 220px; background: var(--mc-bg-elevated); border-right: 1px solid var(--mc-border); padding: 20px 12px; }
.nav-title { font-size: 13px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; padding: 0 8px; margin: 0 0 12px; }
.nav-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 12px; border: none; background: none; border-radius: 6px; font-size: 14px; font-weight: 400; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; text-align: left; text-decoration: none; }
.nav-item:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.nav-item.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 500; }
.settings-shell {
background: transparent;
}
.settings-layout { display: flex; min-height: calc(100vh - 120px); gap: 20px; }
.settings-nav { width: 270px; min-width: 270px; padding: 18px 14px; align-self: flex-start; }
.settings-nav__intro { padding: 6px 8px 16px; }
.nav-title { font-size: 28px; font-weight: 800; color: var(--mc-text-primary); letter-spacing: -0.04em; margin: 0 0 6px; }
.nav-desc { color: var(--mc-text-secondary); font-size: 13px; line-height: 1.65; }
.nav-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; border: none; background: none; border-radius: 14px; font-size: 14px; font-weight: 500; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; text-align: left; text-decoration: none; }
.nav-item:hover { background: var(--mc-bg-muted); color: var(--mc-text-primary); }
.nav-item.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 600; box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08); }
.nav-item + .nav-item { margin-top: 2px; }
.nav-icon { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.nav-icon :deep(svg) { width: 18px; height: 18px; display: block; }
.nav-divider { font-size: 11px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; padding: 16px 8px 6px; margin-top: 4px; }
.settings-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; }
.nav-divider { font-size: 11px; font-weight: 700; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.1em; padding: 18px 8px 8px; margin-top: 4px; }
.settings-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; min-height: 720px; }
@media (max-width: 900px) {
.settings-layout { flex-direction: column; }
.settings-nav { width: 100%; min-width: 100%; border-right: none; border-bottom: 1px solid var(--mc-border); }
.settings-nav { width: 100%; min-width: 100%; }
.settings-content { min-height: 0; }
}
</style>

View File

@ -1,21 +1,24 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('nav.wiki') }}</h1>
<p class="page-desc">{{ t('wiki.desc') }}</p>
</div>
<button class="btn-primary" @click="showCreateKB = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('wiki.createKB') }}
</button>
</div>
<div class="mc-page-shell wiki-shell">
<div class="mc-page-frame">
<div class="mc-page-inner">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Knowledge Engine</div>
<h1 class="mc-page-title">{{ t('nav.wiki') }}</h1>
<p class="mc-page-desc">{{ t('wiki.desc') }}</p>
</div>
<button class="btn-primary page-cta" @click="showCreateKB = true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
{{ t('wiki.createKB') }}
</button>
</div>
<div class="wiki-layout">
<div class="wiki-layout">
<!-- Left: Knowledge Base List -->
<div class="wiki-sidebar">
<div class="wiki-sidebar mc-surface-card">
<div class="sidebar-section">
<h3 class="sidebar-title">{{ t('wiki.knowledgeBases') }}</h3>
<div v-if="store.loading" class="text-center py-4 text-gray-400">Loading...</div>
@ -39,7 +42,7 @@
</div>
<!-- Pages List when KB selected -->
<div v-if="store.currentKB" class="sidebar-section">
<div v-if="store.currentKB" class="sidebar-section sidebar-section--pages">
<h3 class="sidebar-title">
{{ t('wiki.pages') }}
<span class="text-xs text-gray-400">({{ store.pages.length }})</span>
@ -64,7 +67,7 @@
</div>
<!-- Right: Content Area -->
<div class="wiki-content">
<div class="wiki-content mc-surface-card">
<!-- No KB selected -->
<div v-if="!store.currentKB" class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="mx-auto mb-4 text-gray-400">
@ -105,6 +108,8 @@
</div>
</div>
</div>
</div>
</div>
<!-- Create KB Modal -->
<div v-if="showCreateKB" class="modal-overlay" @click.self="showCreateKB = false">
@ -182,39 +187,37 @@ onMounted(() => {
</script>
<style scoped>
/* Base styles */
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 20px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 4px; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.wiki-shell {
background: transparent;
}
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; cursor: pointer; }
.btn-secondary { padding: 8px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 12px; font-size: 14px; cursor: pointer; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
/* Layout */
.wiki-layout { display: flex; gap: 16px; height: calc(100vh - 180px); overflow: hidden; }
.wiki-layout { display: flex; gap: 18px; min-height: calc(100vh - 200px); overflow: hidden; }
.wiki-sidebar { width: 280px; min-width: 280px; overflow-y: auto; border-right: 1px solid var(--mc-border); padding-right: 16px; display: flex; flex-direction: column; gap: 16px; }
.wiki-sidebar { width: 320px; min-width: 320px; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 18px; }
.sidebar-section { display: flex; flex-direction: column; gap: 8px; }
.sidebar-section { display: flex; flex-direction: column; gap: 10px; }
.sidebar-title { font-size: 12px; font-weight: 600; text-transform: uppercase; color: var(--mc-text-secondary); letter-spacing: 0.05em; }
.sidebar-title { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--mc-text-tertiary); letter-spacing: 0.1em; }
.sidebar-search { width: 100%; padding: 6px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; }
.sidebar-search { width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border); border-radius: 12px; font-size: 13px; background: var(--mc-bg-muted); color: var(--mc-text-primary); outline: none; }
.sidebar-search:focus { border-color: var(--mc-primary); }
.kb-list, .page-list { display: flex; flex-direction: column; gap: 4px; }
.kb-item, .page-item { padding: 8px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s; position: relative; }
.kb-item:hover, .page-item:hover { background: var(--mc-sidebar-hover); }
.kb-item.active, .page-item.active { background: var(--mc-primary-bg); border-left: 2px solid var(--mc-primary); }
.kb-item, .page-item { padding: 12px 14px; border-radius: 16px; cursor: pointer; transition: background 0.15s, transform 0.15s; position: relative; border: 1px solid transparent; }
.kb-item:hover, .page-item:hover { background: var(--mc-bg-muted); transform: translateY(-1px); }
.kb-item.active, .page-item.active { background: var(--mc-primary-bg); border-color: rgba(217, 109, 70, 0.12); }
.kb-item-name { font-size: 14px; font-weight: 500; color: var(--mc-text-primary); }
.kb-item-name { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); margin-bottom: 4px; }
.kb-item-meta, .page-item-meta { font-size: 12px; color: var(--mc-text-secondary); display: flex; gap: 8px; }
.page-item-title { font-size: 13px; color: var(--mc-text-primary); }
.page-item-title { font-size: 13px; color: var(--mc-text-primary); font-weight: 600; }
.kb-status { position: absolute; right: 8px; top: 8px; font-size: 10px; padding: 2px 6px; border-radius: 9999px; text-transform: uppercase; font-weight: 500; }
.kb-status.active { background: rgba(90, 138, 90, 0.15); color: var(--mc-success); }
@ -222,16 +225,16 @@ onMounted(() => {
.kb-status.error { background: var(--mc-danger-bg); color: var(--mc-danger); }
/* Content area */
.wiki-content { flex: 1; overflow-y: auto; min-width: 0; }
.wiki-content { flex: 1; overflow-y: auto; min-width: 0; padding: 20px; }
.content-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--mc-border); margin-bottom: 16px; }
.tab-btn { padding: 8px 16px; border: none; background: none; cursor: pointer; font-size: 14px; color: var(--mc-text-secondary); border-bottom: 2px solid transparent; transition: all 0.15s; }
.content-tabs { display: inline-flex; gap: 4px; padding: 4px; background: var(--mc-bg-muted); border-radius: 16px; margin-bottom: 20px; border: 1px solid var(--mc-border-light); }
.tab-btn { padding: 10px 16px; border: none; background: none; cursor: pointer; font-size: 14px; color: var(--mc-text-secondary); border-radius: 12px; transition: all 0.15s; font-weight: 600; }
.tab-btn:hover { color: var(--mc-text-primary); }
.tab-btn.active { color: var(--mc-primary); border-bottom-color: var(--mc-primary); }
.tab-btn.active { color: var(--mc-primary); background: var(--mc-bg-elevated); box-shadow: var(--mc-shadow-soft); }
.tab-content { min-height: 400px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 300px; color: var(--mc-text-tertiary); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 300px; color: var(--mc-text-tertiary); text-align: center; padding: 32px; }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
@ -245,4 +248,15 @@ onMounted(() => {
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
@media (max-width: 980px) {
.wiki-layout {
flex-direction: column;
}
.wiki-sidebar {
width: 100%;
min-width: 0;
}
}
</style>

View File

@ -59,41 +59,123 @@
<!-- 底部 -->
<div class="sidebar-footer">
<!-- Doctor 健康指示器 -->
<button class="health-indicator" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
<span class="health-dot"></span>
<span v-if="!sidebarCollapsed" class="health-label">{{ t('doctor.title') }}</span>
</button>
<!-- 主题切换 -->
<div class="theme-toggle-row">
<button
v-for="opt in themeOptions"
:key="opt.value"
class="theme-btn"
:class="{ active: themeStore.mode === opt.value }"
:title="opt.label"
@click="themeStore.setMode(opt.value)"
>
<span v-html="opt.icon"></span>
<span v-if="!sidebarCollapsed" class="theme-btn-label">{{ opt.label }}</span>
<template v-if="!sidebarCollapsed || isMobile">
<!-- Doctor 健康指示器 -->
<button class="health-indicator" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
<span class="health-dot"></span>
<span class="health-label">{{ t('doctor.title') }}</span>
</button>
</div>
<div class="user-info">
<div class="user-avatar">{{ userInitial }}</div>
<transition name="fade">
<div v-if="!sidebarCollapsed" class="user-detail">
<div class="sidebar-utility-section">
<div class="utility-label">{{ t('nav.themeLabel') }}</div>
<div class="theme-toggle-row">
<button
v-for="opt in themeOptions"
:key="opt.value"
class="theme-btn"
:class="{ active: themeStore.mode === opt.value }"
:title="opt.label"
@click="themeStore.setMode(opt.value)"
>
<span v-html="opt.icon"></span>
<span class="theme-btn-label">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="sidebar-utility-section">
<div class="utility-label">{{ t('nav.languageLabel') }}</div>
<div class="language-toggle-row">
<button
v-for="opt in localeOptions"
:key="opt.value"
class="language-btn"
:class="{ active: currentLocaleValue === opt.value }"
@click="changeLocale(opt.value)"
>
<span class="language-abbr">{{ opt.short }}</span>
<span class="language-label">{{ opt.label }}</span>
</button>
</div>
</div>
<div class="user-info">
<div class="user-avatar">{{ userInitial }}</div>
<div class="user-detail">
<div class="user-name">{{ username }}</div>
<div class="user-role">{{ roleLabel }}</div>
</div>
</transition>
<button v-if="!sidebarCollapsed" class="logout-btn" @click="logout" :title="t('nav.logout')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
<button class="logout-btn" @click="logout" :title="t('nav.logout')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</template>
<template v-else>
<div class="collapsed-footer-actions">
<button class="footer-icon-btn" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
<span class="health-dot"></span>
</button>
<button class="footer-icon-btn footer-icon-btn--accent" :title="t('nav.appearance')" @click="footerPanelOpen = !footerPanelOpen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M12 4h9"/><path d="M4 9h16"/><path d="M4 15h16"/><circle cx="8" cy="4" r="2"/><circle cx="16" cy="20" r="2"/><circle cx="6" cy="15" r="2"/><circle cx="18" cy="9" r="2"/>
</svg>
</button>
</div>
<Transition name="fade">
<div v-if="footerPanelOpen" class="sidebar-utility-panel">
<div class="panel-section">
<div class="utility-label">{{ t('nav.themeLabel') }}</div>
<div class="panel-option-list">
<button
v-for="opt in themeOptions"
:key="opt.value"
class="panel-option-btn"
:class="{ active: themeStore.mode === opt.value }"
@click="themeStore.setMode(opt.value)"
>
<span class="panel-option-icon" v-html="opt.icon"></span>
<span>{{ opt.label }}</span>
</button>
</div>
</div>
<div class="panel-section">
<div class="utility-label">{{ t('nav.languageLabel') }}</div>
<div class="panel-option-list">
<button
v-for="opt in localeOptions"
:key="opt.value"
class="panel-option-btn"
:class="{ active: currentLocaleValue === opt.value }"
@click="changeLocale(opt.value)"
>
<span class="language-abbr">{{ opt.short }}</span>
<span>{{ opt.label }}</span>
</button>
</div>
</div>
<div class="panel-user">
<div class="user-avatar">{{ userInitial }}</div>
<div class="panel-user-meta">
<div class="user-name">{{ username }}</div>
<div class="user-role">{{ roleLabel }}</div>
</div>
<button class="logout-btn logout-btn--panel" @click="logout" :title="t('nav.logout')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
</Transition>
</template>
</div>
</aside>
@ -110,7 +192,7 @@
</button>
<span class="mobile-topbar-title">Mate<span class="logo-name-highlight">Claw</span></span>
</div>
<router-view />
<router-view :key="workspaceRouteKey" />
</main>
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
@ -119,22 +201,29 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useThemeStore } from '@/stores/useThemeStore'
import { version as appVersion } from '../../../package.json'
import type { ThemeMode } from '@/stores/useThemeStore'
import { http, setupApi } from '@/api/index'
import { http, settingsApi, setupApi } from '@/api/index'
import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { applyLocale, currentLocale, type AppLocale } from '@/i18n'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const themeStore = useThemeStore()
const workspaceStore = useWorkspaceStore()
const sidebarCollapsed = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
const footerPanelOpen = ref(false)
// Workspace key router-view hard reload
const workspaceRouteKey = computed(() => `ws-${workspaceStore.currentWorkspaceId ?? 'none'}`)
const showOnboarding = ref(false)
const showDoctor = ref(false)
const healthStatus = ref('unknown')
@ -161,6 +250,7 @@ let mobileQuery: MediaQueryList | null = null
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
isMobile.value = e.matches
if (!e.matches) mobileMenuOpen.value = false
if (e.matches) footerPanelOpen.value = false
}
onMounted(async () => {
@ -197,6 +287,7 @@ const role = computed(() => localStorage.getItem('role') || 'user')
const userInitial = computed(() => username.value.charAt(0).toUpperCase())
const roleLabel = computed(() => role.value === 'admin' ? t('nav.roleAdmin') : t('nav.roleUser'))
const sidebarToggleLabel = computed(() => sidebarCollapsed.value ? t('common.expandSidebar') : t('common.collapseSidebar'))
const currentLocaleValue = computed(() => currentLocale.value)
const themeOptions = computed<{ value: ThemeMode; label: string; icon: string }[]>(() => [
{
@ -216,6 +307,11 @@ const themeOptions = computed<{ value: ThemeMode; label: string; icon: string }[
},
])
const localeOptions = computed<{ value: AppLocale; label: string; short: string }[]>(() => [
{ value: 'zh-CN', label: t('settings.languageOptions.zhCN'), short: '中' },
{ value: 'en-US', label: t('settings.languageOptions.enUS'), short: 'EN' },
])
const navGroups = computed(() => [
{
key: 'core',
@ -285,6 +381,9 @@ const navGroups = computed(() => [
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
localStorage.setItem('mc-sidebar-collapsed', String(sidebarCollapsed.value))
if (!sidebarCollapsed.value) {
footerPanelOpen.value = false
}
}
function isNavItemActive(item: { path: string; label: string }) {
@ -303,6 +402,29 @@ function logout() {
localStorage.removeItem('role')
router.push('/login')
}
async function changeLocale(locale: AppLocale) {
applyLocale(locale)
footerPanelOpen.value = false
try {
await settingsApi.update({ language: locale })
} catch {
// keep local preference even if backend persistence fails
}
}
watch(() => route.fullPath, () => {
footerPanelOpen.value = false
if (isMobile.value) mobileMenuOpen.value = false
})
watch(() => sidebarCollapsed.value, (collapsed) => {
if (!collapsed) footerPanelOpen.value = false
})
watch(() => workspaceStore.currentWorkspaceId, () => {
footerPanelOpen.value = false
})
</script>
<style scoped>
@ -311,58 +433,91 @@ function logout() {
height: 100vh;
background: var(--mc-bg);
overflow: hidden;
position: relative;
}
.app-layout::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at top left, rgba(217, 109, 70, 0.12), transparent 22%),
radial-gradient(circle at bottom right, rgba(24, 74, 69, 0.08), transparent 18%);
pointer-events: none;
}
:global(html.dark) .app-layout::before {
background:
radial-gradient(circle at top left, rgba(235, 143, 101, 0.14), transparent 24%),
radial-gradient(circle at bottom right, rgba(92, 166, 157, 0.08), transparent 20%);
}
/* ===== 侧边栏 ===== */
.sidebar {
width: 220px;
min-width: 220px;
background: var(--mc-sidebar-bg);
border-right: 1px solid var(--mc-sidebar-border);
width: 236px;
min-width: 236px;
margin: 14px 0 14px 14px;
background:
linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
border: 1px solid var(--mc-sidebar-border);
border-radius: 28px;
box-shadow: var(--mc-shadow-soft);
display: flex;
flex-direction: column;
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
position: relative;
z-index: 1;
}
.sidebar.collapsed {
width: 56px;
min-width: 56px;
width: 74px;
min-width: 74px;
}
.sidebar::before {
content: '';
position: absolute;
inset: 0;
background: var(--mc-glow);
pointer-events: none;
}
.sidebar-logo {
display: flex;
align-items: center;
padding: 14px 12px;
padding: 18px 16px 14px;
border-bottom: 1px solid var(--mc-border-light);
gap: 10px;
min-height: 58px;
gap: 12px;
min-height: 72px;
}
.sidebar.collapsed .sidebar-logo {
flex-direction: column;
justify-content: center;
padding: 10px 6px;
padding: 14px 10px;
gap: 8px;
min-height: 88px;
min-height: 110px;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: 8px;
width: 42px;
height: 42px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
background: linear-gradient(135deg, rgba(217, 109, 70, 0.18), rgba(24, 74, 69, 0.08));
border: 1px solid rgba(217, 109, 70, 0.14);
}
.logo-img {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
object-fit: contain;
filter: drop-shadow(0 2px 6px rgba(217, 119, 87, 0.25));
filter: drop-shadow(0 8px 18px rgba(217, 109, 70, 0.22));
}
.logo-emoji { font-size: 16px; }
@ -371,10 +526,11 @@ function logout() {
.logo-name {
display: block;
font-size: 15px;
font-weight: 700;
font-size: 17px;
font-weight: 800;
color: var(--mc-sidebar-logo-name);
white-space: nowrap;
letter-spacing: -0.03em;
}
.logo-name-highlight {
@ -385,19 +541,21 @@ function logout() {
display: block;
font-size: 11px;
color: var(--mc-text-tertiary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collapse-btn {
width: 24px;
height: 24px;
border: none;
background: none;
width: 30px;
height: 30px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
cursor: pointer;
color: var(--mc-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
border-radius: 10px;
flex-shrink: 0;
padding: 0;
margin-left: auto;
@ -426,7 +584,7 @@ function logout() {
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 8px 0;
padding: 12px 0 8px;
}
.sidebar-nav::-webkit-scrollbar { width: 4px; }
@ -435,12 +593,12 @@ function logout() {
.nav-group { margin-bottom: 2px; }
.nav-group-title {
padding: 8px 16px 4px;
padding: 10px 18px 6px;
font-size: 11px;
font-weight: 600;
color: var(--mc-sidebar-group-title);
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.1em;
white-space: nowrap;
}
@ -448,12 +606,12 @@ function logout() {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
padding: 11px 12px;
color: var(--mc-sidebar-text);
text-decoration: none;
font-size: 14px;
border-radius: 6px;
margin: 1px 8px;
border-radius: 14px;
margin: 2px 10px;
transition: all 0.15s ease;
white-space: nowrap;
overflow: hidden;
@ -468,15 +626,16 @@ function logout() {
.nav-item.active {
background: var(--mc-sidebar-active);
color: var(--mc-sidebar-text-active);
font-weight: 500;
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(217, 109, 70, 0.08);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--mc-primary);
border-radius: 0 3px 3px 0;
@ -488,24 +647,36 @@ function logout() {
/* 底部 */
.sidebar-footer {
border-top: 1px solid var(--mc-border-light);
padding: 10px 12px;
padding: 14px 14px 16px;
background: var(--mc-sidebar-footer-bg);
backdrop-filter: blur(14px);
position: relative;
}
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: none; background: none; border-radius: 6px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 6px; }
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: 1px solid var(--mc-border-light); background: var(--mc-bg-muted); border-radius: 14px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 10px; }
.health-indicator:hover { background: var(--mc-bg-sunken); }
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.health-indicator.healthy .health-dot { background: var(--mc-success); }
.health-indicator.warning .health-dot { background: var(--mc-primary); }
.health-indicator.error .health-dot { background: var(--mc-danger); }
.health-indicator.unknown .health-dot { background: var(--mc-text-tertiary); }
.sidebar-utility-section { margin-bottom: 12px; }
.utility-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--mc-text-tertiary); margin: 0 0 8px; padding-left: 2px; }
/* 主题切换 */
.theme-toggle-row {
display: flex;
gap: 2px;
background: var(--mc-bg-sunken);
border-radius: 8px;
padding: 3px;
margin-bottom: 10px;
background: var(--mc-bg-muted);
border-radius: 14px;
padding: 4px;
margin-bottom: 12px;
border: 1px solid var(--mc-border-light);
}
.language-toggle-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.theme-btn {
@ -532,7 +703,7 @@ function logout() {
.theme-btn.active {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
box-shadow: var(--mc-shadow-soft);
}
.theme-btn-label {
@ -540,17 +711,69 @@ function logout() {
text-overflow: ellipsis;
}
.language-btn {
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
width: 100%;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
font-size: 12px;
font-weight: 600;
}
.language-btn:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.language-btn.active {
border-color: rgba(217, 109, 70, 0.18);
background: var(--mc-primary-bg);
color: var(--mc-primary);
}
.language-abbr {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--mc-panel-raised);
color: inherit;
font-size: 11px;
font-weight: 800;
flex-shrink: 0;
}
.language-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 16px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
}
.user-avatar {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover));
border-radius: 50%;
background: linear-gradient(135deg, var(--mc-primary), var(--mc-accent));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
@ -593,6 +816,125 @@ function logout() {
color: var(--mc-danger);
}
.collapsed-footer-actions {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.footer-icon-btn {
width: 42px;
height: 42px;
border-radius: 14px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.footer-icon-btn:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.footer-icon-btn.healthy .health-dot { background: var(--mc-success); }
.footer-icon-btn.warning .health-dot { background: var(--mc-primary); }
.footer-icon-btn.error .health-dot { background: var(--mc-danger); }
.footer-icon-btn.unknown .health-dot { background: var(--mc-text-tertiary); }
.footer-icon-btn--accent {
color: var(--mc-primary);
background: var(--mc-primary-bg);
border-color: rgba(217, 109, 70, 0.18);
}
.sidebar-utility-panel {
position: absolute;
left: calc(100% + 14px);
bottom: 16px;
width: 236px;
padding: 14px;
border-radius: 22px;
background: var(--mc-sidebar-floating-bg);
border: 1px solid var(--mc-border);
box-shadow: var(--mc-shadow-medium);
display: flex;
flex-direction: column;
gap: 14px;
backdrop-filter: blur(18px);
}
.panel-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-option-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.panel-option-btn {
width: 100%;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--mc-border-light);
background: var(--mc-bg-muted);
color: var(--mc-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.15s ease;
}
.panel-option-btn:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
.panel-option-btn.active {
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-color: rgba(217, 109, 70, 0.18);
}
.panel-option-icon {
width: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.panel-user {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-radius: 16px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
}
.panel-user-meta {
min-width: 0;
flex: 1;
}
.logout-btn--panel {
flex-shrink: 0;
}
/* ===== 主内容区 ===== */
.main-content {
flex: 1;
@ -600,6 +942,9 @@ function logout() {
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
padding: 14px 14px 14px 18px;
}
/* ===== 移动端元素(桌面端隐藏) ===== */
@ -627,9 +972,10 @@ function logout() {
z-index: 1000;
width: 260px;
min-width: 260px;
margin: 10px;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: none;
box-shadow: var(--mc-shadow-medium);
}
.sidebar.mobile-open {
@ -658,9 +1004,12 @@ function logout() {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--mc-sidebar-bg);
border-bottom: 1px solid var(--mc-border-light);
margin: 0 0 12px;
padding: 12px 14px;
background: var(--mc-surface-overlay);
border: 1px solid var(--mc-border);
border-radius: 18px;
box-shadow: var(--mc-shadow-soft);
flex-shrink: 0;
}
@ -687,5 +1036,14 @@ function logout() {
.mobile-menu-btn:hover {
background: var(--mc-bg-sunken);
}
.sidebar-utility-panel {
display: none;
}
.sidebar-footer {
background: transparent;
backdrop-filter: none;
}
}
</style>