feat(frontend): 对接工作台页面真实接口
This commit is contained in:
@@ -1,7 +1,123 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { Link, Upload } from '@element-plus/icons-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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -11,7 +127,10 @@ import { mcpCapabilities } from '@/data/studioMock';
|
|||||||
<p class="studio-kicker">McpImportView</p>
|
<p class="studio-kicker">McpImportView</p>
|
||||||
<h1>MCP 导入</h1>
|
<h1>MCP 导入</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div class="mcp-layout">
|
<div class="mcp-layout">
|
||||||
@@ -21,26 +140,67 @@ import { mcpCapabilities } from '@/data/studioMock';
|
|||||||
<span>POST /api/mcp/import</span>
|
<span>POST /api/mcp/import</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="import-options">
|
<div class="import-options">
|
||||||
<button class="active"><el-icon><Link /></el-icon><strong>URL</strong><span>https://mcp.example.com/sse</span></button>
|
<button
|
||||||
<button><strong>npm package</strong><span>@acme/mcp-jira</span></button>
|
v-for="option in importOptions"
|
||||||
<button><strong>JSON Manifest</strong><span>粘贴 server 能力声明</span></button>
|
: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>
|
||||||
<div class="manifest-box">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="studio-panel capability-panel">
|
<section class="studio-panel capability-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>能力预览</h2>
|
<h2>能力预览</h2>
|
||||||
<span>GET /api/mcp/servers/jira/capabilities</span>
|
<span>GET /api/mcp/servers/code/{serverCode}/capabilities</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="capability-grid">
|
<div class="server-tabs">
|
||||||
<article v-for="item in mcpCapabilities" :key="item.name">
|
<button
|
||||||
<el-tag>{{ item.type }}</el-tag>
|
v-for="server in servers"
|
||||||
<strong>{{ item.name }}</strong>
|
:key="server.serverCode"
|
||||||
<p>{{ item.description }}</p>
|
:class="{ active: server.serverCode === selectedServerCode }"
|
||||||
<span>{{ item.status }}</span>
|
: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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,75 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -9,10 +79,10 @@ import { recentRuns, traceSteps } from '@/data/studioMock';
|
|||||||
<p class="studio-kicker">ObservabilityView</p>
|
<p class="studio-kicker">ObservabilityView</p>
|
||||||
<h1>运行观测</h1>
|
<h1>运行观测</h1>
|
||||||
</div>
|
</div>
|
||||||
<el-button>导出日志</el-button>
|
<el-button data-test="observability-export" @click="exportTrace">导出日志</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="observability-layout">
|
<div class="observability-layout" v-loading="loading">
|
||||||
<section class="studio-panel">
|
<section class="studio-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>运行记录</h2>
|
<h2>运行记录</h2>
|
||||||
@@ -20,18 +90,23 @@ import { recentRuns, traceSteps } from '@/data/studioMock';
|
|||||||
</div>
|
</div>
|
||||||
<div class="run-table">
|
<div class="run-table">
|
||||||
<div class="run-row run-head">
|
<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>
|
||||||
<div v-for="run in recentRuns" :key="run.id" class="run-row">
|
<div
|
||||||
<strong>{{ run.name }}</strong>
|
v-for="run in runs"
|
||||||
<span>{{ run.type }}</span>
|
: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-cell">
|
||||||
<span class="status-pill" :class="run.status === '成功' ? 'is-success' : 'is-warning'">
|
<span class="status-pill" :class="formatStatus(run.status) === '成功' ? 'is-success' : 'is-warning'">
|
||||||
{{ run.status }}
|
{{ formatStatus(run.status) }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ run.latency }}</span>
|
<span>{{ formatLatency(run.durationMs) }}</span>
|
||||||
<span>{{ run.cost }}</span>
|
<span>{{ formatCost(run.estimatedCost) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -39,12 +114,13 @@ import { recentRuns, traceSteps } from '@/data/studioMock';
|
|||||||
<aside class="studio-panel">
|
<aside class="studio-panel">
|
||||||
<div class="panel-heading compact">
|
<div class="panel-heading compact">
|
||||||
<h2>步骤日志</h2>
|
<h2>步骤日志</h2>
|
||||||
<span>run-1842</span>
|
<span data-test="observability-trace-id">{{ selectedRequestId || '暂无请求' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="exportSummary" class="export-summary" data-test="observability-export-summary">{{ exportSummary }}</p>
|
||||||
<ol class="log-list">
|
<ol class="log-list">
|
||||||
<li v-for="step in traceSteps" :key="step.node">
|
<li v-for="step in trace?.stepLogs ?? []" :key="`${step.nodeName}-${step.durationMs}`" :data-test="`observability-step-${step.nodeName}`">
|
||||||
<time>{{ step.duration }}</time>
|
<time>{{ formatLatency(step.durationMs) }}</time>
|
||||||
<span>{{ step.node }} · {{ step.status }} · {{ step.output }}</span>
|
<span>{{ step.nodeName }} · {{ formatStatus(step.status) }} · {{ step.outputSummary || step.errorMessage || '-' }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,5 +1,78 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -9,45 +82,63 @@ import { skillVersions } from '@/data/studioMock';
|
|||||||
<p class="studio-kicker">SkillWorkspaceView</p>
|
<p class="studio-kicker">SkillWorkspaceView</p>
|
||||||
<h1>Skill 编辑与使用</h1>
|
<h1>Skill 编辑与使用</h1>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div class="skill-layout">
|
<div class="skill-layout" v-loading="loading">
|
||||||
<section class="studio-panel skill-editor">
|
<section class="studio-panel skill-editor">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<h2>引用审校 Skill</h2>
|
<h2>{{ workspace?.skillName || 'Skill 工作台' }}</h2>
|
||||||
<span>PUT /api/skills/skill-citation/draft</span>
|
<span>POST /api/skills/{{ skillCode }}/draft</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-tabs">
|
<div class="editor-tabs">
|
||||||
<button class="active">Prompt</button>
|
<button :class="{ active: activeTab === 'Prompt' }" data-test="skill-tab-prompt" @click="activeTab = 'Prompt'">Prompt</button>
|
||||||
<button>Code</button>
|
<button :class="{ active: activeTab === 'Code' }" data-test="skill-tab-code" @click="activeTab = 'Code'">Code</button>
|
||||||
<button>Config</button>
|
<button :class="{ active: activeTab === 'Config' }" data-test="skill-tab-config" @click="activeTab = 'Config'">Config</button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="prompt-editor">你是回答审校器。请检查答案是否完整引用知识库切片,并输出:
|
<textarea
|
||||||
1. answer_quality
|
v-if="activeTab === 'Prompt'"
|
||||||
2. missing_citations
|
v-model="draft.promptText"
|
||||||
3. rewrite_suggestion</pre>
|
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">
|
<div class="variable-grid">
|
||||||
<label>变量 <strong>answer</strong></label>
|
<label>变量 Schema <strong>{{ draft.variableSchemaJson }}</strong></label>
|
||||||
<label>变量 <strong>citations[]</strong></label>
|
<label>当前版本 <strong>v{{ draft.versionNo }}</strong></label>
|
||||||
<label>输出 <strong>quality_score</strong></label>
|
<label>发布状态 <strong>{{ workspace?.status || draft.publishStatus }}</strong></label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="studio-panel test-panel">
|
<aside class="studio-panel test-panel">
|
||||||
<div class="panel-heading compact">
|
<div class="panel-heading compact">
|
||||||
<h2>测试面板</h2>
|
<h2>测试面板</h2>
|
||||||
<span>POST /api/skills/skill-citation/test</span>
|
<span>POST /api/skills/{{ skillCode }}/test</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="test-result">
|
<div class="test-result" data-test="skill-test-result">
|
||||||
<strong>quality_score: 0.86</strong>
|
<strong>测试结果摘要</strong>
|
||||||
<p>建议补充“日志留存周期”的引用来源,并将私有化部署边界写得更明确。</p>
|
<p>{{ latestTestSummary }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-list">
|
<div class="version-list">
|
||||||
<article v-for="version in skillVersions" :key="version.version">
|
<article v-for="version in versionRows" :key="version.versionNo" :data-test="`skill-version-${version.versionNo}`">
|
||||||
<strong>{{ version.version }}</strong>
|
<strong>v{{ version.versionNo }}</strong>
|
||||||
<span>{{ version.status }}</span>
|
<span>{{ normalizeVersionStatus(version.publishStatus) }}</span>
|
||||||
<em>{{ version.note }} · {{ version.updatedAt }}</em>
|
<em>{{ version.remark || '无备注' }} · {{ version.publishedTime || '未发布' }}</em>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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