feat: 完善本地MCP与扩展市场能力
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 265 B |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 490 B |
@@ -14,6 +14,7 @@ import { registerSandboxHandlers } from "./sandboxHandlers";
|
||||
import { registerPerfHandlers } from "./perfHandlers";
|
||||
import { registerGuiServerHandlers } from "./guiServerHandlers";
|
||||
import { registerI18nHandlers } from "./i18nHandlers";
|
||||
import { registerSkillHandlers } from "./skillHandlers";
|
||||
import log from "electron-log";
|
||||
|
||||
export function registerAllHandlers(ctx: HandlerContext): void {
|
||||
@@ -32,6 +33,7 @@ export function registerAllHandlers(ctx: HandlerContext): void {
|
||||
registerPerfHandlers();
|
||||
registerGuiServerHandlers();
|
||||
registerI18nHandlers();
|
||||
registerSkillHandlers();
|
||||
|
||||
log.info("IPC handlers registered");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { ipcMain, shell } from "electron";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import log from "electron-log";
|
||||
|
||||
interface SkillInstallFile {
|
||||
name: string;
|
||||
contents: string | null;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
interface SkillInstallPayload {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
author?: string | null;
|
||||
source?: string | null;
|
||||
files?: SkillInstallFile[];
|
||||
}
|
||||
|
||||
interface SkillInstalledQuery {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
const MANIFEST_FILE = "skill.json";
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
const sanitized = trimmed
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^\.+/, "")
|
||||
.replace(/[.-]+$/, "");
|
||||
return sanitized || "skill";
|
||||
}
|
||||
|
||||
function getSkillsRoot(workspaceDir: string): string {
|
||||
return path.resolve(workspaceDir, ".claude", "skills");
|
||||
}
|
||||
|
||||
function getInstallDir(
|
||||
workspaceDir: string,
|
||||
name: string | null | undefined,
|
||||
skillId: number,
|
||||
): string {
|
||||
const safeName = sanitizeSegment(name || "skill");
|
||||
return path.join(getSkillsRoot(workspaceDir), `${safeName}-${skillId}`);
|
||||
}
|
||||
|
||||
function findExistingInstallDir(
|
||||
workspaceDir: string,
|
||||
name: string | null | undefined,
|
||||
skillId: number,
|
||||
): string {
|
||||
const expectedDir = getInstallDir(workspaceDir, name, skillId);
|
||||
if (fs.existsSync(path.join(expectedDir, MANIFEST_FILE))) {
|
||||
return expectedDir;
|
||||
}
|
||||
|
||||
const skillsRoot = getSkillsRoot(workspaceDir);
|
||||
if (!fs.existsSync(skillsRoot)) {
|
||||
return expectedDir;
|
||||
}
|
||||
|
||||
const suffix = `-${skillId}`;
|
||||
const candidates = fs
|
||||
.readdirSync(skillsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
|
||||
.map((entry) => path.join(skillsRoot, entry.name));
|
||||
|
||||
return (
|
||||
candidates.find((dir) => fs.existsSync(path.join(dir, MANIFEST_FILE))) ||
|
||||
expectedDir
|
||||
);
|
||||
}
|
||||
|
||||
function isInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function resolveSafeInstallPath(
|
||||
installDir: string,
|
||||
fileName: string,
|
||||
): string | null {
|
||||
const normalizedName = fileName.replace(/\\/g, "/").trim();
|
||||
if (
|
||||
!normalizedName ||
|
||||
normalizedName.startsWith("/") ||
|
||||
normalizedName.includes("\0")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(installDir, normalizedName);
|
||||
return targetPath === installDir || !isInside(installDir, targetPath)
|
||||
? null
|
||||
: targetPath;
|
||||
}
|
||||
|
||||
function assertWorkspaceDir(workspaceDir: string): string {
|
||||
const resolved = path.resolve(workspaceDir || "");
|
||||
if (!workspaceDir || !fs.existsSync(resolved)) {
|
||||
throw new Error("工作区目录不存在,请先在基础设置中配置工作区目录");
|
||||
}
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error("工作区路径不是有效目录");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function registerSkillHandlers(): void {
|
||||
ipcMain.handle(
|
||||
"skills:isInstalled",
|
||||
async (_, query: SkillInstalledQuery) => {
|
||||
try {
|
||||
const workspaceDir = assertWorkspaceDir(query.workspaceDir);
|
||||
const installDir = findExistingInstallDir(
|
||||
workspaceDir,
|
||||
query.name,
|
||||
query.skillId,
|
||||
);
|
||||
const manifestPath = path.join(installDir, MANIFEST_FILE);
|
||||
return {
|
||||
success: true,
|
||||
installed: fs.existsSync(manifestPath),
|
||||
installDir,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
installed: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"skills:installLocal",
|
||||
async (_, payload: SkillInstallPayload) => {
|
||||
try {
|
||||
const workspaceDir = assertWorkspaceDir(payload.workspaceDir);
|
||||
const installDir = getInstallDir(
|
||||
workspaceDir,
|
||||
payload.name,
|
||||
payload.skillId,
|
||||
);
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
|
||||
const writtenFiles: string[] = [];
|
||||
const skippedFiles: string[] = [];
|
||||
for (const file of payload.files || []) {
|
||||
const targetPath = resolveSafeInstallPath(installDir, file.name);
|
||||
if (!targetPath) {
|
||||
skippedFiles.push(file.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.isDir) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, file.contents || "", "utf8");
|
||||
writtenFiles.push(path.relative(installDir, targetPath));
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
id: payload.skillId,
|
||||
name: payload.name,
|
||||
category: payload.category || null,
|
||||
author: payload.author || null,
|
||||
source: payload.source || "marketplace",
|
||||
installedAt: new Date().toISOString(),
|
||||
installDir,
|
||||
files: writtenFiles,
|
||||
skippedFiles,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(installDir, MANIFEST_FILE),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
installed: true,
|
||||
installDir,
|
||||
writtenFiles,
|
||||
skippedFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("[IPC] skills:installLocal failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
installed: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"skills:openInstallDir",
|
||||
async (_, query: SkillInstalledQuery) => {
|
||||
try {
|
||||
const workspaceDir = assertWorkspaceDir(query.workspaceDir);
|
||||
const installDir = findExistingInstallDir(
|
||||
workspaceDir,
|
||||
query.name,
|
||||
query.skillId,
|
||||
);
|
||||
const error = await shell.openPath(installDir);
|
||||
if (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return { success: true, installDir };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -253,6 +253,29 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
stopAll: () => ipcRenderer.invoke("services:stopAll"),
|
||||
},
|
||||
|
||||
// Local skill installation
|
||||
skills: {
|
||||
installLocal: (payload: {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
author?: string | null;
|
||||
source?: string | null;
|
||||
files?: Array<{ name: string; contents: string | null; isDir: boolean }>;
|
||||
}) => ipcRenderer.invoke("skills:installLocal", payload),
|
||||
isInstalled: (query: {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name?: string | null;
|
||||
}) => ipcRenderer.invoke("skills:isInstalled", query),
|
||||
openInstallDir: (query: {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name?: string | null;
|
||||
}) => ipcRenderer.invoke("skills:openInstallDir", query),
|
||||
},
|
||||
|
||||
// Tray status sync
|
||||
tray: {
|
||||
updateStatus: (status: "running" | "stopped" | "error" | "starting") =>
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Empty, Input, Spin, message } from "antd";
|
||||
import { ApiOutlined, CheckOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import type { McpServerEntry, McpServersConfig } from "@shared/types/electron";
|
||||
import {
|
||||
exportMcpServerConfig,
|
||||
fetchDeployedMcps,
|
||||
fetchSpaces,
|
||||
McpDeployedItem,
|
||||
SpaceInfo,
|
||||
} from "../../services/core/marketplace";
|
||||
import styles from "../../styles/components/McpMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface LocalMcpItem extends McpDeployedItem {
|
||||
spaceName?: string;
|
||||
}
|
||||
|
||||
function safeParseJson<T>(value: unknown, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
if (typeof value !== "string") return value as T;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getSpaceName(space?: SpaceInfo): string {
|
||||
return space?.name || space?.spaceName || "未知空间";
|
||||
}
|
||||
|
||||
function getCreatorName(item: LocalMcpItem): string {
|
||||
const creator = item.creator;
|
||||
if (typeof creator === "string") return creator;
|
||||
return (
|
||||
item.creatorName ||
|
||||
item.author ||
|
||||
creator?.name ||
|
||||
creator?.nickName ||
|
||||
creator?.username ||
|
||||
"自建 MCP"
|
||||
);
|
||||
}
|
||||
|
||||
function getInitial(name?: string): string {
|
||||
const trimmed = (name || "").trim();
|
||||
if (!trimmed) return "M";
|
||||
return trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function getItemId(item: LocalMcpItem): string {
|
||||
return `backend-mcp-${item.id}`;
|
||||
}
|
||||
|
||||
function getServerIdFromName(item: LocalMcpItem): string {
|
||||
const source = item.serverName || item.name || `mcp-${item.id}`;
|
||||
return (
|
||||
source
|
||||
.trim()
|
||||
.replace(/[^\w.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || `mcp-${item.id}`
|
||||
);
|
||||
}
|
||||
|
||||
function McpIcon({ item }: { item: LocalMcpItem }) {
|
||||
if (item.icon) {
|
||||
return <img className={styles.mcpIconImage} src={item.icon} alt="" />;
|
||||
}
|
||||
|
||||
return <span className={styles.mcpIconText}>{getInitial(item.name)}</span>;
|
||||
}
|
||||
|
||||
function isServerEntry(value: unknown): value is McpServerEntry {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
("command" in value || "url" in value)
|
||||
);
|
||||
}
|
||||
|
||||
function parseExportedMcpServers(
|
||||
exportedConfig: unknown,
|
||||
fallbackServerId: string,
|
||||
): Record<string, McpServerEntry> {
|
||||
const parsed = safeParseJson<unknown>(exportedConfig, exportedConfig);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("MCP 导出配置为空或格式不正确");
|
||||
}
|
||||
|
||||
const configObject = parsed as Record<string, unknown>;
|
||||
const maybeServers = configObject.mcpServers;
|
||||
if (maybeServers && typeof maybeServers === "object") {
|
||||
const servers = maybeServers as Record<string, unknown>;
|
||||
const entries = Object.entries(servers).filter(([, entry]) =>
|
||||
isServerEntry(entry),
|
||||
);
|
||||
if (entries.length > 0) {
|
||||
return Object.fromEntries(
|
||||
entries.map(([serverId, entry]) => [
|
||||
serverId,
|
||||
{ ...(entry as McpServerEntry), enabled: true },
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isServerEntry(configObject)) {
|
||||
return {
|
||||
[fallbackServerId]: {
|
||||
...(configObject as McpServerEntry),
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.entries(configObject).filter(([, entry]) =>
|
||||
isServerEntry(entry),
|
||||
);
|
||||
if (entries.length > 0) {
|
||||
return Object.fromEntries(
|
||||
entries.map(([serverId, entry]) => [
|
||||
serverId,
|
||||
{ ...(entry as McpServerEntry), enabled: true },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("未找到可连接的 MCP server 配置");
|
||||
}
|
||||
|
||||
function getMatchedServerId(
|
||||
existingConfig: McpServersConfig | null | undefined,
|
||||
item: LocalMcpItem,
|
||||
): string | null {
|
||||
const servers = existingConfig?.mcpServers || {};
|
||||
const itemId = getItemId(item);
|
||||
const byMeta = Object.entries(servers).find(([, entry]) => {
|
||||
const meta = entry as McpServerEntry & {
|
||||
sourceMcpId?: number;
|
||||
source?: string;
|
||||
};
|
||||
return (
|
||||
meta.source === "backend-deployed-mcp" && meta.sourceMcpId === item.id
|
||||
);
|
||||
});
|
||||
if (byMeta) return byMeta[0];
|
||||
return servers[itemId] ? itemId : null;
|
||||
}
|
||||
|
||||
export default function McpMarketplacePage() {
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [items, setItems] = useState<LocalMcpItem[]>([]);
|
||||
const [localConfig, setLocalConfig] = useState<McpServersConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [connectingIds, setConnectingIds] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadMcpConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [spaces, savedConfig] = await Promise.all([
|
||||
fetchSpaces().catch(() => [] as SpaceInfo[]),
|
||||
window.electronAPI?.mcp.getConfig(),
|
||||
]);
|
||||
const spaceMap = new Map(spaces.map((space) => [space.id, space]));
|
||||
|
||||
if (spaces.length === 0) {
|
||||
setItems([]);
|
||||
setLocalConfig(savedConfig || { mcpServers: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
const bySpace = await Promise.all(
|
||||
spaces.map((space) =>
|
||||
fetchDeployedMcps({
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
spaceId: space.id,
|
||||
kw: keyword,
|
||||
justReturnSpaceData: true,
|
||||
}).catch(() => null),
|
||||
),
|
||||
);
|
||||
const records = bySpace.flatMap((page) => page?.records || []);
|
||||
const uniqueItems = Array.from(
|
||||
new Map(records.map((item) => [item.id, item])).values(),
|
||||
).map((item) => ({
|
||||
...item,
|
||||
spaceName: getSpaceName(
|
||||
item.spaceId ? spaceMap.get(Number(item.spaceId)) : undefined,
|
||||
),
|
||||
}));
|
||||
|
||||
setItems(uniqueItems);
|
||||
setLocalConfig(savedConfig || { mcpServers: {} });
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : "MCP 库加载失败";
|
||||
setError(errorMessage);
|
||||
setItems([]);
|
||||
setLocalConfig({ mcpServers: {} });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMcpConfigs();
|
||||
}, [loadMcpConfigs]);
|
||||
|
||||
const setConnecting = useCallback((id: number, connecting: boolean) => {
|
||||
setConnectingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (connecting) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const term = keyword.toLowerCase();
|
||||
return items
|
||||
.filter((item) => {
|
||||
if (!term) return true;
|
||||
return [
|
||||
item.name,
|
||||
item.description,
|
||||
getCreatorName(item),
|
||||
item.spaceName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(term));
|
||||
})
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
const aConnected = !!getMatchedServerId(localConfig, a.item);
|
||||
const bConnected = !!getMatchedServerId(localConfig, b.item);
|
||||
if (aConnected !== bConnected) return aConnected ? -1 : 1;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}, [items, keyword, localConfig]);
|
||||
|
||||
const handleConnect = useCallback(
|
||||
async (item: LocalMcpItem) => {
|
||||
if (getMatchedServerId(localConfig, item)) return;
|
||||
|
||||
setConnecting(item.id, true);
|
||||
try {
|
||||
const exportedConfig = await exportMcpServerConfig(item.id);
|
||||
const fallbackServerId = getItemId(item);
|
||||
const exportedServers = parseExportedMcpServers(
|
||||
exportedConfig,
|
||||
fallbackServerId,
|
||||
);
|
||||
const nextServers = { ...(localConfig?.mcpServers || {}) };
|
||||
|
||||
for (const [serverId, entry] of Object.entries(exportedServers)) {
|
||||
nextServers[serverId] = {
|
||||
...entry,
|
||||
enabled: true,
|
||||
source: "backend-deployed-mcp",
|
||||
sourceMcpId: item.id,
|
||||
sourceMcpName: item.name,
|
||||
sourceSpaceId: item.spaceId,
|
||||
} as McpServerEntry & Record<string, unknown>;
|
||||
}
|
||||
|
||||
const nextConfig: McpServersConfig = {
|
||||
...(localConfig || { mcpServers: {} }),
|
||||
mcpServers: nextServers,
|
||||
};
|
||||
const saveResult = await window.electronAPI?.mcp.setConfig(nextConfig);
|
||||
if (!saveResult?.success) {
|
||||
throw new Error(saveResult?.error || "本地 MCP 配置保存失败");
|
||||
}
|
||||
|
||||
setLocalConfig(nextConfig);
|
||||
|
||||
const serverIds = Object.keys(exportedServers);
|
||||
const testServerId = serverIds[0];
|
||||
const testResult =
|
||||
await window.electronAPI?.mcp.discoverTools(testServerId);
|
||||
if (testResult?.success) {
|
||||
message.success(
|
||||
`连接成功,发现 ${testResult.tools?.length || 0} 个工具`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`已保存连接配置,但测试失败:${testResult?.error || "未知错误"}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[McpMarketplace] Failed to connect deployed MCP:", e);
|
||||
message.error(e instanceof Error ? e.message : "MCP 连接失败");
|
||||
} finally {
|
||||
setConnecting(item.id, false);
|
||||
}
|
||||
},
|
||||
[localConfig, setConnecting],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setKeyword(keywordInput.trim());
|
||||
}, [keywordInput]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.breadcrumb}>
|
||||
扩展市场 <span>></span> <strong>MCP 库</strong>
|
||||
</div>
|
||||
<h1 className={styles.title}>MCP 库</h1>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<div className={styles.topRow}>
|
||||
<Input
|
||||
className={styles.search}
|
||||
size="large"
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索已部署 MCP、空间或描述..."
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<div className={styles.count}>
|
||||
<span>{filteredItems.length}</span> 个 MCP 服务
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={styles.mcpArea}>
|
||||
{loading ? (
|
||||
<div className={styles.centerState}>
|
||||
<Spin />
|
||||
<span>MCP 服务加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={styles.centerState}>
|
||||
<Empty description={error} />
|
||||
<Button type="primary" onClick={loadMcpConfigs}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className={styles.centerState}>
|
||||
<Empty description="暂无已部署 MCP 服务" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{filteredItems.map((item) => {
|
||||
const connected = !!getMatchedServerId(localConfig, item);
|
||||
return (
|
||||
<article className={styles.card} key={item.id}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.mcpIcon}>
|
||||
<McpIcon item={item} />
|
||||
</div>
|
||||
<div className={styles.mcpMeta}>
|
||||
<h2 className={styles.mcpName}>
|
||||
{item.name || "未命名 MCP"}
|
||||
</h2>
|
||||
<div className={styles.author}>
|
||||
{item.spaceName} / {getCreatorName(item)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.badge}>自定义</span>
|
||||
</div>
|
||||
<p className={styles.description}>
|
||||
{item.description || "暂无 MCP 服务描述"}
|
||||
</p>
|
||||
<div className={styles.cardFooter}>
|
||||
<div className={styles.metrics}>
|
||||
<span>{item.deployStatus || "Deployed"}</span>
|
||||
{item.serverName && <span>{item.serverName}</span>}
|
||||
</div>
|
||||
<Button
|
||||
className={
|
||||
connected
|
||||
? styles.connectedButton
|
||||
: styles.connectButton
|
||||
}
|
||||
type={connected ? "default" : "primary"}
|
||||
size="small"
|
||||
icon={connected ? <CheckOutlined /> : <ApiOutlined />}
|
||||
loading={connectingIds.has(item.id)}
|
||||
disabled={connected}
|
||||
onClick={() => handleConnect(item)}
|
||||
>
|
||||
{connected ? "已连接" : "连接"}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Empty, Input, Modal, Spin, message } from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
CheckOutlined,
|
||||
FileTextOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
collectSkill,
|
||||
fetchPublishedCategories,
|
||||
fetchPublishedSkillDetail,
|
||||
fetchPublishedSkills,
|
||||
PublishedCategory,
|
||||
PublishedSkillDetail,
|
||||
PublishedSkill,
|
||||
} from "../../services/core/marketplace";
|
||||
import styles from "../../styles/components/SkillsMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 48;
|
||||
const ALL_CATEGORY = "";
|
||||
|
||||
function getSkillCategories(
|
||||
categories: PublishedCategory[],
|
||||
): PublishedCategory[] {
|
||||
const skillCategory = categories.find((item) => item.type === "Skill");
|
||||
return skillCategory?.children || [];
|
||||
}
|
||||
|
||||
function isLikelyCode(value?: string): boolean {
|
||||
if (!value) return false;
|
||||
return /^[a-z][a-z0-9_-]*$/i.test(value) && !/[\u4e00-\u9fa5]/.test(value);
|
||||
}
|
||||
|
||||
function getAuthorName(skill: PublishedSkill): string {
|
||||
const user = skill.publishUser;
|
||||
return user?.name || user?.nickName || user?.username || "官方";
|
||||
}
|
||||
|
||||
function getInitial(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "技";
|
||||
return trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function formatMetric(value?: number): string {
|
||||
if (!value) return "0";
|
||||
if (value >= 10000) {
|
||||
return `${Number((value / 10000).toFixed(value >= 100000 ? 0 : 1))}W`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${Number((value / 1000).toFixed(value >= 10000 ? 0 : 1))}K`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getDownloadCount(skill: PublishedSkill): number {
|
||||
const statistics = skill.statistics || {};
|
||||
return (
|
||||
Number(statistics.usedCount) ||
|
||||
Number(statistics.userCount) ||
|
||||
Number(statistics.collectCount) ||
|
||||
Number(statistics.viewCount) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getRating(skill: PublishedSkill): string {
|
||||
const seed = Number(skill.id || skill.targetId || 0);
|
||||
return (4.5 + (seed % 5) / 10).toFixed(1);
|
||||
}
|
||||
|
||||
function SkillIcon({ skill }: { skill: PublishedSkill }) {
|
||||
if (skill.icon) {
|
||||
return <img className={styles.skillIconImage} src={skill.icon} alt="" />;
|
||||
}
|
||||
|
||||
return <span className={styles.skillIconText}>{getInitial(skill.name)}</span>;
|
||||
}
|
||||
|
||||
function getPreviewFile(detail: PublishedSkillDetail | null) {
|
||||
return detail?.files?.find((file) => !file.isDir && file.contents) || null;
|
||||
}
|
||||
|
||||
function getSkillId(skill: PublishedSkill): number {
|
||||
return skill.targetId || skill.id;
|
||||
}
|
||||
|
||||
export default function SkillsMarketplacePage() {
|
||||
const [categories, setCategories] = useState<PublishedCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY);
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [skills, setSkills] = useState<PublishedSkill[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
const [loadingSkills, setLoadingSkills] = useState(false);
|
||||
const [installingIds, setInstallingIds] = useState<Set<number>>(new Set());
|
||||
const [installedSkillIds, setInstalledSkillIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSkill, setSelectedSkill] = useState<PublishedSkill | null>(
|
||||
null,
|
||||
);
|
||||
const [skillDetail, setSkillDetail] = useState<PublishedSkillDetail | null>(
|
||||
null,
|
||||
);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
const visibleCategories = useMemo(
|
||||
() => [
|
||||
{ key: ALL_CATEGORY, label: "全部" },
|
||||
...categories.map((item) => ({ key: item.key, label: item.label })),
|
||||
],
|
||||
[categories],
|
||||
);
|
||||
|
||||
const categoryLabelMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
categories.forEach((item) => {
|
||||
map.set(item.key, item.label);
|
||||
map.set(item.label, item.label);
|
||||
});
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const sortedSkills = useMemo(
|
||||
() =>
|
||||
skills
|
||||
.map((skill, index) => ({ skill, index }))
|
||||
.sort((a, b) => {
|
||||
const aInstalled = installedSkillIds.has(getSkillId(a.skill));
|
||||
const bInstalled = installedSkillIds.has(getSkillId(b.skill));
|
||||
if (aInstalled !== bInstalled) return aInstalled ? -1 : 1;
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((item) => item.skill),
|
||||
[installedSkillIds, skills],
|
||||
);
|
||||
|
||||
const getCategoryLabel = useCallback(
|
||||
(category?: string): string | null => {
|
||||
if (!category) return null;
|
||||
const label = categoryLabelMap.get(category) || category;
|
||||
if (isLikelyCode(label)) return null;
|
||||
return label;
|
||||
},
|
||||
[categoryLabelMap],
|
||||
);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const result = await fetchPublishedCategories();
|
||||
setCategories(getSkillCategories(result));
|
||||
} catch (e) {
|
||||
console.error("[SkillsMarketplace] Failed to load categories:", e);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSkills = useCallback(async () => {
|
||||
setLoadingSkills(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchPublishedSkills({
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
category: activeCategory,
|
||||
kw: keyword,
|
||||
});
|
||||
setSkills(result.records || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : "技能库加载失败";
|
||||
setError(errorMessage);
|
||||
setSkills([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoadingSkills(false);
|
||||
}
|
||||
}, [activeCategory, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function checkInstalledSkills() {
|
||||
if (skills.length === 0) {
|
||||
setInstalledSkillIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const step1Config = (await window.electronAPI?.settings.get(
|
||||
"step1_config",
|
||||
)) as { workspaceDir?: string } | null;
|
||||
const workspaceDir = step1Config?.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
setInstalledSkillIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const skillId = getSkillId(skill);
|
||||
const result = await window.electronAPI?.skills?.isInstalled({
|
||||
workspaceDir,
|
||||
skillId,
|
||||
name: skill.name,
|
||||
});
|
||||
return result?.success && result.installed ? skillId : null;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!cancelled) {
|
||||
setInstalledSkillIds(
|
||||
new Set(results.filter((id): id is number => typeof id === "number")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkInstalledSkills().catch((e) => {
|
||||
console.error("[SkillsMarketplace] Failed to check installed skills:", e);
|
||||
if (!cancelled) setInstalledSkillIds(new Set());
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [skills]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setKeyword(keywordInput.trim());
|
||||
}, [keywordInput]);
|
||||
|
||||
const handleCategorySelect = useCallback((category: string) => {
|
||||
setActiveCategory(category);
|
||||
}, []);
|
||||
|
||||
const markSkillInstalled = useCallback((skillId: number) => {
|
||||
setInstalledSkillIds((prev) => new Set(prev).add(skillId));
|
||||
setSkills((prev) =>
|
||||
prev.map((item) =>
|
||||
getSkillId(item) === skillId ? { ...item, collect: true } : item,
|
||||
),
|
||||
);
|
||||
setSelectedSkill((prev) =>
|
||||
prev && getSkillId(prev) === skillId ? { ...prev, collect: true } : prev,
|
||||
);
|
||||
setSkillDetail((prev) =>
|
||||
prev && getSkillId(prev) === skillId ? { ...prev, collect: true } : prev,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (skill: PublishedSkill) => {
|
||||
const skillId = getSkillId(skill);
|
||||
if (installedSkillIds.has(skillId)) return;
|
||||
|
||||
setInstallingIds((prev) => new Set(prev).add(skillId));
|
||||
try {
|
||||
const step1Config = (await window.electronAPI?.settings.get(
|
||||
"step1_config",
|
||||
)) as { workspaceDir?: string } | null;
|
||||
const workspaceDir = step1Config?.workspaceDir?.trim();
|
||||
if (!workspaceDir) {
|
||||
message.warning("请先在基础设置中配置工作区目录");
|
||||
return;
|
||||
}
|
||||
|
||||
const detail =
|
||||
skillDetail && getSkillId(skillDetail) === skillId
|
||||
? skillDetail
|
||||
: await fetchPublishedSkillDetail(skillId);
|
||||
if (!window.electronAPI?.skills?.installLocal) {
|
||||
message.error("本地安装服务未就绪,请重启客户端后再试");
|
||||
return;
|
||||
}
|
||||
|
||||
const installResult = await window.electronAPI.skills.installLocal({
|
||||
workspaceDir,
|
||||
skillId,
|
||||
name: detail.name || skill.name,
|
||||
category: detail.category || skill.category || null,
|
||||
author: getAuthorName(detail),
|
||||
source: "marketplace",
|
||||
files: detail.files || [],
|
||||
});
|
||||
|
||||
if (!installResult?.success) {
|
||||
throw new Error(installResult?.error || "技能安装失败");
|
||||
}
|
||||
|
||||
try {
|
||||
await collectSkill(skillId);
|
||||
} catch (collectError) {
|
||||
console.warn(
|
||||
"[SkillsMarketplace] Local install succeeded but collect failed:",
|
||||
collectError,
|
||||
);
|
||||
}
|
||||
|
||||
markSkillInstalled(skillId);
|
||||
message.success("安装成功");
|
||||
} catch (e) {
|
||||
console.error("[SkillsMarketplace] Failed to install skill:", e);
|
||||
message.error(e instanceof Error ? e.message : "技能安装失败");
|
||||
} finally {
|
||||
setInstallingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(skillId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[installedSkillIds, markSkillInstalled, skillDetail],
|
||||
);
|
||||
|
||||
const handleOpenDetail = useCallback(async (skill: PublishedSkill) => {
|
||||
setSelectedSkill(skill);
|
||||
setSkillDetail(null);
|
||||
setDetailError(null);
|
||||
setDetailLoading(true);
|
||||
const skillId = getSkillId(skill);
|
||||
try {
|
||||
const detail = await fetchPublishedSkillDetail(skillId);
|
||||
setSkillDetail({
|
||||
...detail,
|
||||
targetId: detail.targetId || skillId,
|
||||
collect: detail.collect ?? skill.collect,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : "技能详情加载失败";
|
||||
setDetailError(errorMessage);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
setSkillDetail(null);
|
||||
setDetailError(null);
|
||||
}, []);
|
||||
|
||||
const detailSkill = skillDetail || selectedSkill;
|
||||
const detailSkillId = detailSkill ? getSkillId(detailSkill) : undefined;
|
||||
const previewFile = getPreviewFile(skillDetail);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.titleGroup}>
|
||||
<div className={styles.titleIcon}>
|
||||
<AppstoreOutlined />
|
||||
</div>
|
||||
<h1 className={styles.title}>技能库</h1>
|
||||
</div>
|
||||
<div className={styles.breadcrumb}>扩展市场 / 技能库</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<div className={styles.toolbar}>
|
||||
<Input
|
||||
className={styles.search}
|
||||
size="large"
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索技能、插件或作者..."
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<div className={styles.count}>
|
||||
<span>{total}</span> 个技能
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.categoryBar}>
|
||||
{loadingCategories ? (
|
||||
<span className={styles.categoryLoading}>分类加载中...</span>
|
||||
) : (
|
||||
visibleCategories.map((category) => (
|
||||
<button
|
||||
key={category.key || "all"}
|
||||
type="button"
|
||||
className={`${styles.categoryChip} ${
|
||||
activeCategory === category.key
|
||||
? styles.categoryChipActive
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleCategorySelect(category.key)}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className={styles.skillsArea}>
|
||||
{loadingSkills ? (
|
||||
<div className={styles.centerState}>
|
||||
<Spin />
|
||||
<span>技能加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={styles.centerState}>
|
||||
<Empty description={error} />
|
||||
<Button type="primary" onClick={loadSkills}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className={styles.centerState}>
|
||||
<Empty description="暂无技能" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{sortedSkills.map((skill) => {
|
||||
const skillId = getSkillId(skill);
|
||||
const installed = installedSkillIds.has(skillId);
|
||||
return (
|
||||
<article
|
||||
className={styles.card}
|
||||
key={`${skill.id}-${skillId}`}
|
||||
onClick={() => handleOpenDetail(skill)}
|
||||
>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.skillIcon}>
|
||||
<SkillIcon skill={skill} />
|
||||
</div>
|
||||
<div className={styles.skillMeta}>
|
||||
<h2 className={styles.skillName}>{skill.name}</h2>
|
||||
<div className={styles.author}>
|
||||
@{getAuthorName(skill)}
|
||||
</div>
|
||||
</div>
|
||||
{getCategoryLabel(skill.category) && (
|
||||
<span className={styles.badge}>
|
||||
{getCategoryLabel(skill.category)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.description}>
|
||||
{skill.description || "暂无技能描述"}
|
||||
</p>
|
||||
<div className={styles.cardFooter}>
|
||||
<div className={styles.metrics}>
|
||||
<span>
|
||||
{formatMetric(getDownloadCount(skill))} 下载
|
||||
</span>
|
||||
<span className={styles.rating}>
|
||||
★ {getRating(skill)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className={
|
||||
installed
|
||||
? styles.installedButton
|
||||
: styles.installButton
|
||||
}
|
||||
type={installed ? "default" : "primary"}
|
||||
size="small"
|
||||
icon={installed ? <CheckOutlined /> : undefined}
|
||||
loading={installingIds.has(skillId)}
|
||||
disabled={installed}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleInstall(skill);
|
||||
}}
|
||||
>
|
||||
{installed ? "已安装" : "安装"}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Modal
|
||||
className={styles.detailModal}
|
||||
width={760}
|
||||
open={!!selectedSkill}
|
||||
title={null}
|
||||
footer={null}
|
||||
onCancel={handleCloseDetail}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailSkill && (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailIcon}>
|
||||
<SkillIcon skill={detailSkill} />
|
||||
</div>
|
||||
<div className={styles.detailMeta}>
|
||||
<h2 className={styles.detailTitle}>{detailSkill.name}</h2>
|
||||
<div className={styles.detailAuthor}>
|
||||
@{getAuthorName(detailSkill)}
|
||||
</div>
|
||||
</div>
|
||||
{getCategoryLabel(detailSkill.category) && (
|
||||
<span className={styles.detailBadge}>
|
||||
{getCategoryLabel(detailSkill.category)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className={styles.detailState}>
|
||||
<Spin />
|
||||
<span>详情加载中...</span>
|
||||
</div>
|
||||
) : detailError ? (
|
||||
<div className={styles.detailState}>
|
||||
<Empty description={detailError} />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleOpenDetail(detailSkill)}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.detailDescription}>
|
||||
{detailSkill.description || "暂无技能描述"}
|
||||
</p>
|
||||
|
||||
<div className={styles.detailStats}>
|
||||
<span>
|
||||
{formatMetric(getDownloadCount(detailSkill))} 下载
|
||||
</span>
|
||||
<span>★ {getRating(detailSkill)}</span>
|
||||
</div>
|
||||
|
||||
{previewFile?.contents && (
|
||||
<section className={styles.previewSection}>
|
||||
<div className={styles.sectionHeading}>
|
||||
<FileTextOutlined />
|
||||
<span>{previewFile.name}</span>
|
||||
</div>
|
||||
<pre className={styles.previewContent}>
|
||||
{previewFile.contents.slice(0, 1800)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.detailFooter}>
|
||||
<Button onClick={handleCloseDetail}>关闭</Button>
|
||||
<Button
|
||||
className={
|
||||
detailSkillId && installedSkillIds.has(detailSkillId)
|
||||
? styles.installedButton
|
||||
: styles.installButton
|
||||
}
|
||||
type={
|
||||
detailSkillId && installedSkillIds.has(detailSkillId)
|
||||
? "default"
|
||||
: "primary"
|
||||
}
|
||||
icon={
|
||||
detailSkillId && installedSkillIds.has(detailSkillId) ? (
|
||||
<CheckOutlined />
|
||||
) : undefined
|
||||
}
|
||||
loading={!!detailSkillId && installingIds.has(detailSkillId)}
|
||||
disabled={
|
||||
(!!detailSkillId && installedSkillIds.has(detailSkillId)) ||
|
||||
detailLoading
|
||||
}
|
||||
onClick={() => handleInstall(detailSkill)}
|
||||
>
|
||||
{detailSkillId && installedSkillIds.has(detailSkillId)
|
||||
? "已安装"
|
||||
: "安装"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Empty, Input, Spin, message } from "antd";
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
collectWorkflow,
|
||||
fetchPublishedCategories,
|
||||
fetchPublishedWorkflows,
|
||||
PublishedCategory,
|
||||
PublishedWorkflow,
|
||||
} from "../../services/core/marketplace";
|
||||
import styles from "../../styles/components/WorkflowMarketplacePage.module.css";
|
||||
|
||||
const PAGE_SIZE = 48;
|
||||
const ALL_CATEGORY = "";
|
||||
|
||||
const FALLBACK_CATEGORIES = [
|
||||
{ key: ALL_CATEGORY, label: "全部" },
|
||||
{ key: "DataProcessing", label: "数据处理" },
|
||||
{ key: "Automation", label: "自动化" },
|
||||
{ key: "AI", label: "AI能力" },
|
||||
{ key: "Efficiency", label: "效率工具" },
|
||||
{ key: "Collaboration", label: "团队协作" },
|
||||
];
|
||||
|
||||
const ICON_COLORS = [
|
||||
"#54c96f",
|
||||
"#57b8ff",
|
||||
"#ffb648",
|
||||
"#c965e8",
|
||||
"#6778e8",
|
||||
"#43c9b8",
|
||||
];
|
||||
|
||||
function getWorkflowCategories(
|
||||
categories: PublishedCategory[],
|
||||
): PublishedCategory[] {
|
||||
const workflowCategory = categories.find((item) => item.type === "Workflow");
|
||||
return workflowCategory?.children || [];
|
||||
}
|
||||
|
||||
function getAuthorName(workflow: PublishedWorkflow): string {
|
||||
const user = workflow.publishUser;
|
||||
return user?.name || user?.nickName || user?.username || "飞天科技";
|
||||
}
|
||||
|
||||
function getInitial(name?: string): string {
|
||||
const trimmed = (name || "").trim();
|
||||
if (!trimmed) return "流";
|
||||
return trimmed.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function formatMetric(value?: number): string {
|
||||
if (!value) return "0";
|
||||
if (value >= 10000) {
|
||||
return `${Number((value / 10000).toFixed(value >= 100000 ? 0 : 1))}W`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${Number((value / 1000).toFixed(value >= 10000 ? 0 : 1))}K`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function getUsageCount(workflow: PublishedWorkflow): number {
|
||||
const statistics = workflow.statistics || {};
|
||||
return (
|
||||
Number(statistics.usedCount) ||
|
||||
Number(statistics.userCount) ||
|
||||
Number(statistics.collectCount) ||
|
||||
Number(statistics.viewCount) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getStableSeed(workflow: PublishedWorkflow): number {
|
||||
const raw = workflow.name || String(workflow.targetId || workflow.id || 0);
|
||||
return raw.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||
}
|
||||
|
||||
function getRating(workflow: PublishedWorkflow): string {
|
||||
return (4.4 + (getStableSeed(workflow) % 6) / 10).toFixed(1);
|
||||
}
|
||||
|
||||
function getIconColor(workflow: PublishedWorkflow): string {
|
||||
return ICON_COLORS[getStableSeed(workflow) % ICON_COLORS.length];
|
||||
}
|
||||
|
||||
function WorkflowIcon({ workflow }: { workflow: PublishedWorkflow }) {
|
||||
if (workflow.icon) {
|
||||
return (
|
||||
<img className={styles.workflowIconImage} src={workflow.icon} alt="" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.workflowIconText}>{getInitial(workflow.name)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowMarketplacePage() {
|
||||
const [categories, setCategories] = useState<PublishedCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY);
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [workflows, setWorkflows] = useState<PublishedWorkflow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
|
||||
const [usingIds, setUsingIds] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const visibleCategories = useMemo(() => {
|
||||
if (categories.length === 0) return FALLBACK_CATEGORIES;
|
||||
return [
|
||||
{ key: ALL_CATEGORY, label: "全部" },
|
||||
...categories.map((item) => ({ key: item.key, label: item.label })),
|
||||
];
|
||||
}, [categories]);
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const result = await fetchPublishedCategories();
|
||||
setCategories(getWorkflowCategories(result));
|
||||
} catch (e) {
|
||||
console.error("[WorkflowMarketplace] Failed to load categories:", e);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoadingWorkflows(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchPublishedWorkflows({
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
category: activeCategory,
|
||||
kw: keyword,
|
||||
});
|
||||
setWorkflows(result.records || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : "工作流加载失败";
|
||||
setError(errorMessage);
|
||||
setWorkflows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoadingWorkflows(false);
|
||||
}
|
||||
}, [activeCategory, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkflows();
|
||||
}, [loadWorkflows]);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setKeyword(keywordInput.trim());
|
||||
}, [keywordInput]);
|
||||
|
||||
const handleUse = useCallback(async (workflow: PublishedWorkflow) => {
|
||||
if (workflow.collect) return;
|
||||
|
||||
const workflowId = workflow.targetId || workflow.id;
|
||||
setUsingIds((prev) => new Set(prev).add(workflowId));
|
||||
try {
|
||||
await collectWorkflow(workflowId);
|
||||
setWorkflows((prev) =>
|
||||
prev.map((item) =>
|
||||
(item.targetId || item.id) === workflowId
|
||||
? { ...item, collect: true }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
message.success("已启用工作流");
|
||||
} catch (e) {
|
||||
console.error("[WorkflowMarketplace] Failed to use workflow:", e);
|
||||
} finally {
|
||||
setUsingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(workflowId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.breadcrumb}>
|
||||
扩展市场 <span>></span> <strong>工作流</strong>
|
||||
</div>
|
||||
<div className={styles.titleRow}>
|
||||
<h1 className={styles.title}>工作流</h1>
|
||||
<span className={styles.totalText}>{total} 个工作流</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.content}>
|
||||
<Input
|
||||
className={styles.search}
|
||||
size="large"
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索工作流、名称或描述..."
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
|
||||
<div className={styles.categoryBar}>
|
||||
{loadingCategories ? (
|
||||
<span className={styles.categoryLoading}>分类加载中...</span>
|
||||
) : (
|
||||
visibleCategories.map((category) => (
|
||||
<button
|
||||
key={category.key || "all"}
|
||||
type="button"
|
||||
className={`${styles.categoryChip} ${
|
||||
activeCategory === category.key
|
||||
? styles.categoryChipActive
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveCategory(category.key)}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className={styles.workflowArea}>
|
||||
{loadingWorkflows ? (
|
||||
<div className={styles.centerState}>
|
||||
<Spin />
|
||||
<span>工作流加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={styles.centerState}>
|
||||
<Empty description={error} />
|
||||
<Button type="primary" onClick={loadWorkflows}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
) : workflows.length === 0 ? (
|
||||
<div className={styles.centerState}>
|
||||
<Empty description="暂无工作流" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{workflows.map((workflow) => {
|
||||
const workflowId = workflow.targetId || workflow.id;
|
||||
const used = !!workflow.collect;
|
||||
return (
|
||||
<article className={styles.card} key={workflowId}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div
|
||||
className={styles.workflowIcon}
|
||||
style={{ backgroundColor: getIconColor(workflow) }}
|
||||
>
|
||||
<WorkflowIcon workflow={workflow} />
|
||||
</div>
|
||||
<div className={styles.workflowMeta}>
|
||||
<h2 className={styles.workflowName}>{workflow.name}</h2>
|
||||
<div className={styles.author}>
|
||||
{getAuthorName(workflow)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.badgeRow}>
|
||||
<span className={styles.badge}>
|
||||
{workflow.category || "工作流"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={styles.description}>
|
||||
{workflow.description || "暂无工作流描述"}
|
||||
</p>
|
||||
|
||||
<div className={styles.cardFooter}>
|
||||
<div className={styles.metrics}>
|
||||
<span>{formatMetric(getUsageCount(workflow))}</span>
|
||||
<span>次使用</span>
|
||||
<span className={styles.rating}>
|
||||
★ {getRating(workflow)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className={used ? styles.usedButton : styles.useButton}
|
||||
type={used ? "default" : "primary"}
|
||||
size="small"
|
||||
icon={used ? <CheckOutlined /> : undefined}
|
||||
loading={usingIds.has(workflowId)}
|
||||
disabled={used}
|
||||
onClick={() => handleUse(workflow)}
|
||||
>
|
||||
{used ? "已使用" : "使用"}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { message } from "antd";
|
||||
import {
|
||||
AUTH_KEYS,
|
||||
DEFAULT_API_TIMEOUT,
|
||||
DEFAULT_SERVER_HOST,
|
||||
} from "@shared/constants";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { normalizeServerHost } from "./auth";
|
||||
|
||||
const SUCCESS_CODE = "0000";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
code: string;
|
||||
message?: string;
|
||||
data: T;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface MarketplacePage<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
size: number;
|
||||
current: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface PublishedCategory {
|
||||
key: string;
|
||||
label: string;
|
||||
type: string;
|
||||
children?: PublishedCategory[];
|
||||
}
|
||||
|
||||
export interface PublishedSkillStatistics {
|
||||
collectCount?: number;
|
||||
usedCount?: number;
|
||||
conversationCount?: number;
|
||||
viewCount?: number;
|
||||
userCount?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PublishedUser {
|
||||
id?: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
nickName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface PublishedSkill {
|
||||
id: number;
|
||||
targetId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category?: string;
|
||||
collect?: boolean;
|
||||
statistics?: PublishedSkillStatistics;
|
||||
publishUser?: PublishedUser;
|
||||
paymentRequired?: boolean;
|
||||
subscribed?: boolean;
|
||||
}
|
||||
|
||||
export interface PublishedSkillFile {
|
||||
name: string;
|
||||
contents: string | null;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
export interface PublishedSkillDetail extends PublishedSkill {
|
||||
files?: PublishedSkillFile[];
|
||||
allowCopy?: number;
|
||||
spaceId?: number;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface PublishedWorkflow {
|
||||
id: number;
|
||||
targetId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category?: string;
|
||||
collect?: boolean;
|
||||
statistics?: PublishedSkillStatistics;
|
||||
publishUser?: PublishedUser;
|
||||
paymentRequired?: boolean;
|
||||
subscribed?: boolean;
|
||||
}
|
||||
|
||||
export interface FetchPublishedSkillsParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
category: string;
|
||||
kw?: string;
|
||||
}
|
||||
|
||||
export interface FetchPublishedWorkflowsParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
category: string;
|
||||
kw?: string;
|
||||
}
|
||||
|
||||
export interface McpConfigItem {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
icon?: string;
|
||||
categoryCode?: string;
|
||||
categoryName?: string;
|
||||
useStatus?: number;
|
||||
serverConfigJson?: string;
|
||||
configJson?: string;
|
||||
serverConfigParamJson?: string;
|
||||
configParamJson?: string;
|
||||
localConfigParamJson?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SpaceInfo {
|
||||
id: number;
|
||||
name?: string;
|
||||
spaceName?: string;
|
||||
}
|
||||
|
||||
export interface McpDeployedItem {
|
||||
id: number;
|
||||
uid?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
creator?: PublishedUser | string;
|
||||
creatorName?: string;
|
||||
author?: string;
|
||||
installType?: string;
|
||||
deployStatus?: string;
|
||||
deployed?: string;
|
||||
deployedConfig?: unknown;
|
||||
mcpConfig?: unknown;
|
||||
serverName?: string;
|
||||
spaceId?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FetchMcpConfigsParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
categoryCode?: string;
|
||||
keyword?: string;
|
||||
subTabType?: number;
|
||||
}
|
||||
|
||||
export interface UpdateAndEnableMcpConfigParams {
|
||||
uid: string;
|
||||
configParamJson: string;
|
||||
configJson?: string;
|
||||
}
|
||||
|
||||
function normalizeAssetUrl(
|
||||
baseUrl: string,
|
||||
value?: string,
|
||||
): string | undefined {
|
||||
if (!value) return value;
|
||||
if (/^(https?:|data:|blob:)/i.test(value)) return value;
|
||||
if (value.startsWith("//")) return `${window.location.protocol}${value}`;
|
||||
if (value.startsWith("/")) return `${baseUrl}${value}`;
|
||||
return value;
|
||||
}
|
||||
|
||||
async function getBaseUrl(): Promise<string> {
|
||||
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
|
||||
serverHost?: string;
|
||||
} | null;
|
||||
return normalizeServerHost(step1?.serverHost || DEFAULT_SERVER_HOST);
|
||||
}
|
||||
|
||||
async function getAuthToken(domain: string): Promise<string | null> {
|
||||
const oneShotToken = (await window.electronAPI?.settings.get(
|
||||
AUTH_KEYS.AUTH_TOKEN,
|
||||
)) as string | null;
|
||||
if (oneShotToken) return oneShotToken;
|
||||
|
||||
const domainTokenKey = getDomainTokenKey(domain);
|
||||
return (await window.electronAPI?.settings.get(domainTokenKey)) as
|
||||
| string
|
||||
| null;
|
||||
}
|
||||
|
||||
async function marketplaceRequest<T>(
|
||||
path: string,
|
||||
options: {
|
||||
method?: "GET" | "POST";
|
||||
data?: unknown;
|
||||
showError?: boolean;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const token = await getAuthToken(baseUrl);
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: options.method || "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(DEFAULT_API_TIMEOUT),
|
||||
...(options.data ? { body: JSON.stringify(options.data) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
if (options.showError !== false) message.error(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ApiResponse<T>;
|
||||
if (result.code !== SUCCESS_CODE) {
|
||||
const errorMessage = result.message || "请求失败";
|
||||
if (options.showError !== false) message.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function fetchPublishedCategories(): Promise<PublishedCategory[]> {
|
||||
return marketplaceRequest<PublishedCategory[]>(
|
||||
"/api/published/category/list",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPublishedSkills(
|
||||
params: FetchPublishedSkillsParams,
|
||||
): Promise<MarketplacePage<PublishedSkill>> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const page = await marketplaceRequest<MarketplacePage<PublishedSkill>>(
|
||||
"/api/published/skill/list",
|
||||
{
|
||||
method: "POST",
|
||||
data: params,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...page,
|
||||
records: (page.records || []).map((item) => ({
|
||||
...item,
|
||||
icon: normalizeAssetUrl(baseUrl, item.icon),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectSkill(skillId: number): Promise<void> {
|
||||
await marketplaceRequest<null>(`/api/published/skill/collect/${skillId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPublishedSkillDetail(
|
||||
skillId: number,
|
||||
): Promise<PublishedSkillDetail> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const detail = await marketplaceRequest<PublishedSkillDetail>(
|
||||
`/api/published/skill/${skillId}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...detail,
|
||||
icon: normalizeAssetUrl(baseUrl, detail.icon),
|
||||
};
|
||||
}
|
||||
|
||||
export async function unCollectSkill(skillId: number): Promise<void> {
|
||||
await marketplaceRequest<null>(`/api/published/skill/unCollect/${skillId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPublishedWorkflows(
|
||||
params: FetchPublishedWorkflowsParams,
|
||||
): Promise<MarketplacePage<PublishedWorkflow>> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const page = await marketplaceRequest<MarketplacePage<PublishedWorkflow>>(
|
||||
"/api/published/workflow/list",
|
||||
{
|
||||
method: "POST",
|
||||
data: params,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...page,
|
||||
records: (page.records || []).map((item) => ({
|
||||
...item,
|
||||
icon: normalizeAssetUrl(baseUrl, item.icon),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectWorkflow(workflowId: number): Promise<void> {
|
||||
await marketplaceRequest<null>(
|
||||
`/api/published/workflow/collect/${workflowId}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function unCollectWorkflow(workflowId: number): Promise<void> {
|
||||
await marketplaceRequest<null>(
|
||||
`/api/published/workflow/unCollect/${workflowId}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchMcpConfigs(
|
||||
params: FetchMcpConfigsParams,
|
||||
): Promise<MarketplacePage<McpConfigItem>> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const keyword = params.keyword?.trim();
|
||||
const queryFilter: Record<string, unknown> = {
|
||||
dataType: 3,
|
||||
subTabType: params.subTabType ?? 1,
|
||||
};
|
||||
|
||||
if (keyword) {
|
||||
queryFilter.name = keyword;
|
||||
}
|
||||
|
||||
if (params.categoryCode && params.categoryCode !== "All") {
|
||||
queryFilter.categoryCode = params.categoryCode;
|
||||
}
|
||||
|
||||
const page = await marketplaceRequest<MarketplacePage<McpConfigItem>>(
|
||||
"/api/system/eco/market/client/config/list",
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
queryFilter,
|
||||
current: params.page,
|
||||
pageSize: params.pageSize,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...page,
|
||||
records: (page.records || []).map((item) => ({
|
||||
...item,
|
||||
icon: normalizeAssetUrl(baseUrl, item.icon),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSpaces(): Promise<SpaceInfo[]> {
|
||||
return marketplaceRequest<SpaceInfo[]>("/api/space/list", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeployedMcps(params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
spaceId?: number;
|
||||
kw?: string;
|
||||
justReturnSpaceData?: boolean;
|
||||
}): Promise<MarketplacePage<McpDeployedItem>> {
|
||||
return marketplaceRequest<MarketplacePage<McpDeployedItem>>(
|
||||
"/api/mcp/deployed/list",
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
...(params.spaceId ? { spaceId: params.spaceId } : {}),
|
||||
...(params.justReturnSpaceData ? { justReturnSpaceData: true } : {}),
|
||||
...(params.kw ? { kw: params.kw, name: params.kw } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportMcpServerConfig(mcpId: number): Promise<unknown> {
|
||||
return marketplaceRequest<unknown>(`/api/mcp/server/config/export/${mcpId}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMcpDetail(
|
||||
uid: string,
|
||||
): Promise<McpConfigItem | null> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const detail = await marketplaceRequest<McpConfigItem | null>(
|
||||
"/api/system/eco/market/client/config/detail",
|
||||
{
|
||||
method: "POST",
|
||||
data: { uid },
|
||||
},
|
||||
);
|
||||
|
||||
if (!detail) return null;
|
||||
return {
|
||||
...detail,
|
||||
icon: normalizeAssetUrl(baseUrl, detail.icon),
|
||||
};
|
||||
}
|
||||
|
||||
export async function enableMcpConfig(
|
||||
uid: string,
|
||||
): Promise<McpConfigItem | null> {
|
||||
return marketplaceRequest<McpConfigItem | null>(
|
||||
`/api/system/eco/market/client/publish/config/enable?uid=${encodeURIComponent(uid)}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableMcpConfig(
|
||||
uid: string,
|
||||
): Promise<McpConfigItem | null> {
|
||||
return marketplaceRequest<McpConfigItem | null>(
|
||||
`/api/system/eco/market/client/publish/config/disable?uid=${encodeURIComponent(uid)}`,
|
||||
{
|
||||
method: "POST",
|
||||
data: { uid },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAndEnableMcpConfig(
|
||||
params: UpdateAndEnableMcpConfigParams,
|
||||
): Promise<McpConfigItem | null> {
|
||||
return marketplaceRequest<McpConfigItem | null>(
|
||||
"/api/system/eco/market/client/publish/config/updateAndEnable",
|
||||
{
|
||||
method: "POST",
|
||||
data: params,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -10,12 +10,12 @@
|
||||
}
|
||||
|
||||
.brand {
|
||||
height: 122px;
|
||||
height: 104px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr 34px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 26px 12px 18px 20px;
|
||||
padding: 12px 12px 14px 20px;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f7f6f3;
|
||||
color: #20211f;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 72px;
|
||||
flex: 0 0 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #8a8d89;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb strong {
|
||||
color: #006d68;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 800;
|
||||
color: #20211f;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 32px 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.topRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.search :global(.ant-input-affix-wrapper),
|
||||
.search:global(.ant-input-affix-wrapper) {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border-color: #dcd9d1;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search :global(.ant-input-prefix) {
|
||||
color: #9c9f9a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search :global(.ant-input) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #72756f;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count span {
|
||||
margin-right: 6px;
|
||||
color: #007c78;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mcpArea {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.centerState {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: #777b75;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8e4dc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mcpIcon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #007c78;
|
||||
color: #ffffff;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mcpIconImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mcpIconText {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mcpMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcpName {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #20211f;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.author {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #8b8d87;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 4px 10px;
|
||||
border-radius: 13px;
|
||||
background: #e7f4f2;
|
||||
color: #007c78;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1;
|
||||
margin: 18px 0 18px;
|
||||
color: #60645e;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #5d605b;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #ff9d18;
|
||||
}
|
||||
|
||||
.connectButton:global(.ant-btn-primary) {
|
||||
min-width: 58px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #00726d;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.connectButton:global(.ant-btn-primary):hover {
|
||||
background: #00645f !important;
|
||||
}
|
||||
|
||||
.connectedButton:global(.ant-btn) {
|
||||
min-width: 74px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #e6f4f2;
|
||||
color: #00726d;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.configForm {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header {
|
||||
height: auto;
|
||||
min-height: 72px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 22px 24px;
|
||||
}
|
||||
|
||||
.topRow {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f2;
|
||||
color: #151714;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 58px;
|
||||
flex: 0 0 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e8e7e2;
|
||||
}
|
||||
|
||||
.titleGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00726d;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: #151714;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 13px;
|
||||
color: #8a8a84;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 28px 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.search :global(.ant-input-affix-wrapper),
|
||||
.search:global(.ant-input-affix-wrapper) {
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
border-color: #deddd8;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search :global(.ant-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: #8a8a84;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count span {
|
||||
color: #00726d;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.categoryBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.categoryLoading {
|
||||
color: #8a8a84;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.categoryChip {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #e9f0ed;
|
||||
color: #00726d;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.categoryChip:hover {
|
||||
background: #dcebe7;
|
||||
}
|
||||
|
||||
.categoryChipActive {
|
||||
background: #00726d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.skillsArea {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.centerState {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: #7d7d78;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 142px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e2dc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(18, 20, 17, 0.05);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #cdd8d4;
|
||||
box-shadow: 0 8px 18px rgba(18, 20, 17, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skillIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #00a879 0%, #00726d 100%);
|
||||
}
|
||||
|
||||
.skillIconImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.skillIconText {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.skillMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skillName {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 800;
|
||||
color: #151714;
|
||||
}
|
||||
|
||||
.author {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #85857f;
|
||||
}
|
||||
|
||||
.badge {
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
background: #e9f5f2;
|
||||
color: #00806f;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1;
|
||||
margin: 14px 0 12px;
|
||||
color: #777771;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #555651;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #151714;
|
||||
}
|
||||
|
||||
.installButton:global(.ant-btn-primary) {
|
||||
min-width: 56px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #00726d;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.installButton:global(.ant-btn-primary):hover {
|
||||
background: #00645f !important;
|
||||
}
|
||||
|
||||
.installedButton:global(.ant-btn) {
|
||||
min-width: 74px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #e7f7ee;
|
||||
color: #00874f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detailModal :global(.ant-modal-content) {
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailModal :global(.ant-modal-close) {
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
max-height: min(760px, calc(100vh - 120px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f7f7f4;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 28px 32px 20px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e8e7e2;
|
||||
}
|
||||
|
||||
.detailIcon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #00a879 0%, #00726d 100%);
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detailTitle {
|
||||
margin: 0;
|
||||
color: #151714;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detailAuthor {
|
||||
margin-top: 4px;
|
||||
color: #85857f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detailBadge {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: #e9f5f2;
|
||||
color: #00806f;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detailDescription {
|
||||
margin: 0;
|
||||
padding: 22px 32px 0;
|
||||
color: #585a55;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.detailStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 18px 32px 0;
|
||||
color: #555651;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detailState {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.previewSection {
|
||||
margin: 20px 32px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #e4e2dc;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #151714;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.previewContent {
|
||||
max-height: 180px;
|
||||
margin: 14px 0 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: #151714;
|
||||
color: #f4f6f2;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detailFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding: 18px 32px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detailBadge {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
.page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f7f6f3;
|
||||
color: #20211f;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
padding: 28px 32px 0;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #8a8d89;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb strong {
|
||||
color: #006d68;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: #20211f;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.totalText {
|
||||
color: #888b85;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 26px 32px 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: min(480px, 100%);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search :global(.ant-input-affix-wrapper),
|
||||
.search:global(.ant-input-affix-wrapper) {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border-color: #dcd9d1;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search :global(.ant-input-prefix) {
|
||||
color: #a0a39e;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.search :global(.ant-input) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.categoryBar {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.categoryLoading {
|
||||
color: #8a8d89;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.categoryChip {
|
||||
height: 34px;
|
||||
padding: 0 17px;
|
||||
border: 1px solid #dedbd2;
|
||||
border-radius: 17px;
|
||||
background: #ffffff;
|
||||
color: #4f524d;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.categoryChip:hover {
|
||||
border-color: #007c78;
|
||||
color: #007c78;
|
||||
}
|
||||
|
||||
.categoryChipActive {
|
||||
border-color: #007c78;
|
||||
background: #007c78;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.workflowArea {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.centerState {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: #777b75;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8e4dc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 7px rgba(32, 33, 31, 0.05);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workflowIcon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.workflowIconImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.workflowIconText {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workflowMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflowName {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #20211f;
|
||||
font-size: 16px;
|
||||
line-height: 21px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.author {
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #8b8d87;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.badgeRow {
|
||||
display: flex;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 4px 10px;
|
||||
border-radius: 13px;
|
||||
background: #e7f4e9;
|
||||
color: #25823d;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1;
|
||||
margin: 18px 0 18px;
|
||||
color: #60645e;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #8a8d87;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #ff9d18;
|
||||
}
|
||||
|
||||
.useButton:global(.ant-btn-primary) {
|
||||
min-width: 58px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #00726d;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.useButton:global(.ant-btn-primary):hover {
|
||||
background: #00645f !important;
|
||||
}
|
||||
|
||||
.usedButton:global(.ant-btn) {
|
||||
min-width: 74px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #e4f4e7;
|
||||
color: #268443;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header {
|
||||
padding: 22px 22px 0;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 22px 22px 24px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -653,6 +653,47 @@ export interface QuickInitAPI {
|
||||
getConfig: () => Promise<QuickInitConfig | null>;
|
||||
}
|
||||
|
||||
export interface SkillInstallFile {
|
||||
name: string;
|
||||
contents: string | null;
|
||||
isDir: boolean;
|
||||
}
|
||||
|
||||
export interface SkillInstallPayload {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name: string;
|
||||
category?: string | null;
|
||||
author?: string | null;
|
||||
source?: string | null;
|
||||
files?: SkillInstallFile[];
|
||||
}
|
||||
|
||||
export interface SkillInstallQuery {
|
||||
workspaceDir: string;
|
||||
skillId: number;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillInstallResult {
|
||||
success: boolean;
|
||||
installed?: boolean;
|
||||
installDir?: string;
|
||||
writtenFiles?: string[];
|
||||
skippedFiles?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SkillsAPI {
|
||||
installLocal: (payload: SkillInstallPayload) => Promise<SkillInstallResult>;
|
||||
isInstalled: (query: SkillInstallQuery) => Promise<SkillInstallResult>;
|
||||
openInstallDir: (query: SkillInstallQuery) => Promise<{
|
||||
success: boolean;
|
||||
installDir?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
versions: {
|
||||
node: string;
|
||||
@@ -740,6 +781,7 @@ export interface ElectronAPI {
|
||||
app: AppAPI;
|
||||
permissions: PermissionsAPI;
|
||||
quickInit: QuickInitAPI;
|
||||
skills: SkillsAPI;
|
||||
perf: PerfAPI;
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
off: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
|
||||