refactor(ui): minimalist login redesign, MCP fixes, Flyway upgrade compatibility

This commit is contained in:
matevip 2026-04-11 23:01:08 +08:00
parent 4f217dfc2c
commit 53b72e3928
18 changed files with 1012 additions and 1062 deletions

View File

@ -70,7 +70,9 @@ public class TalkModeWebSocketHandler extends AbstractWebSocketHandler {
String type = (String) data.get("type");
if ("init".equals(type)) {
Long agentId = data.get("agentId") != null ? Long.valueOf(data.get("agentId").toString()) : null;
Object rawAgentId = data.get("agentId");
Long agentId = (rawAgentId != null && !rawAgentId.toString().isBlank())
? Long.valueOf(rawAgentId.toString()) : null;
String conversationId = (String) data.getOrDefault("conversationId", "talk-" + session.getId());
String username = (String) data.getOrDefault("username", "anonymous");

View File

@ -0,0 +1,32 @@
package vip.mate.config;
import lombok.extern.slf4j.Slf4j;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Flyway auto-repair configuration.
* <p>
* Replaces the default {@link FlywayMigrationInitializer} with one that
* calls {@code flyway.repair()} before {@code flyway.migrate()}.
* This handles failed migrations and checksum mismatches transparently
* during version upgrades especially important for Desktop app users
* who cannot manually run CLI commands.
*
* @author MateClaw Team
*/
@Slf4j
@Configuration
public class FlywayRepairConfig {
@Bean
public FlywayMigrationInitializer flywayInitializer(Flyway flyway) {
return new FlywayMigrationInitializer(flyway, f -> {
log.info("[Flyway] Running repair before migrate (auto-fix failed/changed migrations)...");
f.repair();
f.migrate();
});
}
}

View File

@ -3,17 +3,50 @@ package vip.mate.tool.mcp.runtime;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.McpJsonMapper;
import lombok.extern.slf4j.Slf4j;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/**
* stdio MCP 子进程补充工作目录支持
* MCP SDK 1.0.0 ServerParameters 尚未暴露 cwd这里通过覆写 ProcessBuilder 注入
* Enhanced stdio MCP transport with:
* <ul>
* <li>Working directory (cwd) support</li>
* <li>Automatic PATH enrichment for Desktop app environments where
* Node.js/npx may not be in the JRE process's PATH</li>
* </ul>
*/
@Slf4j
public class CwdAwareStdioClientTransport extends StdioClientTransport {
private final String cwd;
/** Common Node.js installation paths across platforms */
private static final String[] NODE_PATH_CANDIDATES = {
// macOS Homebrew
"/usr/local/bin",
"/opt/homebrew/bin",
// macOS nvm
System.getProperty("user.home") + "/.nvm/current/bin",
// Linux common
"/usr/bin",
"/usr/local/bin",
// Linux nvm
System.getProperty("user.home") + "/.nvm/current/bin",
// Windows common
System.getenv("APPDATA") != null ? System.getenv("APPDATA") + "\\npm" : "",
"C:\\Program Files\\nodejs",
// pnpm global
System.getProperty("user.home") + "/.local/share/pnpm",
System.getProperty("user.home") + "/Library/pnpm",
// Volta
System.getProperty("user.home") + "/.volta/bin",
// fnm
System.getProperty("user.home") + "/.fnm/current/bin",
};
public CwdAwareStdioClientTransport(ServerParameters params, McpJsonMapper jsonMapper, String cwd) {
super(params, jsonMapper);
this.cwd = cwd;
@ -25,6 +58,56 @@ public class CwdAwareStdioClientTransport extends StdioClientTransport {
if (cwd != null && !cwd.isBlank()) {
builder.directory(new File(cwd));
}
enrichPath(builder);
return builder;
}
/**
* Enrich the process PATH with common Node.js installation directories.
* Desktop apps (Electron/JRE) often don't inherit the user's shell PATH,
* causing "npx: command not found" errors.
*/
private void enrichPath(ProcessBuilder builder) {
Map<String, String> env = builder.environment();
String currentPath = env.getOrDefault("PATH", env.getOrDefault("Path", ""));
StringBuilder enriched = new StringBuilder(currentPath);
for (String candidate : NODE_PATH_CANDIDATES) {
if (candidate == null || candidate.isEmpty()) continue;
if (currentPath.contains(candidate)) continue;
if (Files.isDirectory(Path.of(candidate))) {
enriched.append(File.pathSeparator).append(candidate);
}
}
// Also try to resolve nvm's actual current version directory
String nvmDir = System.getenv("NVM_DIR");
if (nvmDir == null) nvmDir = System.getProperty("user.home") + "/.nvm";
Path nvmDefault = Path.of(nvmDir, "versions", "node");
if (Files.isDirectory(nvmDefault)) {
try (var stream = Files.list(nvmDefault)) {
stream.filter(Files::isDirectory)
.sorted((a, b) -> b.getFileName().toString().compareTo(a.getFileName().toString()))
.findFirst()
.ifPresent(nodeDir -> {
String binPath = nodeDir.resolve("bin").toString();
if (!currentPath.contains(binPath)) {
enriched.append(File.pathSeparator).append(binPath);
}
});
} catch (Exception ignored) {}
}
String finalPath = enriched.toString();
env.put("PATH", finalPath);
// Windows uses "Path" key
if (env.containsKey("Path")) {
env.put("Path", finalPath);
}
if (!finalPath.equals(currentPath)) {
log.debug("[MCP] Enriched PATH for subprocess: added {} entries",
finalPath.split(File.pathSeparator).length - currentPath.split(File.pathSeparator).length);
}
}
}

View File

@ -37,7 +37,6 @@ spring:
- classpath:db/migration/h2
validate-on-migrate: true
clean-disabled: true
repair-on-migrate: true
h2:
console:

View File

@ -405,10 +405,10 @@ VALUES (
NULL,
NULL,
'npx',
'["-y","@modelcontextprotocol/server-filesystem","/Users/mate"]',
'["-y","@modelcontextprotocol/server-filesystem","${user.home}"]',
'{}',
'/Users/mate',
TRUE,
NULL,
FALSE,
30,
30,
'disconnected',

View File

@ -403,10 +403,10 @@ VALUES (
NULL,
NULL,
'npx',
'["-y","@modelcontextprotocol/server-filesystem","/Users/mate"]',
'["-y","@modelcontextprotocol/server-filesystem","${user.home}"]',
'{}',
'/Users/mate',
TRUE,
NULL,
FALSE,
30,
30,
'disconnected',

View File

@ -405,10 +405,10 @@ VALUES (
NULL,
NULL,
'npx',
'["-y","@modelcontextprotocol/server-filesystem","/Users/mate"]',
'["-y","@modelcontextprotocol/server-filesystem","${user.home}"]',
'{}',
'/Users/mate',
TRUE,
NULL,
FALSE,
30,
30,
'disconnected',

View File

@ -411,10 +411,10 @@ VALUES (
NULL,
NULL,
'npx',
'["-y","@modelcontextprotocol/server-filesystem","/Users/mate"]',
'["-y","@modelcontextprotocol/server-filesystem","${user.home}"]',
'{}',
'/Users/mate',
TRUE,
NULL,
FALSE,
30,
30,
'disconnected',

View File

@ -1,2 +1,121 @@
-- V2: Add workspace base_path for directory restriction (RFC-002)
-- V2: Upgrade schema for databases created before Flyway was introduced.
-- All statements use IF NOT EXISTS / IF EXISTS to be idempotent.
-- ===== Workspace tables =====
CREATE TABLE IF NOT EXISTS mate_workspace (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL,
description VARCHAR(256),
owner_id BIGINT,
settings_json TEXT,
base_path VARCHAR(512),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
CONSTRAINT uk_workspace_slug UNIQUE (slug)
);
ALTER TABLE mate_workspace ADD COLUMN IF NOT EXISTS base_path VARCHAR(512);
CREATE TABLE IF NOT EXISTS mate_workspace_member (
id BIGINT NOT NULL PRIMARY KEY,
workspace_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'member',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ws_member_workspace ON mate_workspace_member(workspace_id);
CREATE INDEX IF NOT EXISTS idx_ws_member_user ON mate_workspace_member(user_id);
-- ===== Add workspace_id column to tables that may lack it (pre-workspace era databases) =====
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_channel ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
-- ===== Tables that may not exist in very old databases =====
CREATE TABLE IF NOT EXISTS mate_workspace_file (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
filename VARCHAR(256) NOT NULL,
content CLOB,
file_size BIGINT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_workspace_file_agent ON mate_workspace_file(agent_id);
CREATE TABLE IF NOT EXISTS mate_usage_daily (
id BIGINT NOT NULL PRIMARY KEY,
workspace_id BIGINT NOT NULL,
agent_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
conversation_count INT NOT NULL DEFAULT 0,
message_count INT NOT NULL DEFAULT 0,
tool_call_count INT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_usage_daily ON mate_usage_daily(workspace_id, agent_id, stat_date);
CREATE TABLE IF NOT EXISTS mate_audit_event (
id BIGINT NOT NULL PRIMARY KEY,
workspace_id BIGINT,
user_id BIGINT,
username VARCHAR(64),
action VARCHAR(64) NOT NULL,
resource_type VARCHAR(64),
resource_id VARCHAR(128),
detail TEXT,
ip_address VARCHAR(64),
create_time DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event(workspace_id, create_time);
-- ===== OAuth columns on model_provider =====
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS auth_type VARCHAR(16) NOT NULL DEFAULT 'api_key';
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_access_token TEXT;
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_refresh_token TEXT;
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_expires_at BIGINT;
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_account_id VARCHAR(128);
-- ===== Agent-Skill / Agent-Tool binding tables =====
CREATE TABLE IF NOT EXISTS mate_agent_skill (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
skill_id BIGINT NOT NULL,
create_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS mate_agent_tool (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
tool_name VARCHAR(128) NOT NULL,
create_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
-- ===== Memory recall table =====
CREATE TABLE IF NOT EXISTS mate_memory_recall (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
filename VARCHAR(256) NOT NULL,
content CLOB,
tags VARCHAR(512),
score DOUBLE NOT NULL DEFAULT 0.0,
last_recalled_at DATETIME,
promoted BOOLEAN NOT NULL DEFAULT FALSE,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall(agent_id);

View File

@ -1,2 +1,114 @@
-- V2: Add workspace base_path for directory restriction (RFC-002)
-- V2: Upgrade schema for databases created before Flyway was introduced.
CREATE TABLE IF NOT EXISTS mate_workspace (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL,
description VARCHAR(256),
owner_id BIGINT,
settings_json TEXT,
base_path VARCHAR(512),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_workspace_slug (slug)
);
ALTER TABLE mate_workspace ADD COLUMN IF NOT EXISTS base_path VARCHAR(512);
CREATE TABLE IF NOT EXISTS mate_workspace_member (
id BIGINT NOT NULL PRIMARY KEY,
workspace_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'member',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
INDEX idx_ws_member_workspace (workspace_id),
INDEX idx_ws_member_user (user_id)
);
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_channel ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
CREATE TABLE IF NOT EXISTS mate_workspace_file (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
filename VARCHAR(256) NOT NULL,
content LONGTEXT,
file_size BIGINT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
INDEX idx_workspace_file_agent (agent_id)
);
CREATE TABLE IF NOT EXISTS mate_usage_daily (
id BIGINT NOT NULL PRIMARY KEY,
workspace_id BIGINT NOT NULL,
agent_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
conversation_count INT NOT NULL DEFAULT 0,
message_count INT NOT NULL DEFAULT 0,
tool_call_count INT NOT NULL DEFAULT 0,
prompt_tokens BIGINT NOT NULL DEFAULT 0,
completion_tokens BIGINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_usage_daily (workspace_id, agent_id, stat_date)
);
CREATE TABLE IF NOT EXISTS mate_audit_event (
id BIGINT NOT NULL PRIMARY KEY,
workspace_id BIGINT,
user_id BIGINT,
username VARCHAR(64),
action VARCHAR(64) NOT NULL,
resource_type VARCHAR(64),
resource_id VARCHAR(128),
detail TEXT,
ip_address VARCHAR(64),
create_time DATETIME NOT NULL,
INDEX idx_audit_ws_time (workspace_id, create_time)
);
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS auth_type VARCHAR(16) NOT NULL DEFAULT 'api_key';
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_access_token TEXT;
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_refresh_token TEXT;
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_expires_at BIGINT;
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS oauth_account_id VARCHAR(128);
CREATE TABLE IF NOT EXISTS mate_agent_skill (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
skill_id BIGINT NOT NULL,
create_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS mate_agent_tool (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
tool_name VARCHAR(128) NOT NULL,
create_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS mate_memory_recall (
id BIGINT NOT NULL PRIMARY KEY,
agent_id BIGINT NOT NULL,
filename VARCHAR(256) NOT NULL,
content LONGTEXT,
tags VARCHAR(512),
score DOUBLE NOT NULL DEFAULT 0.0,
last_recalled_at DATETIME,
promoted BOOLEAN NOT NULL DEFAULT FALSE,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0,
INDEX idx_memory_recall_agent (agent_id)
);

View File

@ -90,7 +90,9 @@ const stateLabel = computed(() => {
})
onMounted(() => {
connectWebSocket()
if (props.agentId) {
connectWebSocket()
}
})
onBeforeUnmount(() => {

View File

@ -258,41 +258,15 @@ export default {
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.',
heroDesc: 'A personal AI operating system that places models inside a continuous loop of context, memory, execution, knowledge, and delivery.',
foundationTitle: 'Built With',
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.',
contextDesc: 'Models, knowledge, workspace, and the active task live inside the same frame instead of starting over every turn.',
executionTitle: 'Make execution real',
executionDesc: 'Tools, channels, and async tasks are the real execution surface, not bolted-on extras.',
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.',
memoryDesc: 'Memory is shaped, recalled, and turned into leverage for the next task.',
},
},
model: {

View File

@ -248,41 +248,15 @@ export default {
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以及面向真实工作的产品边界。',
heroDesc: '一套个人 AI 操作系统,让模型真正进入上下文、记忆、执行、知识和交付的连续流程。',
foundationTitle: '技术栈',
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 产品不该停在回答层,它应该能持续推进任务、暴露状态、沉淀结果。',
contextDesc: '模型、知识、工作区和当前任务在同一个语境里运行,而不是每次重新开始。',
executionTitle: '让执行成为产品本身',
executionDesc: '工具、渠道与异步任务是系统的真实执行面,不是外挂。',
memoryTitle: '让记忆产生复利',
memoryDesc: '记忆被整理、被召回,在下一次任务里变得更有价值。',
},
},
model: {

View File

@ -239,6 +239,7 @@
<!-- Talk Mode 覆盖层 -->
<TalkMode
v-if="showTalkMode"
:visible="showTalkMode"
:agent-id="selectedAgentId"
:conversation-id="currentConversationId"
@ -1389,6 +1390,14 @@ function handleCodeCopy(e: MouseEvent) {
padding: 8px;
}
.conversation-panel.conv-collapsed .agent-dropdown {
position: fixed;
top: auto;
left: 62px;
right: auto;
min-width: 260px;
}
.conversation-panel.conv-collapsed .conv-item {
justify-content: center;
padding: 10px 6px;
@ -1531,6 +1540,7 @@ function handleCodeCopy(e: MouseEvent) {
top: calc(100% + 4px);
left: 12px;
right: 12px;
min-width: 240px;
z-index: 100;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);

View File

@ -13,10 +13,9 @@
</button>
</div>
<div class="page-stage">
<!-- 数据源列表 -->
<div class="tools-table-wrap">
<table class="tools-table">
<!-- 数据源列表 -->
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>{{ t('datasources.columns.name') }}</th>
@ -26,7 +25,7 @@
</tr>
</thead>
<tbody>
<tr v-for="ds in datasources" :key="ds.id" class="tool-row">
<tr v-for="ds in datasources" :key="ds.id" class="data-row">
<td>
<div class="tool-info">
<div class="tool-icon-wrap" :class="{ 'icon-ok': ds.lastTestOk === true, 'icon-fail': ds.lastTestOk === false }">
@ -89,7 +88,6 @@
</tbody>
</table>
</div>
</div>
</div>
<div v-if="detailDs" class="modal-overlay">
@ -440,154 +438,152 @@ async function testConnection(ds: Datasource) {
</script>
<style scoped>
.page-container { height: 100%; overflow-y: auto; padding: 24px; background: var(--mc-bg); }
.page-container {
height: 100%;
overflow-y: auto;
padding: 0;
background: transparent;
}
/* ===== Shell ===== */
.page-container { height: 100%; overflow-y: auto; }
.page-shell { padding: 24px; }
.page-shell {
min-height: 100%;
padding: 24px;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--mc-primary-bg) 34%, transparent) 0, transparent 36%),
linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 78%, white 22%) 0%, var(--mc-bg) 100%);
}
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.page-lead { display: flex; flex-direction: column; gap: 8px; }
/* ===== Header ===== */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-lead { display: flex; flex-direction: column; gap: 6px; }
.page-kicker {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 12px;
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
color: var(--mc-primary-hover);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
display: inline-flex; width: fit-content;
padding: 4px 10px; border-radius: 999px;
font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--mc-primary); background: var(--mc-primary-bg);
}
.page-title { font-size: clamp(28px, 4vw, 40px); line-height: 0.95; font-weight: 800; color: var(--mc-text-primary); margin: 0; }
.page-desc { max-width: 620px; font-size: 15px; line-height: 1.55; color: var(--mc-text-secondary); margin: 0; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.page-title { font-size: clamp(24px, 3.5vw, 36px); font-weight: 800; color: var(--mc-text-primary); margin: 0; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
/* ===== Buttons ===== */
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 9px 16px; background: var(--mc-primary); color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-primary:disabled { opacity: .4; 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:hover { background: var(--mc-bg-sunken); }
.btn-test { display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: transparent; color: var(--mc-primary); border: 1px solid var(--mc-primary); border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; min-width: 90px; justify-content: center; }
.btn-test:hover { background: var(--mc-primary-bg); }
.btn-test:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-test:disabled { opacity: .4; cursor: not-allowed; }
.page-stage {
background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
/* ===== Table — one surface, no nesting ===== */
.table-wrap {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 18px;
padding: 12px;
box-shadow: 0 18px 48px rgba(152, 93, 63, 0.06);
border-radius: 16px;
overflow: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
padding: 10px 16px; text-align: left;
font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
color: var(--mc-text-tertiary);
background: var(--mc-bg-sunken);
border-bottom: 1px solid var(--mc-border);
white-space: nowrap;
}
.data-row { border-bottom: 1px solid var(--mc-border-light); transition: background .12s; }
.data-row:last-child { border-bottom: none; }
.data-row:hover { background: var(--mc-bg-sunken); }
.data-table td { padding: 12px 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: middle; }
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; overflow-x: auto; overflow-y: hidden; }
.tools-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
.tools-table th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.08em; background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%); border-bottom: 1px solid var(--mc-border); }
.tool-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
.tool-row:hover { background: var(--mc-bg-sunken); }
.tool-row:last-child { border-bottom: none; }
.tools-table td { padding: 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: top; }
/* ===== Name cell ===== */
.tool-info { display: flex; align-items: center; gap: 10px; }
.tool-icon-wrap { width: 32px; height: 32px; background: var(--mc-bg-sunken); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); }
.tool-icon-wrap.icon-ok { background: #e8f5e9; color: #2e7d32; }
.tool-icon-wrap.icon-fail { background: #fce4ec; color: #c62828; }
.tool-name { max-width: 180px; font-weight: 700; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tool-type-inline { margin-top: 6px; }
.tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tool-icon-wrap { width: 32px; height: 32px; background: var(--mc-bg-sunken); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-tertiary); }
.tool-icon-wrap.icon-ok { color: #2e7d32; background: color-mix(in srgb, #2e7d32 10%, transparent); }
.tool-icon-wrap.icon-fail { color: var(--mc-danger, #ef4444); background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); }
.tool-name { font-weight: 600; color: var(--mc-text-primary); }
.tool-type-inline { margin-top: 4px; }
.tool-desc { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ===== Connection cell ===== */
.conn-info { display: flex; flex-direction: column; gap: 2px; }
.conn-host { max-width: 180px; background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conn-db { max-width: 200px; font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conn-host { display: inline-block; max-width: 180px; background: var(--mc-bg-sunken); padding: 2px 8px; border-radius: 6px; font-size: 12px; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conn-db { font-size: 12px; color: var(--mc-text-tertiary); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
.type-mysql { background: #e8f4fd; color: #1a73e8; }
.type-postgresql { background: #e8f0fe; color: #336791; }
.type-clickhouse { background: #fff8e1; color: #e6a817; }
.type-mariadb { background: #fce4ec; color: #c0392b; }
/* ===== Type badge ===== */
.type-badge { padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--mc-text-tertiary); background: var(--mc-bg-sunken); }
.type-mysql { color: #1a73e8; background: color-mix(in srgb, #1a73e8 10%, transparent); }
.type-postgresql { color: #336791; background: color-mix(in srgb, #336791 10%, transparent); }
.type-clickhouse { color: #e6a817; background: color-mix(in srgb, #e6a817 10%, transparent); }
.type-mariadb { color: #c0392b; background: color-mix(in srgb, #c0392b 10%, transparent); }
/* ===== Status cell ===== */
.status-cell { display: flex; align-items: center; gap: 8px; }
.status-label { font-size: 12px; color: var(--mc-text-secondary); white-space: nowrap; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-ok { background: #4caf50; box-shadow: 0 0 4px rgba(76,175,80,0.4); }
.dot-fail { background: #f44336; box-shadow: 0 0 4px rgba(244,67,54,0.4); }
.dot-unknown { background: var(--mc-border); }
.dot-disabled { background: var(--mc-border); opacity: 0.5; }
.status-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); white-space: nowrap; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-ok { background: #34d399; box-shadow: 0 0 4px rgba(52,211,153,.45); }
.dot-fail { background: var(--mc-danger, #ef4444); box-shadow: 0 0 4px rgba(239,68,68,.4); }
.dot-unknown { background: var(--mc-text-tertiary); opacity: .4; }
.dot-disabled { background: var(--mc-text-tertiary); opacity: .3; }
/* ===== Toggle ===== */
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: .2s; }
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: .2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.row-actions { display: flex; gap: 6px; }
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
.row-btn:hover { background: var(--mc-bg-sunken); }
.row-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
/* ===== Row actions ===== */
.row-actions { display: flex; gap: 5px; }
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); border-radius: 8px; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-tertiary); transition: all .12s; }
.row-btn:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.row-btn:disabled { opacity: .3; cursor: not-allowed; }
.row-btn.danger:hover { background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); border-color: var(--mc-danger); color: var(--mc-danger); }
.row-btn.test-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
.spinner { width: 12px; height: 12px; border: 2px solid var(--mc-border); border-top-color: var(--mc-primary); border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
/* ===== Spinner ===== */
.spinner { width: 12px; height: 12px; border: 2px solid var(--mc-border); border-top-color: var(--mc-primary); border-radius: 50%; animation: spin .6s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty-row { padding: 40px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
/* ===== Empty state ===== */
.empty-row { padding: 48px 16px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--mc-text-tertiary); }
.empty-icon { font-size: 32px; }
.empty-state p { font-size: 14px; margin: 0; }
.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; }
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 580px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 18px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
/* ===== Modal ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; width: 100%; max-width: 580px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 17px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.modal-close { width: 28px; height: 28px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
.modal-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.detail-item { display: flex; flex-direction: column; gap: 6px; }
.detail-item-full { grid-column: 1 / -1; }
.detail-label { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mc-text-tertiary); }
.detail-value { font-size: 14px; line-height: 1.5; color: var(--mc-text-primary); word-break: break-word; }
.detail-subvalue { font-size: 12px; color: var(--mc-text-tertiary); }
.detail-block { padding: 12px 14px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-bg-sunken); white-space: pre-wrap; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; }
.modal-footer { display: flex; align-items: center; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
.form-section-title { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin: 20px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--mc-border-light); }
/* Detail grid */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.detail-item { display: flex; flex-direction: column; gap: 4px; }
.detail-item-full { grid-column: 1 / -1; }
.detail-label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--mc-text-tertiary); }
.detail-value { font-size: 14px; color: var(--mc-text-primary); word-break: break-word; }
.detail-subvalue { font-size: 12px; color: var(--mc-text-tertiary); }
.detail-block { padding: 8px 12px; border-radius: 8px; background: var(--mc-bg-sunken); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; white-space: pre-wrap; }
/* Form */
.form-section-title { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: .05em; margin: 20px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--mc-border-light); }
.form-section-title:first-child { margin-top: 0; }
.advanced-toggle { cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none; }
.advanced-toggle svg { transition: transform 0.2s; }
.advanced-toggle svg { transition: transform .2s; }
.advanced-toggle svg.rotated { transform: rotate(180deg); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,.12); }
.form-hint { font-size: 11px; color: var(--mc-text-tertiary); margin-top: 2px; }
.password-wrap { position: relative; }
.password-wrap .form-input { padding-right: 36px; }
.password-toggle { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); padding: 4px; display: flex; align-items: center; justify-content: center; }
.password-toggle:hover { color: var(--mc-text-secondary); }
.test-result { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; margin-top: 16px; }
.test-ok { background: #e8f5e9; color: #2e7d32; }
.test-fail { background: #fce4ec; color: #c62828; }
.modal-footer { display: flex; align-items: center; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
.test-ok { background: color-mix(in srgb, #2e7d32 10%, transparent); color: #2e7d32; }
.test-fail { background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); color: var(--mc-danger, #ef4444); }
/* Responsive */
@media (max-width: 900px) {
.page-header { flex-direction: column; align-items: stretch; }
.page-header { flex-direction: column; }
.btn-primary { width: 100%; justify-content: center; }
.detail-grid { grid-template-columns: 1fr; }
}

View File

@ -1,103 +1,57 @@
<template>
<div class="login-page">
<div class="login-frame">
<div class="login-hero">
<div class="login-hero__kicker">{{ t('login.kicker') }}</div>
<div class="login-logo">
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-image" />
<h1 class="logo-title">Mate<span class="logo-title-highlight">Claw</span></h1>
<p class="logo-subtitle">{{ t('login.subtitle') }}</p>
</div>
<h2 class="login-hero__title">{{ t('login.heroTitle') }}</h2>
<p class="login-hero__desc">{{ t('login.heroDesc') }}</p>
<div class="login-hero__points">
<div class="hero-point">{{ t('login.pointContext') }}</div>
<div class="hero-point">{{ t('login.pointKnowledge') }}</div>
<div class="hero-point">{{ t('login.pointExecution') }}</div>
</div>
<div class="login-center">
<div class="login-logo">
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="logo-image" />
<h1 class="logo-title">Mate<span class="logo-title-highlight">Claw</span></h1>
</div>
<div class="login-card">
<div class="login-card__intro">
<div class="login-card__kicker">{{ t('login.signIn') }}</div>
<h3 class="login-card__title">{{ t('login.cardTitle') }}</h3>
<p class="login-card__desc">{{ t('login.cardDesc') }}</p>
<form class="login-form" @submit.prevent="handleLogin">
<div class="input-wrap">
<input
v-model="form.username"
type="text"
class="form-input"
:placeholder="t('login.placeholders.username')"
:aria-label="t('login.fields.username')"
autocomplete="username"
required
/>
</div>
<!-- 登录表单 -->
<form class="login-form" @submit.prevent="handleLogin">
<div class="form-group">
<label class="form-label">{{ t('login.fields.username') }}</label>
<div class="input-wrap">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<input
v-model="form.username"
type="text"
class="form-input"
:placeholder="t('login.placeholders.username')"
autocomplete="username"
required
/>
</div>
</div>
<div class="form-group">
<label class="form-label">{{ t('login.fields.password') }}</label>
<div class="input-wrap">
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="form-input"
:placeholder="t('login.placeholders.password')"
autocomplete="current-password"
required
/>
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
<svg v-if="!showPassword" width="16" height="16" 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>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
</button>
</div>
</div>
<div v-if="errorMsg" class="error-msg">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
<div class="input-wrap">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="form-input form-input--has-eye"
:placeholder="t('login.placeholders.password')"
:aria-label="t('login.fields.password')"
autocomplete="current-password"
required
/>
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
<svg v-if="!showPassword" width="16" height="16" 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>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
{{ errorMsg }}
</div>
<button type="submit" class="login-btn" :disabled="loading">
<span v-if="!loading">{{ t('login.signIn') }}</span>
<span v-else class="loading-dots">
<span></span><span></span><span></span>
</span>
</button>
</form>
</div>
<p class="login-hint" v-html="t('login.hint')"></p>
</div>
</div>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="bg-circle bg-circle-1"></div>
<div class="bg-circle bg-circle-2"></div>
<div class="bg-circle bg-circle-3"></div>
<button type="submit" class="login-btn" :disabled="loading">
<span v-if="!loading">{{ t('login.signIn') }}</span>
<span v-else class="loading-dots">
<span></span><span></span><span></span>
</span>
</button>
</form>
<p class="login-hint" v-html="t('login.hint')"></p>
</div>
</div>
</template>
@ -140,205 +94,91 @@ async function handleLogin() {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--mc-primary-bg) 0%, #FAF5F0 50%, #F5EDE5 100%);
position: relative;
overflow: hidden;
padding: 28px;
background: linear-gradient(160deg, #FAF5F0 0%, #F5EDE5 100%);
padding: 24px;
}
:root.dark .login-page,
html.dark .login-page {
background: linear-gradient(135deg, var(--mc-bg) 0%, #1E1814 50%, #1A1210 100%);
background: linear-gradient(160deg, var(--mc-bg) 0%, #1A1210 100%);
}
.login-frame {
width: min(1120px, 100%);
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: 24px;
position: relative;
z-index: 1;
}
.login-hero,
.login-card {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 28px;
box-shadow: 0 20px 60px rgba(217, 119, 87, 0.12);
backdrop-filter: blur(18px);
}
.login-hero {
padding: 40px;
.login-center {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 560px;
}
.login-hero__kicker,
.login-card__kicker {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 7px 12px;
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.1em;
text-transform: uppercase;
}
.login-hero__title {
font-size: clamp(34px, 4vw, 56px);
line-height: 0.98;
font-weight: 800;
letter-spacing: -0.05em;
color: var(--mc-text-primary);
margin: 18px 0 12px;
}
.login-hero__desc {
font-size: 16px;
line-height: 1.75;
color: var(--mc-text-secondary);
max-width: 640px;
margin: 0;
}
.login-hero__points {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.hero-point {
padding: 10px 14px;
border-radius: 16px;
border: 1px solid var(--mc-border-light);
background: linear-gradient(180deg, var(--mc-bg-muted), var(--mc-bg-elevated));
color: var(--mc-text-primary);
font-size: 14px;
font-weight: 600;
}
.login-card {
padding: 36px;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
}
.login-card__intro {
margin-bottom: 24px;
}
.login-card__title {
margin: 14px 0 8px;
font-size: 28px;
line-height: 1.05;
letter-spacing: -0.04em;
color: var(--mc-text-primary);
}
.login-card__desc {
margin: 0;
color: var(--mc-text-secondary);
font-size: 14px;
line-height: 1.7;
gap: 40px;
animation: fadeUp 0.6s ease-out both;
}
/* Logo */
.login-logo {
margin-top: 22px;
text-align: center;
}
.logo-image {
display: block;
margin: 0 auto 12px;
width: 80px;
height: 80px;
margin: 0 auto 16px;
width: 100px;
height: 100px;
object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(217, 119, 87, 0.35));
filter: drop-shadow(0 6px 20px rgba(217, 119, 87, 0.3));
animation: breathe 3.5s ease-in-out infinite;
}
.logo-title {
font-size: 42px;
font-size: 36px;
font-weight: 800;
color: var(--mc-text-primary);
margin: 0 0 8px;
letter-spacing: -0.05em;
margin: 0;
letter-spacing: -0.04em;
}
.logo-title-highlight {
color: var(--mc-primary);
}
.logo-subtitle {
font-size: 15px;
color: var(--mc-text-tertiary);
margin: 0;
}
/* 表单 */
/* Form */
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: var(--mc-text-primary);
}
.input-wrap {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 12px;
color: var(--mc-text-tertiary);
pointer-events: none;
}
.form-input {
width: 100%;
padding: 10px 40px 10px 38px;
border: 1px solid var(--mc-border);
border-radius: 10px;
font-size: 14px;
padding: 14px 16px;
border: 1.5px solid var(--mc-border);
border-radius: 12px;
font-size: 15px;
color: var(--mc-text-primary);
outline: none;
transition: all 0.15s;
background: var(--mc-bg-sunken);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.form-input--has-eye {
padding-right: 44px;
}
.form-input:focus {
border-color: var(--mc-primary);
background: var(--mc-bg-elevated);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1);
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.08);
}
.eye-btn {
position: absolute;
right: 10px;
right: 12px;
width: 28px;
height: 28px;
border: none;
@ -355,33 +195,30 @@ html.dark .login-page {
color: var(--mc-primary);
}
/* 错误提示 */
/* Error */
.error-msg {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
padding: 10px 14px;
background: var(--mc-danger-bg);
border: 1px solid var(--mc-danger);
border-radius: 8px;
border-radius: 10px;
font-size: 13px;
color: var(--mc-danger);
}
/* 登录按钮 */
/* Button */
.login-btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover));
color: white;
border: none;
border-radius: 10px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
margin-top: 4px;
height: 44px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
@ -397,7 +234,7 @@ html.dark .login-page {
cursor: not-allowed;
}
/* 加载动画 */
/* Loading */
.loading-dots {
display: flex;
gap: 5px;
@ -420,15 +257,16 @@ html.dark .login-page {
30% { transform: translateY(-5px); }
}
/* 提示 */
/* Hint */
.login-hint {
text-align: left;
text-align: center;
font-size: 12px;
color: var(--mc-text-tertiary);
margin: 20px 0 0;
margin: 0;
opacity: 0.7;
}
.login-hint code {
.login-hint :deep(code) {
background: var(--mc-inline-code-bg);
padding: 1px 6px;
border-radius: 4px;
@ -436,76 +274,34 @@ html.dark .login-page {
font-size: 12px;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
inset: 0;
pointer-events: none;
}
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.4;
}
.bg-circle-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #E0C4B0, transparent);
top: -100px;
right: -100px;
}
.bg-circle-2 {
width: 300px;
height: 300px;
background: radial-gradient(circle, #F0D0B8, transparent);
bottom: -80px;
left: -80px;
}
.bg-circle-3 {
width: 200px;
height: 200px;
background: radial-gradient(circle, #F5E4D8, transparent);
bottom: 100px;
right: 100px;
}
:root.dark .bg-circle-1,
html.dark .bg-circle-1 {
background: radial-gradient(circle, rgba(217, 119, 87, 0.2), transparent);
}
:root.dark .bg-circle-2,
html.dark .bg-circle-2 {
background: radial-gradient(circle, rgba(193, 87, 43, 0.15), transparent);
}
:root.dark .bg-circle-3,
html.dark .bg-circle-3 {
background: radial-gradient(circle, rgba(123, 63, 30, 0.12), transparent);
}
@media (max-width: 960px) {
.login-frame {
grid-template-columns: 1fr;
/* Breathing animation */
@keyframes breathe {
0%, 100% {
transform: scale(1);
filter: drop-shadow(0 6px 20px rgba(217, 119, 87, 0.3));
}
.login-hero {
min-height: auto;
padding: 28px;
50% {
transform: scale(1.06);
filter: drop-shadow(0 8px 28px rgba(217, 119, 87, 0.45));
}
}
.login-card {
padding: 28px;
/* Entrance animation */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(12px);
}
.logo-title {
font-size: 34px;
to {
opacity: 1;
transform: translateY(0);
}
}
.login-hero__title {
font-size: 34px;
/* Mobile */
@media (max-width: 480px) {
.login-page {
padding: 16px;
}
}
</style>

View File

@ -24,126 +24,117 @@
</div>
</div>
<div class="page-stage">
<div class="tools-table-wrap">
<table class="tools-table">
<thead>
<tr>
<th>{{ t('mcp.columns.name') }}</th>
<th>{{ t('mcp.columns.lastStatus') }}</th>
<th>{{ t('mcp.columns.toolCount') }}</th>
<th>{{ t('mcp.columns.enabled') }}</th>
<th>{{ t('mcp.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="server in servers" :key="server.id" class="tool-row">
<td>
<div class="tool-info">
<div class="tool-icon-wrap" :class="'status-icon-' + server.lastStatus">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div>
<div class="tool-name" :title="server.name">{{ server.name }}</div>
<div class="tool-type-inline">
<span class="type-badge" :class="'type-' + server.transport">
{{ t('mcp.transport.' + server.transport) }}
</span>
<!-- Table -->
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>{{ t('mcp.columns.name') }}</th>
<th>{{ t('mcp.columns.lastStatus') }}</th>
<th class="th-center">{{ t('mcp.columns.toolCount') }}</th>
<th class="th-center">{{ t('mcp.columns.enabled') }}</th>
<th>{{ t('mcp.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="server in servers" :key="server.id" class="data-row">
<td>
<div class="server-info">
<div class="server-icon" :class="'icon-' + (server.lastStatus || 'disconnected')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div>
<div class="server-name">{{ server.name }}</div>
<div class="server-meta">
<span class="transport-tag">{{ server.transport }}</span>
<span v-if="server.description" class="server-desc">{{ server.description }}</span>
</div>
</div>
<div class="tool-desc" :title="server.description || '-'">{{ server.description || '-' }}</div>
</div>
</div>
</td>
<td>
<div class="status-stack">
<span class="status-badge" :class="'status-' + server.lastStatus" :title="t('mcp.status.' + (server.lastStatus || 'disconnected'))">
{{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}
</span>
<span v-if="server.lastConnectedTime" class="status-time" :title="server.lastConnectedTime">{{ server.lastConnectedTime }}</span>
</div>
<div v-if="server.lastError" class="status-error" :title="server.lastError">
{{ truncate(server.lastError, 40) }}
</div>
</td>
<td>
<span class="tool-count">{{ server.toolCount || 0 }}</span>
</td>
<td>
<label class="toggle-switch">
<input type="checkbox" :checked="server.enabled" @change="toggleServer(server)" />
<span class="toggle-slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="row-btn" @click="openDetailModal(server)" :title="t('common.view')">
<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="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
</svg>
</button>
<button class="row-btn" @click="testConnection(server)" :disabled="testingId === server.id" :title="t('mcp.actions.test')">
<svg v-if="testingId !== server.id" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>
</svg>
</button>
<button class="row-btn" @click="openEditModal(server)" :title="t('common.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 class="row-btn danger" @click="deleteServer(server)" :title="t('common.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 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="servers.length === 0">
<td colspan="5" class="empty-row">
<div class="empty-state">
<span class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--mc-text-tertiary)">
</td>
<td>
<div class="status-row">
<span class="status-dot" :class="'dot-' + (server.lastStatus || 'disconnected')"></span>
<span class="status-label">{{ t('mcp.status.' + (server.lastStatus || 'disconnected')) }}</span>
<span v-if="server.lastConnectedTime" class="status-time">{{ formatRelativeTime(server.lastConnectedTime) }}</span>
</div>
<div v-if="server.lastError" class="status-error" :title="server.lastError">
{{ truncate(server.lastError, 36) }}
</div>
</td>
<td class="td-center">
<span class="count-pill">{{ server.toolCount || 0 }}</span>
</td>
<td class="td-center">
<label class="toggle">
<input type="checkbox" :checked="server.enabled" @change="toggleServer(server)" />
<span class="toggle-track"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="row-btn" @click="openDetailModal(server)" :title="t('common.view')">
<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="M2.05 12a9.94 9.94 0 0 1 19.9 0 9.94 9.94 0 0 1-19.9 0z"/>
</svg>
</button>
<button class="row-btn" @click="testConnection(server)" :disabled="testingId === server.id" :title="t('mcp.actions.test')">
<svg v-if="testingId !== server.id" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/>
</svg>
</button>
<button class="row-btn" @click="openEditModal(server)" :title="t('common.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 class="row-btn danger" @click="deleteServer(server)" :title="t('common.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 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="servers.length === 0">
<td colspan="5" class="empty-row">
<div class="empty-state">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.35">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</span>
<p>{{ t('mcp.messages.empty') }}</p>
<p class="empty-sub">{{ t('mcp.messages.emptyDesc') }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p>{{ t('mcp.messages.empty') }}</p>
<p class="empty-sub">{{ t('mcp.messages.emptyDesc') }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="detailServer" class="modal-overlay">
<!-- Detail Modal -->
<div v-if="detailServer" class="modal-overlay" @click.self="closeDetailModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>{{ detailServer.name }}</h2>
<button class="modal-close" @click="closeDetailModal">
<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"/>
</svg>
</button>
<button class="modal-close" @click="closeDetailModal">&times;</button>
</div>
<div class="modal-body detail-grid">
<div class="detail-item">
@ -162,55 +153,53 @@
<div class="detail-label">{{ t('mcp.fields.enabled') }}</div>
<div class="detail-value">{{ detailServer.enabled ? 'Enabled' : 'Disabled' }}</div>
</div>
<div class="detail-item detail-item-full" v-if="detailServer.transport === 'stdio'">
<div class="detail-item detail-full" v-if="detailServer.transport === 'stdio'">
<div class="detail-label">{{ t('mcp.fields.command') }}</div>
<div class="detail-value detail-block mono">{{ detailServer.command || '-' }}</div>
<div class="detail-subvalue" v-if="detailServer.argsJson">{{ detailServer.argsJson }}</div>
<div class="detail-value detail-code">{{ detailServer.command || '-' }}</div>
<div class="detail-sub" v-if="detailServer.argsJson">{{ detailServer.argsJson }}</div>
</div>
<div class="detail-item detail-item-full" v-else>
<div class="detail-item detail-full" v-else>
<div class="detail-label">{{ t('mcp.fields.url') }}</div>
<div class="detail-value detail-block mono">{{ detailServer.url || '-' }}</div>
<div class="detail-value detail-code">{{ detailServer.url || '-' }}</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.fields.connectTimeout') }}</div>
<div class="detail-value">{{ detailServer.connectTimeoutSeconds || 30 }}</div>
<div class="detail-value">{{ detailServer.connectTimeoutSeconds || 30 }}s</div>
</div>
<div class="detail-item">
<div class="detail-label">{{ t('mcp.fields.readTimeout') }}</div>
<div class="detail-value">{{ detailServer.readTimeoutSeconds || 30 }}</div>
<div class="detail-value">{{ detailServer.readTimeoutSeconds || 30 }}s</div>
</div>
<div class="detail-item detail-item-full" v-if="detailServer.lastError">
<div class="detail-item detail-full" v-if="detailServer.lastError">
<div class="detail-label">Error</div>
<div class="detail-value detail-block">{{ detailServer.lastError }}</div>
<div class="detail-value detail-code" style="color: var(--mc-danger)">{{ detailServer.lastError }}</div>
</div>
<div class="detail-item detail-item-full" v-if="detailServer.description">
<div class="detail-item detail-full" v-if="detailServer.description">
<div class="detail-label">{{ t('mcp.fields.description') }}</div>
<div class="detail-value detail-block">{{ detailServer.description }}</div>
<div class="detail-value">{{ detailServer.description }}</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<transition name="toast">
<div v-if="testResult" class="test-toast" :class="testResult.success ? 'toast-success' : 'toast-error'">
<div class="toast-title">{{ testResult.success ? t('mcp.testResult.success') : t('mcp.testResult.failed') }}</div>
<div v-if="testResult.success" class="toast-detail">
<div v-if="testResult" class="test-toast" :class="testResult.success ? 'toast-ok' : 'toast-fail'">
<strong>{{ testResult.success ? t('mcp.testResult.success') : t('mcp.testResult.failed') }}</strong>
<span v-if="testResult.success">
{{ t('mcp.testResult.tools', { count: testResult.toolCount }) }} &middot;
{{ t('mcp.testResult.latency', { ms: testResult.latencyMs }) }}
</div>
<div v-else class="toast-detail">{{ testResult.message }}</div>
</span>
<span v-else>{{ testResult.message }}</span>
</div>
</transition>
<div v-if="showModal" class="modal-overlay">
<!-- Create / Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-wide">
<div class="modal-header">
<h2>{{ editing ? t('mcp.modal.editTitle') : t('mcp.modal.newTitle') }}</h2>
<button class="modal-close" @click="closeModal">
<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"/>
</svg>
</button>
<button class="modal-close" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-grid">
@ -226,12 +215,10 @@
<option value="streamable_http">Streamable HTTP</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('mcp.placeholders.description')" />
</div>
<template v-if="form.transport === 'stdio'">
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.command') }} *</label>
@ -250,7 +237,6 @@
<textarea v-model="form.envJson" class="form-input form-textarea mono" placeholder='{"API_KEY": "xxx"}' rows="2"></textarea>
</div>
</template>
<template v-else>
<div class="form-group full-width">
<label class="form-label">{{ t('mcp.fields.url') }} *</label>
@ -261,7 +247,6 @@
<textarea v-model="form.headersJson" class="form-input form-textarea mono" placeholder='{"Authorization": "Bearer xxx"}' rows="2"></textarea>
</div>
</template>
<div class="form-group">
<label class="form-label">{{ t('mcp.fields.connectTimeout') }}</label>
<input v-model.number="form.connectTimeoutSeconds" type="number" class="form-input" min="5" max="300" />
@ -270,11 +255,9 @@
<label class="form-label">{{ t('mcp.fields.readTimeout') }}</label>
<input v-model.number="form.readTimeoutSeconds" type="number" class="form-input" min="5" max="300" />
</div>
<div class="form-group full-width">
<label class="toggle-inline">
<input type="checkbox" v-model="form.enabled" />
<span class="toggle-slider-inline"></span>
{{ t('mcp.fields.enabled') }}
</label>
</div>
@ -487,139 +470,184 @@ async function refreshAll() {
function truncate(str: string, len: number) {
return str && str.length > len ? str.substring(0, len) + '...' : str
}
function formatRelativeTime(dateStr: string): string {
if (!dateStr) return ''
const now = Date.now()
const time = new Date(dateStr).getTime()
const diff = now - time
if (diff < 0) return ''
const sec = Math.floor(diff / 1000)
if (sec < 60) return t('chat.timeJustNow', 'Just now')
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const d = new Date(dateStr)
const p = (n: number) => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
</script>
<style scoped>
.page-container {
height: 100%;
overflow-y: auto;
padding: 0;
background: transparent;
}
/* ===== Shell ===== */
.page-container { height: 100%; overflow-y: auto; }
.page-shell { padding: 24px; }
.page-shell {
min-height: 100%;
padding: 24px;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--mc-primary-bg) 34%, transparent) 0, transparent 36%),
linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 78%, white 22%) 0%, var(--mc-bg) 100%);
}
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.page-lead { display: flex; flex-direction: column; gap: 8px; }
/* ===== Header ===== */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-lead { display: flex; flex-direction: column; gap: 6px; }
.page-kicker {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 12px;
border: 1px solid color-mix(in srgb, var(--mc-primary) 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mc-primary-bg) 72%, var(--mc-bg-elevated) 28%);
color: var(--mc-primary-hover);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
display: inline-flex; width: fit-content;
padding: 4px 10px; border-radius: 999px;
font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--mc-primary); background: var(--mc-primary-bg);
}
.page-title { font-size: clamp(28px, 4vw, 40px); line-height: 0.95; font-weight: 800; color: var(--mc-text-primary); margin: 0; }
.page-desc { max-width: 620px; font-size: 15px; line-height: 1.55; color: var(--mc-text-secondary); margin: 0; }
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.page-title { font-size: clamp(24px, 3.5vw, 36px); font-weight: 800; color: var(--mc-text-primary); margin: 0; }
.page-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0; }
.header-actions { display: flex; gap: 8px; }
/* ===== Buttons ===== */
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 9px 16px; background: var(--mc-primary); color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; white-space: nowrap; }
.btn-primary:disabled { opacity: .4; cursor: not-allowed; }
.btn-secondary { display: flex; align-items: center; gap: 6px; padding: 9px 16px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 14px; cursor: pointer; white-space: nowrap; }
.btn-secondary:hover { background: var(--mc-bg-sunken); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.page-stage {
background: linear-gradient(180deg, color-mix(in srgb, var(--mc-bg-elevated) 96%, white 4%) 0%, var(--mc-bg-elevated) 100%);
.btn-secondary:disabled { opacity: .4; cursor: not-allowed; }
/* ===== Table — one surface, no nesting ===== */
.table-wrap {
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 18px;
padding: 12px;
box-shadow: 0 18px 48px rgba(152, 93, 63, 0.06);
border-radius: 16px;
overflow: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
padding: 10px 16px; text-align: left;
font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
color: var(--mc-text-tertiary);
background: var(--mc-bg-sunken);
border-bottom: 1px solid var(--mc-border);
white-space: nowrap;
}
.th-center { text-align: center; }
.data-row { border-bottom: 1px solid var(--mc-border-light); transition: background .12s; }
.data-row:last-child { border-bottom: none; }
.data-row:hover { background: var(--mc-bg-sunken); }
.data-table td { padding: 12px 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: middle; }
.td-center { text-align: center; }
/* ===== Server name cell ===== */
.server-info { display: flex; align-items: center; gap: 10px; }
.server-icon {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
background: var(--mc-bg-sunken); color: var(--mc-text-tertiary);
}
.icon-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
.icon-error { color: var(--mc-danger, #ef4444); background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); }
.server-name { font-weight: 600; color: var(--mc-text-primary); }
.server-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.transport-tag {
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
padding: 1px 6px; border-radius: 4px;
color: var(--mc-text-tertiary); background: var(--mc-bg-sunken);
}
.server-desc { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
/* ===== Status cell ===== */
.status-row { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot-connected { background: #34d399; box-shadow: 0 0 4px rgba(52,211,153,.45); }
.dot-disconnected { background: var(--mc-text-tertiary); opacity: .4; }
.dot-error { background: var(--mc-danger, #ef4444); box-shadow: 0 0 4px rgba(239,68,68,.4); }
.status-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.status-time { font-size: 12px; color: var(--mc-text-tertiary); }
.status-time::before { content: '\00b7'; margin: 0 3px; }
.status-error { font-size: 11px; color: var(--mc-danger, #ef4444); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 240px; }
/* ===== Count pill ===== */
.count-pill {
display: inline-block; min-width: 24px; padding: 1px 8px;
border-radius: 8px; font-size: 13px; font-weight: 600; text-align: center;
color: var(--mc-text-secondary); background: var(--mc-bg-sunken);
}
.tools-table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; overflow-x: auto; overflow-y: hidden; }
.tools-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
.tools-table th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.08em; background: color-mix(in srgb, var(--mc-bg-sunken) 86%, white 14%); border-bottom: 1px solid var(--mc-border); }
.tool-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.1s; }
.tool-row:hover { background: var(--mc-bg-sunken); }
.tool-row:last-child { border-bottom: none; }
.tools-table td { padding: 16px; font-size: 14px; color: var(--mc-text-primary); vertical-align: top; }
.tool-info { display: flex; align-items: center; gap: 10px; }
.tool-icon-wrap { width: 32px; height: 32px; background: var(--mc-bg-sunken); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--mc-text-secondary); }
.status-icon-connected { color: var(--mc-primary); background: var(--mc-primary-bg); }
.status-icon-error { color: var(--mc-danger); background: var(--mc-danger-bg); }
.tool-name { max-width: 180px; font-weight: 700; color: var(--mc-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tool-type-inline { margin-top: 6px; }
.tool-desc { max-width: 240px; font-size: 12px; color: var(--mc-text-tertiary); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.type-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
.type-stdio { background: var(--mc-primary-bg); color: var(--mc-primary); }
.type-sse { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.type-streamable_http { background: var(--mc-primary-bg); color: var(--mc-primary-hover); }
.status-badge { padding: 3px 10px; border-radius: 10px; font-size: 12px; font-weight: 500; }
.status-connected { background: var(--mc-primary-bg); color: var(--mc-primary); }
.status-disconnected { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
.status-stack { display: flex; flex-direction: column; gap: 6px; }
.status-time { font-size: 12px; color: var(--mc-text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-error { font-size: 11px; color: var(--mc-danger); margin-top: 2px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tool-count { font-weight: 600; color: var(--mc-text-primary); }
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.row-actions { display: flex; gap: 6px; }
.row-btn { width: 30px; height: 30px; border: 1px solid var(--mc-border); background: var(--mc-bg-elevated); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--mc-text-secondary); transition: all 0.15s; }
.row-btn:hover { background: var(--mc-bg-sunken); }
.row-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.row-btn.danger:hover { background: var(--mc-danger-bg); border-color: var(--mc-danger); color: var(--mc-danger); }
.empty-row { padding: 40px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
.empty-sub { font-size: 13px; margin: 0; }
/* ===== Toggle ===== */
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-track { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: .2s; }
.toggle-track::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: .2s; }
.toggle input:checked + .toggle-track { background: var(--mc-primary); }
.toggle input:checked + .toggle-track::before { transform: translateX(16px); }
/* ===== Row actions ===== */
.row-actions { display: flex; gap: 5px; }
.row-btn {
width: 30px; height: 30px; border: 1px solid var(--mc-border); border-radius: 8px;
background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: var(--mc-text-tertiary); transition: all .12s;
}
.row-btn:hover { background: var(--mc-bg-sunken); color: var(--mc-text-primary); }
.row-btn:disabled { opacity: .3; cursor: not-allowed; }
.row-btn.danger:hover { background: color-mix(in srgb, var(--mc-danger, #ef4444) 10%, transparent); border-color: var(--mc-danger); color: var(--mc-danger); }
/* ===== Empty state ===== */
.empty-row { padding: 48px 16px !important; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--mc-text-tertiary); }
.empty-state p { font-size: 14px; margin: 0; }
.empty-sub { font-size: 12px; }
.test-toast { position: fixed; bottom: 24px; right: 24px; padding: 14px 20px; border-radius: 10px; z-index: 2000; box-shadow: 0 4px 20px rgba(0,0,0,0.15); }
.toast-success { background: var(--mc-primary); color: white; }
.toast-error { background: var(--mc-danger); color: white; }
.toast-title { font-weight: 600; font-size: 14px; }
.toast-detail { font-size: 12px; margin-top: 2px; opacity: 0.9; }
.toast-enter-active, .toast-leave-active { transition: all 0.3s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(20px); }
/* ===== Toast ===== */
.test-toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 18px; border-radius: 10px; z-index: 2000; box-shadow: 0 4px 16px rgba(0,0,0,.15); display: flex; align-items: center; gap: 8px; font-size: 13px; }
.toast-ok { background: var(--mc-primary); color: #fff; }
.toast-fail { background: var(--mc-danger, #ef4444); color: #fff; }
.toast-enter-active, .toast-leave-active { transition: all .25s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(16px); }
.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; }
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.modal-wide { max-width: 600px; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 18px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.modal-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
/* ===== Modal ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 14px; width: 100%; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.modal-wide { max-width: 580px; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--mc-border-light); }
.modal-header h2 { font-size: 17px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
.modal-close { width: 28px; height: 28px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 20px; display: flex; align-items: center; justify-content: center; border-radius: 6px; }
.modal-close:hover { background: var(--mc-bg-sunken); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.detail-item { display: flex; flex-direction: column; gap: 6px; }
.detail-item-full { grid-column: 1 / -1; }
.detail-label { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--mc-text-tertiary); }
.detail-value { font-size: 14px; line-height: 1.5; color: var(--mc-text-primary); word-break: break-word; }
.detail-subvalue { font-size: 12px; color: var(--mc-text-tertiary); white-space: pre-wrap; }
.detail-block { padding: 12px 14px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-bg-sunken); white-space: pre-wrap; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
/* Detail grid */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.detail-item { display: flex; flex-direction: column; gap: 4px; }
.detail-full { grid-column: 1 / -1; }
.detail-label { font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--mc-text-tertiary); }
.detail-value { font-size: 14px; color: var(--mc-text-primary); word-break: break-word; }
.detail-sub { font-size: 12px; color: var(--mc-text-tertiary); }
.detail-code { padding: 8px 12px; border-radius: 8px; background: var(--mc-bg-sunken); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; white-space: pre-wrap; }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-group { display: flex; flex-direction: column; gap: 4px; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,.12); }
.form-textarea { resize: vertical; min-height: 40px; font-family: 'SF Mono', 'Fira Code', monospace; }
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; }
.toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--mc-text-primary); cursor: pointer; }
.toggle-inline input { width: 16px; height: 16px; accent-color: var(--mc-primary); }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Anim */
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
/* Responsive */
@media (max-width: 900px) {
.page-header { flex-direction: column; align-items: stretch; }
.btn-primary, .btn-secondary { width: 100%; justify-content: center; }
.page-header { flex-direction: column; }
.header-actions { width: 100%; }
.btn-primary, .btn-secondary { flex: 1; justify-content: center; }
.detail-grid { grid-template-columns: 1fr; }
}
</style>

View File

@ -1,68 +1,32 @@
<template>
<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" />
<div class="about-badge">v{{ appVersion }}</div>
</div>
<div class="about-hero__content">
<div class="hero-kicker">{{ t('settings.about.heroKicker') }}</div>
<h2 class="hero-title">{{ t('settings.about.heroTitle') }}</h2>
<!-- Hero: Logo + Identity -->
<section class="hero">
<img src="/logo/mateclaw_logo_s.png" alt="MateClaw" class="hero-logo" />
<div class="hero-copy">
<h1 class="hero-title">Mate<span class="hero-accent">Claw</span></h1>
<div class="hero-version">v{{ appVersion }}</div>
<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>
<!-- Three pillars that's all you need to know -->
<section class="pillars">
<div v-for="(pillar, i) in pillars" :key="i" class="pillar">
<div class="pillar-num">{{ String(i + 1).padStart(2, '0') }}</div>
<h3 class="pillar-title">{{ pillar.title }}</h3>
<p class="pillar-desc">{{ pillar.desc }}</p>
</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">
<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>
<!-- Tech stack clean and honest -->
<section class="stack">
<h3 class="stack-heading">{{ t('settings.about.foundationTitle') }}</h3>
<div class="stack-grid">
<div v-for="tech in techStack" :key="tech.name" class="stack-item">
<el-icon class="stack-icon"><component :is="tech.icon" /></el-icon>
<span class="stack-name">{{ tech.name }}</span>
<span class="stack-ver">{{ tech.version }}</span>
</div>
</div>
</section>
@ -70,67 +34,35 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { Coffee, Cpu, Promotion, Monitor, Lightning, Coin } from '@element-plus/icons-vue'
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: '7.x' },
{ icon: '🗄️', name: 'MyBatis Plus', version: '3.5.x' },
{ icon: markRaw(Coffee), name: 'Spring Boot', version: '3.3' },
{ icon: markRaw(Cpu), name: 'Spring AI', version: '1.1' },
{ icon: markRaw(Promotion), name: 'Spring AI Alibaba', version: '1.1' },
{ icon: markRaw(Monitor), name: 'Vue 3', version: '3.5' },
{ icon: markRaw(Lightning), name: 'Vite', version: '7' },
{ icon: markRaw(Coin), name: 'MyBatis Plus', version: '3.5' },
]
</script>
@ -138,238 +70,129 @@ const techStack = [
.about-page {
display: flex;
flex-direction: column;
gap: 20px;
gap: 24px;
}
.about-hero {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
/* ===== Hero ===== */
.hero {
display: flex;
align-items: center;
gap: 24px;
padding: 28px;
overflow: hidden;
position: relative;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 16px;
}
.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;
.hero-logo {
width: 80px;
height: 80px;
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;
}
.hero-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.hero-title {
font-size: 28px;
font-weight: 800;
color: var(--mc-text-primary);
margin: 0;
letter-spacing: -0.03em;
}
.hero-accent { color: var(--mc-primary); }
.hero-version {
font-size: 12px;
font-weight: 600;
color: var(--mc-text-tertiary);
letter-spacing: 0.04em;
}
.hero-desc {
margin: 6px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--mc-text-secondary);
}
/* ===== Pillars ===== */
.pillars {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.pillar {
padding: 20px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
}
.pillar-num {
font-size: 11px;
font-weight: 700;
color: var(--mc-primary);
letter-spacing: 0.1em;
}
.pillar-title {
margin: 10px 0 0;
font-size: 15px;
font-weight: 700;
color: var(--mc-text-primary);
}
.pillar-desc {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--mc-text-secondary);
}
/* ===== Tech Stack ===== */
.stack {
padding: 24px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 14px;
}
.stack-heading {
font-size: 15px;
font-weight: 700;
color: var(--mc-text-primary);
margin: 0 0 16px;
}
.stack-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.stack-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
background: var(--mc-bg-sunken);
}
.stack-icon { font-size: 16px; flex-shrink: 0; color: var(--mc-primary); }
.stack-name { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); }
.stack-ver { font-size: 12px; color: var(--mc-text-tertiary); margin-left: auto; }
/* ===== Responsive ===== */
@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;
}
.hero { flex-direction: column; text-align: center; }
.pillars, .stack-grid { grid-template-columns: 1fr; }
}
</style>