feat(frontend): 对接工作台页面真实接口

This commit is contained in:
2026-06-01 04:53:05 +08:00
parent 2dd242c54b
commit 8f7ffd6cc9
6 changed files with 670 additions and 51 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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',
}),
);
});
});

View File

@@ -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('脱敏摘要');
});
});

View 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));
});
});