feat(frontend): 对接工作台页面真实接口
This commit is contained in:
@@ -1,7 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { Link, Upload } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { mcpCapabilities } from '@/data/studioMock';
|
||||
import type { McpCapability, McpImportRequest, McpServer } from '@/api/mcp';
|
||||
import { importMcpServer, listMcpCapabilitiesByServerCode, listMcpServers } from '@/api/mcp';
|
||||
|
||||
type ImportType = 'URL' | 'NPM_PACKAGE' | 'JSON_MANIFEST';
|
||||
|
||||
interface ImportOption {
|
||||
label: string;
|
||||
value: ImportType;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const importOptions: ImportOption[] = [
|
||||
{ label: 'URL', value: 'URL', placeholder: 'https://mcp.example.com/sse' },
|
||||
{ label: 'npm package', value: 'NPM_PACKAGE', placeholder: '@acme/mcp-jira' },
|
||||
{ label: 'JSON Manifest', value: 'JSON_MANIFEST', placeholder: '粘贴 server 能力声明' },
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const capabilityLoading = ref(false);
|
||||
const servers = ref<McpServer[]>([]);
|
||||
const selectedServerCode = ref('');
|
||||
const capabilities = ref<McpCapability[]>([]);
|
||||
const activeImportType = ref<ImportType>('URL');
|
||||
|
||||
const form = reactive<McpImportRequest>({
|
||||
serverCode: 'jira_server',
|
||||
serverName: 'Jira 服务',
|
||||
importType: 'URL',
|
||||
endpointUrl: 'https://mcp.example.com/sse',
|
||||
packageName: '@acme/mcp-jira',
|
||||
manifestJson: '{"server":"jira","transport":"sse","auth":"oauth2"}',
|
||||
authType: 'OAUTH2',
|
||||
secretRef: 'secret/mcp/jira',
|
||||
remark: '工作台导入示例',
|
||||
});
|
||||
|
||||
const selectedServer = computed(() => servers.value.find((item) => item.serverCode === selectedServerCode.value) ?? null);
|
||||
const manifestPreview = computed(() => form.manifestJson || '{"server":"jira"}');
|
||||
|
||||
function normalizeCapabilityType(type?: string) {
|
||||
if (type === 'RESOURCE') {
|
||||
return 'resource';
|
||||
}
|
||||
if (type === 'PROMPT') {
|
||||
return 'prompt';
|
||||
}
|
||||
return 'tool';
|
||||
}
|
||||
|
||||
function capabilityStatus(item: McpCapability) {
|
||||
return item.enabled ? '已启用' : '待授权';
|
||||
}
|
||||
|
||||
function selectImportType(type: ImportType) {
|
||||
activeImportType.value = type;
|
||||
form.importType = type;
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await listMcpServers();
|
||||
servers.value = response.data ?? [];
|
||||
if (!selectedServerCode.value && servers.value.length > 0) {
|
||||
selectedServerCode.value = servers.value[0].serverCode;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCapabilities(serverCode: string) {
|
||||
if (!serverCode) {
|
||||
capabilities.value = [];
|
||||
return;
|
||||
}
|
||||
capabilityLoading.value = true;
|
||||
try {
|
||||
const response = await listMcpCapabilitiesByServerCode(serverCode);
|
||||
capabilities.value = response.data ?? [];
|
||||
} finally {
|
||||
capabilityLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const request: McpImportRequest = {
|
||||
serverCode: form.serverCode.trim(),
|
||||
serverName: form.serverName.trim(),
|
||||
importType: activeImportType.value,
|
||||
endpointUrl: activeImportType.value === 'URL' ? form.endpointUrl?.trim() : undefined,
|
||||
packageName: activeImportType.value === 'NPM_PACKAGE' ? form.packageName?.trim() : undefined,
|
||||
manifestJson: form.manifestJson.trim(),
|
||||
authType: form.authType?.trim(),
|
||||
secretRef: form.secretRef?.trim(),
|
||||
remark: form.remark?.trim(),
|
||||
};
|
||||
await importMcpServer(request);
|
||||
ElMessage.success('MCP 服务导入成功');
|
||||
await loadServers();
|
||||
selectedServerCode.value = request.serverCode;
|
||||
await loadCapabilities(request.serverCode);
|
||||
}
|
||||
|
||||
async function selectServer(serverCode: string) {
|
||||
selectedServerCode.value = serverCode;
|
||||
await loadCapabilities(serverCode);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadServers();
|
||||
if (selectedServerCode.value) {
|
||||
await loadCapabilities(selectedServerCode.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,7 +127,10 @@ import { mcpCapabilities } from '@/data/studioMock';
|
||||
<p class="studio-kicker">McpImportView</p>
|
||||
<h1>MCP 导入</h1>
|
||||
</div>
|
||||
<el-button type="primary"><el-icon><Upload /></el-icon> 导入 Server</el-button>
|
||||
<el-button data-test="mcp-import-submit" type="primary" :loading="loading" @click="submitImport">
|
||||
<el-icon><Upload /></el-icon>
|
||||
导入 Server
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<div class="mcp-layout">
|
||||
@@ -21,26 +140,67 @@ import { mcpCapabilities } from '@/data/studioMock';
|
||||
<span>POST /api/mcp/import</span>
|
||||
</div>
|
||||
<div class="import-options">
|
||||
<button class="active"><el-icon><Link /></el-icon><strong>URL</strong><span>https://mcp.example.com/sse</span></button>
|
||||
<button><strong>npm package</strong><span>@acme/mcp-jira</span></button>
|
||||
<button><strong>JSON Manifest</strong><span>粘贴 server 能力声明</span></button>
|
||||
<button
|
||||
v-for="option in importOptions"
|
||||
:key="option.value"
|
||||
:class="{ active: option.value === activeImportType }"
|
||||
:data-test="`mcp-import-type-${option.value}`"
|
||||
@click="selectImportType(option.value)"
|
||||
>
|
||||
<el-icon v-if="option.value === 'URL'"><Link /></el-icon>
|
||||
<strong>{{ option.label }}</strong>
|
||||
<span>{{ option.placeholder }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="manifest-box">
|
||||
<span>{ "server": "jira", "transport": "sse", "auth": "oauth2" }</span>
|
||||
<span data-test="mcp-manifest-preview">{{ manifestPreview }}</span>
|
||||
</div>
|
||||
<div class="mcp-form-grid">
|
||||
<label>
|
||||
<span>Server 编码</span>
|
||||
<input v-model="form.serverCode" data-test="mcp-server-code" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Server 名称</span>
|
||||
<input v-model="form.serverName" data-test="mcp-server-name" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>端点地址</span>
|
||||
<input v-model="form.endpointUrl" data-test="mcp-endpoint-url" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>npm 包</span>
|
||||
<input v-model="form.packageName" data-test="mcp-package-name" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="studio-panel capability-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>能力预览</h2>
|
||||
<span>GET /api/mcp/servers/jira/capabilities</span>
|
||||
<span>GET /api/mcp/servers/code/{serverCode}/capabilities</span>
|
||||
</div>
|
||||
<div class="capability-grid">
|
||||
<article v-for="item in mcpCapabilities" :key="item.name">
|
||||
<el-tag>{{ item.type }}</el-tag>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<p>{{ item.description }}</p>
|
||||
<span>{{ item.status }}</span>
|
||||
<div class="server-tabs">
|
||||
<button
|
||||
v-for="server in servers"
|
||||
:key="server.serverCode"
|
||||
:class="{ active: server.serverCode === selectedServerCode }"
|
||||
:data-test="`mcp-server-tab-${server.serverCode}`"
|
||||
@click="selectServer(server.serverCode)"
|
||||
>
|
||||
<strong>{{ server.serverName }}</strong>
|
||||
<span>{{ server.serverCode }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="selectedServer" class="capability-summary" data-test="mcp-selected-server">
|
||||
当前服务:{{ selectedServer.serverName }} / {{ selectedServer.importType }} / {{ selectedServer.healthStatus }}
|
||||
</p>
|
||||
<div class="capability-grid" v-loading="capabilityLoading">
|
||||
<article v-for="item in capabilities" :key="item.capabilityCode" :data-test="`mcp-capability-${item.capabilityCode}`">
|
||||
<el-tag>{{ normalizeCapabilityType(item.capabilityType) }}</el-tag>
|
||||
<strong>{{ item.capabilityName }}</strong>
|
||||
<p>{{ item.capabilityCode }}</p>
|
||||
<span>{{ capabilityStatus(item) }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { recentRuns, traceSteps } from '@/data/studioMock';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import type { ObservabilityRunSummary, ObservabilityTrace } from '@/api/observability';
|
||||
import { exportObservabilityTrace, getObservabilityTrace, listObservabilityRuns } from '@/api/observability';
|
||||
|
||||
const loading = ref(false);
|
||||
const runs = ref<ObservabilityRunSummary[]>([]);
|
||||
const trace = ref<ObservabilityTrace | null>(null);
|
||||
const exportSummary = ref('');
|
||||
|
||||
const selectedRequestId = computed(() => trace.value?.requestId || runs.value[0]?.requestId || '');
|
||||
|
||||
function formatLatency(durationMs?: number) {
|
||||
if (durationMs == null) {
|
||||
return '-';
|
||||
}
|
||||
if (durationMs >= 1000) {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
return `${durationMs}ms`;
|
||||
}
|
||||
|
||||
function formatCost(cost?: number) {
|
||||
if (cost == null) {
|
||||
return '-';
|
||||
}
|
||||
return `¥${cost}`;
|
||||
}
|
||||
|
||||
function formatStatus(status?: string) {
|
||||
if (status === 'SUCCESS') {
|
||||
return '成功';
|
||||
}
|
||||
if (status === 'FAILED') {
|
||||
return '失败';
|
||||
}
|
||||
if (status === 'RUNNING') {
|
||||
return '运行中';
|
||||
}
|
||||
return status || '-';
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await listObservabilityRuns();
|
||||
runs.value = response.data ?? [];
|
||||
if (runs.value.length > 0) {
|
||||
await loadTrace(runs.value[0].requestId);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrace(requestId: string) {
|
||||
const response = await getObservabilityTrace(requestId);
|
||||
trace.value = response.data;
|
||||
}
|
||||
|
||||
async function exportTrace() {
|
||||
if (!selectedRequestId.value) {
|
||||
return;
|
||||
}
|
||||
const response = await exportObservabilityTrace(selectedRequestId.value);
|
||||
exportSummary.value = response.data.exportSummary ?? '';
|
||||
ElMessage.success('脱敏日志导出成功');
|
||||
}
|
||||
|
||||
onMounted(loadRuns);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,10 +79,10 @@ import { recentRuns, traceSteps } from '@/data/studioMock';
|
||||
<p class="studio-kicker">ObservabilityView</p>
|
||||
<h1>运行观测</h1>
|
||||
</div>
|
||||
<el-button>导出日志</el-button>
|
||||
<el-button data-test="observability-export" @click="exportTrace">导出日志</el-button>
|
||||
</header>
|
||||
|
||||
<div class="observability-layout">
|
||||
<div class="observability-layout" v-loading="loading">
|
||||
<section class="studio-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>运行记录</h2>
|
||||
@@ -20,18 +90,23 @@ import { recentRuns, traceSteps } from '@/data/studioMock';
|
||||
</div>
|
||||
<div class="run-table">
|
||||
<div class="run-row run-head">
|
||||
<span>名称</span><span>类型</span><span>状态</span><span>延迟</span><span>成本</span>
|
||||
<span>请求ID</span><span>状态</span><span>延迟</span><span>成本</span>
|
||||
</div>
|
||||
<div v-for="run in recentRuns" :key="run.id" class="run-row">
|
||||
<strong>{{ run.name }}</strong>
|
||||
<span>{{ run.type }}</span>
|
||||
<div
|
||||
v-for="run in runs"
|
||||
:key="run.requestId"
|
||||
class="run-row"
|
||||
:data-test="`observability-run-${run.requestId}`"
|
||||
@click="loadTrace(run.requestId)"
|
||||
>
|
||||
<strong>{{ run.requestId }}</strong>
|
||||
<span class="status-cell">
|
||||
<span class="status-pill" :class="run.status === '成功' ? 'is-success' : 'is-warning'">
|
||||
{{ run.status }}
|
||||
<span class="status-pill" :class="formatStatus(run.status) === '成功' ? 'is-success' : 'is-warning'">
|
||||
{{ formatStatus(run.status) }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ run.latency }}</span>
|
||||
<span>{{ run.cost }}</span>
|
||||
<span>{{ formatLatency(run.durationMs) }}</span>
|
||||
<span>{{ formatCost(run.estimatedCost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -39,12 +114,13 @@ import { recentRuns, traceSteps } from '@/data/studioMock';
|
||||
<aside class="studio-panel">
|
||||
<div class="panel-heading compact">
|
||||
<h2>步骤日志</h2>
|
||||
<span>run-1842</span>
|
||||
<span data-test="observability-trace-id">{{ selectedRequestId || '暂无请求' }}</span>
|
||||
</div>
|
||||
<p v-if="exportSummary" class="export-summary" data-test="observability-export-summary">{{ exportSummary }}</p>
|
||||
<ol class="log-list">
|
||||
<li v-for="step in traceSteps" :key="step.node">
|
||||
<time>{{ step.duration }}</time>
|
||||
<span>{{ step.node }} · {{ step.status }} · {{ step.output }}</span>
|
||||
<li v-for="step in trace?.stepLogs ?? []" :key="`${step.nodeName}-${step.durationMs}`" :data-test="`observability-step-${step.nodeName}`">
|
||||
<time>{{ formatLatency(step.durationMs) }}</time>
|
||||
<span>{{ step.nodeName }} · {{ formatStatus(step.status) }} · {{ step.outputSummary || step.errorMessage || '-' }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { skillVersions } from '@/data/studioMock';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import type { SkillVersionDraft, SkillWorkspace } from '@/api/skill';
|
||||
import { getSkillWorkspace, publishSkillDraft, saveSkillDraft, testSkillDraft } from '@/api/skill';
|
||||
|
||||
const skillCode = ref('skill-citation');
|
||||
const loading = ref(false);
|
||||
const activeTab = ref<'Prompt' | 'Code' | 'Config'>('Prompt');
|
||||
const workspace = ref<SkillWorkspace | null>(null);
|
||||
|
||||
const draft = reactive<SkillVersionDraft>({
|
||||
versionNo: 1,
|
||||
promptText: '',
|
||||
codeText: '',
|
||||
configJson: '{}',
|
||||
variableSchemaJson: '{"type":"object"}',
|
||||
testResultJson: '',
|
||||
publishStatus: 'DRAFT',
|
||||
});
|
||||
|
||||
const versionRows = computed(() => workspace.value?.versions ?? []);
|
||||
const latestTestSummary = computed(() => {
|
||||
const raw = draft.testResultJson || workspace.value?.latestTestResultJson || '';
|
||||
return raw || '暂无测试结果';
|
||||
});
|
||||
|
||||
function normalizeVersionStatus(status?: string) {
|
||||
if (status === 'PUBLISHED') {
|
||||
return 'Published';
|
||||
}
|
||||
if (status === 'ARCHIVED') {
|
||||
return 'Archived';
|
||||
}
|
||||
return 'Draft';
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getSkillWorkspace(skillCode.value);
|
||||
workspace.value = response.data;
|
||||
const latestVersion = response.data.versions[0];
|
||||
draft.versionNo = latestVersion?.versionNo ?? response.data.publishedVersionNo ?? 1;
|
||||
draft.promptText = latestVersion?.promptText ?? '';
|
||||
draft.codeText = latestVersion?.codeText ?? '';
|
||||
draft.configJson = latestVersion?.configJson ?? '{}';
|
||||
draft.variableSchemaJson = latestVersion?.variableSchemaJson ?? '{"type":"object"}';
|
||||
draft.testResultJson = latestVersion?.testResultJson ?? response.data.latestTestResultJson ?? '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
await saveSkillDraft(skillCode.value, { ...draft });
|
||||
ElMessage.success('Skill 草稿已保存');
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
const response = await testSkillDraft(skillCode.value, { ...draft });
|
||||
draft.testResultJson = response.data.testResultJson ?? '';
|
||||
ElMessage.success('Skill 测试完成');
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
async function publishDraft() {
|
||||
await publishSkillDraft(skillCode.value, { ...draft });
|
||||
ElMessage.success('Skill 已发布');
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
onMounted(loadWorkspace);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,45 +82,63 @@ import { skillVersions } from '@/data/studioMock';
|
||||
<p class="studio-kicker">SkillWorkspaceView</p>
|
||||
<h1>Skill 编辑与使用</h1>
|
||||
</div>
|
||||
<el-button type="primary">测试 Skill</el-button>
|
||||
<div class="page-actions">
|
||||
<el-button data-test="skill-save-draft" @click="saveDraft">保存草稿</el-button>
|
||||
<el-button data-test="skill-run-test" type="primary" @click="runTest">测试 Skill</el-button>
|
||||
<el-button data-test="skill-publish" type="success" @click="publishDraft">发布版本</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="skill-layout">
|
||||
<div class="skill-layout" v-loading="loading">
|
||||
<section class="studio-panel skill-editor">
|
||||
<div class="panel-heading">
|
||||
<h2>引用审校 Skill</h2>
|
||||
<span>PUT /api/skills/skill-citation/draft</span>
|
||||
<h2>{{ workspace?.skillName || 'Skill 工作台' }}</h2>
|
||||
<span>POST /api/skills/{{ skillCode }}/draft</span>
|
||||
</div>
|
||||
<div class="editor-tabs">
|
||||
<button class="active">Prompt</button>
|
||||
<button>Code</button>
|
||||
<button>Config</button>
|
||||
<button :class="{ active: activeTab === 'Prompt' }" data-test="skill-tab-prompt" @click="activeTab = 'Prompt'">Prompt</button>
|
||||
<button :class="{ active: activeTab === 'Code' }" data-test="skill-tab-code" @click="activeTab = 'Code'">Code</button>
|
||||
<button :class="{ active: activeTab === 'Config' }" data-test="skill-tab-config" @click="activeTab = 'Config'">Config</button>
|
||||
</div>
|
||||
<pre class="prompt-editor">你是回答审校器。请检查答案是否完整引用知识库切片,并输出:
|
||||
1. answer_quality
|
||||
2. missing_citations
|
||||
3. rewrite_suggestion</pre>
|
||||
<textarea
|
||||
v-if="activeTab === 'Prompt'"
|
||||
v-model="draft.promptText"
|
||||
class="prompt-editor"
|
||||
data-test="skill-prompt-editor"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="activeTab === 'Code'"
|
||||
v-model="draft.codeText"
|
||||
class="prompt-editor"
|
||||
data-test="skill-code-editor"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
v-model="draft.configJson"
|
||||
class="prompt-editor"
|
||||
data-test="skill-config-editor"
|
||||
/>
|
||||
<div class="variable-grid">
|
||||
<label>变量 <strong>answer</strong></label>
|
||||
<label>变量 <strong>citations[]</strong></label>
|
||||
<label>输出 <strong>quality_score</strong></label>
|
||||
<label>变量 Schema <strong>{{ draft.variableSchemaJson }}</strong></label>
|
||||
<label>当前版本 <strong>v{{ draft.versionNo }}</strong></label>
|
||||
<label>发布状态 <strong>{{ workspace?.status || draft.publishStatus }}</strong></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="studio-panel test-panel">
|
||||
<div class="panel-heading compact">
|
||||
<h2>测试面板</h2>
|
||||
<span>POST /api/skills/skill-citation/test</span>
|
||||
<span>POST /api/skills/{{ skillCode }}/test</span>
|
||||
</div>
|
||||
<div class="test-result">
|
||||
<strong>quality_score: 0.86</strong>
|
||||
<p>建议补充“日志留存周期”的引用来源,并将私有化部署边界写得更明确。</p>
|
||||
<div class="test-result" data-test="skill-test-result">
|
||||
<strong>测试结果摘要</strong>
|
||||
<p>{{ latestTestSummary }}</p>
|
||||
</div>
|
||||
<div class="version-list">
|
||||
<article v-for="version in skillVersions" :key="version.version">
|
||||
<strong>{{ version.version }}</strong>
|
||||
<span>{{ version.status }}</span>
|
||||
<em>{{ version.note }} · {{ version.updatedAt }}</em>
|
||||
<article v-for="version in versionRows" :key="version.versionNo" :data-test="`skill-version-${version.versionNo}`">
|
||||
<strong>v{{ version.versionNo }}</strong>
|
||||
<span>{{ normalizeVersionStatus(version.publishStatus) }}</span>
|
||||
<em>{{ version.remark || '无备注' }} · {{ version.publishedTime || '未发布' }}</em>
|
||||
</article>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
94
frontend/src/pages/studio/__tests__/McpImportPage.spec.ts
Normal file
94
frontend/src/pages/studio/__tests__/McpImportPage.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import McpImportPage from '../McpImportPage.vue';
|
||||
import { importMcpServer, listMcpCapabilitiesByServerCode, listMcpServers } from '@/api/mcp';
|
||||
|
||||
vi.mock('@/api/mcp', () => ({
|
||||
listMcpServers: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
serverCode: 'jira_server',
|
||||
serverName: 'Jira 服务',
|
||||
importType: 'URL',
|
||||
healthStatus: 'HEALTHY',
|
||||
manifestJson: '{"server":"jira"}',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
listMcpCapabilitiesByServerCode: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: [
|
||||
{
|
||||
id: '11',
|
||||
serverId: '1',
|
||||
capabilityCode: 'jira_issue_search',
|
||||
capabilityName: '问题检索',
|
||||
capabilityType: 'TOOL',
|
||||
schemaJson: '{"type":"object"}',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
importMcpServer: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||
saveMcpCapability: vi.fn(),
|
||||
getMcpWorkspace: vi.fn(),
|
||||
listMcpCapabilitiesByServerId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('McpImportPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads server tabs and capability preview from backend api', async () => {
|
||||
const wrapper = mount(McpImportPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(listMcpServers).toHaveBeenCalled();
|
||||
expect(listMcpCapabilitiesByServerCode).toHaveBeenCalledWith('jira_server');
|
||||
expect(wrapper.text()).toContain('Jira 服务');
|
||||
expect(wrapper.text()).toContain('问题检索');
|
||||
expect(wrapper.find('[data-test="mcp-selected-server"]').text()).toContain('HEALTHY');
|
||||
});
|
||||
|
||||
it('submits import form through backend api', async () => {
|
||||
const wrapper = mount(McpImportPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="mcp-server-code"]').setValue('deploy_server');
|
||||
await wrapper.get('[data-test="mcp-server-name"]').setValue('部署服务');
|
||||
await wrapper.get('[data-test="mcp-import-type-NPM_PACKAGE"]').trigger('click');
|
||||
await wrapper.get('[data-test="mcp-package-name"]').setValue('@acme/mcp-deploy');
|
||||
await wrapper.get('[data-test="mcp-import-submit"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(importMcpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverCode: 'deploy_server',
|
||||
serverName: '部署服务',
|
||||
importType: 'NPM_PACKAGE',
|
||||
packageName: '@acme/mcp-deploy',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ObservabilityPage from '../ObservabilityPage.vue';
|
||||
import { exportObservabilityTrace, getObservabilityTrace, listObservabilityRuns } from '@/api/observability';
|
||||
|
||||
vi.mock('@/api/observability', () => ({
|
||||
listObservabilityRuns: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: [
|
||||
{
|
||||
runId: '101',
|
||||
workflowId: '2001',
|
||||
requestId: 'req-1001',
|
||||
status: 'SUCCESS',
|
||||
durationMs: 1420,
|
||||
estimatedCost: 0.018,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
getObservabilityTrace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
requestId: 'req-1001',
|
||||
workflowStatus: 'SUCCESS',
|
||||
sessionStatus: 'ACTIVE',
|
||||
workflowStepCount: 2,
|
||||
messageCount: 2,
|
||||
modelCallCount: 1,
|
||||
totalDurationMs: 1420,
|
||||
estimatedCost: 0.018,
|
||||
stepLogs: [
|
||||
{
|
||||
nodeName: 'Knowledge Retrieval',
|
||||
status: 'SUCCESS',
|
||||
durationMs: 218,
|
||||
outputSummary: '召回 6 个切片',
|
||||
},
|
||||
],
|
||||
modelCalls: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
listObservabilityModelCalls: vi.fn(),
|
||||
exportObservabilityTrace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
requestId: 'req-1001',
|
||||
exportSummary: '仅导出脱敏摘要,不包含密钥和完整请求体',
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ObservabilityPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads run list and trace detail from backend api', async () => {
|
||||
const wrapper = mount(ObservabilityPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(listObservabilityRuns).toHaveBeenCalled();
|
||||
expect(getObservabilityTrace).toHaveBeenCalledWith('req-1001');
|
||||
expect(wrapper.text()).toContain('req-1001');
|
||||
expect(wrapper.text()).toContain('Knowledge Retrieval');
|
||||
});
|
||||
|
||||
it('exports masked trace through backend api', async () => {
|
||||
const wrapper = mount(ObservabilityPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="observability-export"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(exportObservabilityTrace).toHaveBeenCalledWith('req-1001');
|
||||
expect(wrapper.find('[data-test="observability-export-summary"]').text()).toContain('脱敏摘要');
|
||||
});
|
||||
});
|
||||
101
frontend/src/pages/studio/__tests__/SkillWorkspacePage.spec.ts
Normal file
101
frontend/src/pages/studio/__tests__/SkillWorkspacePage.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillWorkspacePage from '../SkillWorkspacePage.vue';
|
||||
import { getSkillWorkspace, publishSkillDraft, saveSkillDraft, testSkillDraft } from '@/api/skill';
|
||||
|
||||
vi.mock('@/api/skill', () => ({
|
||||
getSkillWorkspace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
skillId: '1001',
|
||||
skillCode: 'skill-citation',
|
||||
skillName: '引用审校 Skill',
|
||||
skillType: 'MIXED',
|
||||
description: '检查答案与引用是否一致',
|
||||
status: 'PUBLISHED',
|
||||
publishedVersionNo: 4,
|
||||
latestTestResultJson: '{"quality_score":0.86}',
|
||||
skills: [],
|
||||
versions: [
|
||||
{
|
||||
id: '2001',
|
||||
skillId: '1001',
|
||||
versionNo: 4,
|
||||
promptText: '你是回答审校器',
|
||||
configJson: '{"timeout":3000}',
|
||||
variableSchemaJson: '{"type":"object"}',
|
||||
testResultJson: '{"quality_score":0.86}',
|
||||
publishStatus: 'PUBLISHED',
|
||||
publishedTime: '2026-06-01 10:00:00',
|
||||
remark: '生产版本',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
saveSkillDraft: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||
testSkillDraft: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
versionNo: 5,
|
||||
testResultJson: '{"quality_score":0.92,"summary":"建议补充日志留存周期引用"}',
|
||||
publishStatus: 'DRAFT',
|
||||
},
|
||||
}),
|
||||
),
|
||||
publishSkillDraft: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||
archiveSkillVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('SkillWorkspacePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads workspace detail and renders version list from backend api', async () => {
|
||||
const wrapper = mount(SkillWorkspacePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getSkillWorkspace).toHaveBeenCalledWith('skill-citation');
|
||||
expect(wrapper.text()).toContain('引用审校 Skill');
|
||||
expect(wrapper.text()).toContain('v4');
|
||||
expect(wrapper.find('[data-test="skill-test-result"]').text()).toContain('quality_score');
|
||||
});
|
||||
|
||||
it('saves draft, runs test and publishes through backend api', async () => {
|
||||
const wrapper = mount(SkillWorkspacePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="skill-prompt-editor"]').setValue('新的审校提示词');
|
||||
await wrapper.get('[data-test="skill-save-draft"]').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="skill-run-test"]').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="skill-publish"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(saveSkillDraft).toHaveBeenCalledWith(
|
||||
'skill-citation',
|
||||
expect.objectContaining({
|
||||
promptText: '新的审校提示词',
|
||||
}),
|
||||
);
|
||||
expect(testSkillDraft).toHaveBeenCalledWith('skill-citation', expect.any(Object));
|
||||
expect(publishSkillDraft).toHaveBeenCalledWith('skill-citation', expect.any(Object));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user