refactor(ui): 将导入导出功能抽离为独立组件

- 新增 ImportExportDialog 组件,专门处理导入导出功能
- 重构 ContextEditor,移除内联的导入导出对话框代码
- 支持 4 种导入格式 (smart/openai/langfuse/conversation)
- 支持 2 种导出格式 (standard/openai)
- 更新相关单元测试和 E2E 测试
- 同步更新三语言国际化文件

优化:
- 代码量减少约 586 行 (24%)
- 组件职责更清晰,符合单一职责原则
- 提高代码可维护性和复用性
This commit is contained in:
linshen committed 2025-11-23 11:50:29 +08:00
1 parent dee32ae979
commit 3bc03c3be5
8 files changed
+934 -713

No files matched your search

@@ -1265,239 +1265,23 @@
</NModal>
<!-- 导入对话框 -->
<NModal
v-model:show="showImportDialog"
preset="dialog"
:title="t('contextEditor.importTitle')"
:show-icon="false"
style="width: 600px"
:mask-closable="false"
>
<template #default>
<!-- 格式选择 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{
t("contextEditor.importFormat")
}}</label>
<NSpace size="small" wrap>
<NButton
v-for="format in importFormats"
:key="format.id"
@click="selectedImportFormat = format.id"
:type="
selectedImportFormat === format.id
? 'primary'
: 'default'
"
:size="buttonSize"
>
{{ format.name }}
</NButton>
</NSpace>
<p class="text-xs text-gray-500 mt-2">
{{
importFormats.find((f) => f.id === selectedImportFormat)
?.description
}}
</p>
</div>
<!-- 文件上传 -->
<div class="mb-4">
<NSpace align="center" :size="8" class="mb-2">
<input
type="file"
ref="fileInputRef"
accept=".json,.txt"
@change="handleFileUpload"
class="hidden"
/>
<NButton
@click="fileInputRef?.click()"
secondary
:size="buttonSize"
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</template>
{{ t("contextEditor.selectFile") }}
</NButton>
<NText depth="3" class="text-sm">
{{ t("contextEditor.orPasteText") }}
</NText>
</NSpace>
</div>
<!-- 文本输入区域 -->
<NInput
v-model:value="importData"
type="textarea"
:placeholder="getImportPlaceholder()"
:autosize="{ minRows: 12, maxRows: 16 }"
class="font-mono text-sm"
/>
<div v-if="importError" class="text-sm text-red-500 mt-2">
{{ importError }}
</div>
</template>
<template #action>
<NSpace justify="end">
<NButton @click="showImportDialog = false" :size="buttonSize">
{{ t("common.cancel") }}
</NButton>
<NButton
@click="handleImportSubmit"
:disabled="!importData.trim()"
type="primary"
:size="buttonSize"
:loading="contextEditor.isLoading.value"
>
{{ t("contextEditor.import") }}
</NButton>
</NSpace>
</template>
</NModal>
<ImportExportDialog
v-model:visible="showImportDialog"
mode="import"
:messages="[]"
@import-success="handleImportSuccess"
@export-error="handleExportError"
/>
<!-- 导出对话框 -->
<NModal
v-model:show="showExportDialog"
preset="dialog"
:title="t('contextEditor.exportTitle')"
:show-icon="false"
style="width: 600px"
:mask-closable="false"
>
<template #default>
<!-- 格式选择 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{
t("contextEditor.exportFormat")
}}</label>
<NSpace size="small" wrap>
<NButton
v-for="format in exportFormats"
:key="format.id"
@click="selectedExportFormat = format.id"
:type="
selectedExportFormat === format.id
? 'primary'
: 'default'
"
:size="buttonSize"
>
{{ format.name }}
</NButton>
</NSpace>
<p class="text-xs text-gray-500 mt-2">
{{
exportFormats.find((f) => f.id === selectedExportFormat)
?.description
}}
</p>
</div>
<!-- 导出预览 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{
t("contextEditor.exportPreview")
}}</label>
<NInput
:value="
JSON.stringify(
{
messages: localState.messages,
metadata: {
variables: localState.variables,
tools: localState.tools,
exportTime: new Date().toISOString(),
},
},
null,
2,
)
"
readonly
type="textarea"
:autosize="{ minRows: 8, maxRows: 12 }"
class="font-mono text-sm"
/>
</div>
</template>
<template #action>
<NSpace justify="space-between">
<NButton @click="showExportDialog = false" :size="buttonSize">
{{ t("common.cancel") }}
</NButton>
<NSpace>
<NButton
@click="handleExportToClipboard"
secondary
:size="buttonSize"
:loading="contextEditor.isLoading.value"
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</template>
{{ t("contextEditor.copyToClipboard") }}
</NButton>
<NButton
@click="handleExportToFile"
type="primary"
:size="buttonSize"
:loading="contextEditor.isLoading.value"
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
</template>
{{ t("contextEditor.saveToFile") }}
</NButton>
</NSpace>
</NSpace>
</template>
</NModal>
<ImportExportDialog
v-model:visible="showExportDialog"
mode="export"
:messages="localState.messages"
:tools="localState.tools"
@export-success="handleExportSuccess"
@export-error="handleExportError"
/>
<!-- 变量编辑对话框 -->
<NModal
@@ -1656,8 +1440,8 @@ import { useResponsive } from "../../composables/ui/useResponsive";
import { usePerformanceMonitor } from "../../composables/performance/usePerformanceMonitor";
import { useDebounceThrottle } from '../../composables/performance/useDebounceThrottle';
import { useAccessibility } from "../../composables/accessibility/useAccessibility";
import { useContextEditor } from '../../composables/context/useContextEditor';
import { useTemporaryVariables } from '../../composables/variable/useTemporaryVariables';
import ImportExportDialog from './ImportExportDialog.vue';
import { useAggregatedVariables } from '../../composables/variable/useAggregatedVariables';
import {
quickTemplateManager,
@@ -1672,7 +1456,6 @@ import type {
ConversationMessage,
ToolDefinition,
} from "@prompt-optimizer/core";
import type { StandardPromptData } from "../../types";
import {
PREDEFINED_VARIABLES,
type PredefinedVariable,
@@ -1716,9 +1499,6 @@ const props = withDefaults(
const emit = defineEmits<ContextEditorEvents>();
// 导入导出功能
const contextEditor = useContextEditor();
// 临时变量管理
const tempVars = useTemporaryVariables();
@@ -1746,14 +1526,10 @@ const loading = ref(false);
const activeTab = ref("messages");
const localVisible = ref(props.visible);
// 导入导出状态
// 导入导出对话框状态
const showImportDialog = ref(false);
const showExportDialog = ref(false);
const importData = ref("");
const importError = ref("");
const selectedImportFormat = ref("smart");
const selectedExportFormat = ref("standard");
const fileInputRef = ref<HTMLInputElement | null>(null);
// 变量值输入框引用(用于自动聚焦)
const variableValueInputRef = ref(null);
@@ -1906,41 +1682,6 @@ const quickTemplates = computed(() => {
);
});
const normalizeMessage = (
msg: Partial<ConversationMessage>,
): ConversationMessage => {
const normalizedRole = (msg.role ?? "user") as ConversationMessage["role"];
let content = "";
if (typeof msg.content === "string") {
content = msg.content;
} else if (msg.content != null) {
try {
content =
typeof msg.content === "object"
? JSON.stringify(msg.content)
: String(msg.content);
} catch {
content = String(msg.content);
}
}
const normalized: ConversationMessage = {
role: normalizedRole,
content,
};
if (typeof msg.name === "string") normalized.name = msg.name;
if (Array.isArray(msg.tool_calls)) {
normalized.tool_calls =
msg.tool_calls as ConversationMessage["tool_calls"];
}
if (typeof msg.tool_call_id === "string")
normalized.tool_call_id = msg.tool_call_id;
return normalized;
};
// 工具函数(统一使用注入函数)
const getMessageVariables = (content: string) => {
const detected = props.scanVariables(content || "") || [];
@@ -2527,266 +2268,32 @@ watch(
},
);
// 导入导出方法
const importFormats = computed(() => [
{ id: "smart", name: t("contextEditor.importFormats.smart.name"), description: t("contextEditor.importFormats.smart.description") },
{ id: "conversation", name: t("contextEditor.importFormats.conversation.name"), description: t("contextEditor.importFormats.conversation.description") },
{ id: "openai", name: t("contextEditor.importFormats.openai.name"), description: t("contextEditor.importFormats.openai.description") },
{ id: "langfuse", name: t("contextEditor.importFormats.langfuse.name"), description: t("contextEditor.importFormats.langfuse.description") },
]);
// 导入导出事件处理
interface ImportSuccessData {
messages: ConversationMessage[];
tools?: ToolDefinition[];
}
type ExportFormat = "standard" | "openai" | "template";
const handleImportSuccess = (data: ImportSuccessData) => {
// 将导入的数据同步到本地状态
localState.value.messages = data.messages;
localState.value.tools = data.tools || [];
const exportFormats = computed(() => [
{
id: "standard" as ExportFormat,
name: t("contextEditor.exportFormats.standard.name"),
description: t("contextEditor.exportFormats.standard.description"),
},
{
id: "openai" as ExportFormat,
name: t("contextEditor.exportFormats.openai.name"),
description: t("contextEditor.exportFormats.openai.description"),
},
{
id: "template" as ExportFormat,
name: t("contextEditor.exportFormats.template.name"),
description: t("contextEditor.exportFormats.template.description"),
},
]);
handleStateChange();
const handleFileUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
loading.value = true;
importError.value = "";
const success = await contextEditor.importFromFile(file);
if (success && contextEditor.currentData.value) {
// 将导入的数据同步到本地状态
const data = contextEditor.currentData.value;
localState.value.messages = (data.messages || []).map((msg) =>
normalizeMessage(msg),
);
// 不导入 variables,临时变量不持久化
localState.value.tools = data.tools || [];
handleStateChange();
showImportDialog.value = false;
importData.value = "";
importError.value = "";
// 切换到消息编辑标签页
activeTab.value = "messages";
announce(t("contextEditor.importSuccess"), "polite");
} else {
importError.value = t("contextEditor.importFailed");
}
} catch (err) {
console.error("File upload error:", err);
const errorMsg = err instanceof Error ? err.message : t("contextEditor.importFailed");
importError.value = errorMsg;
} finally {
loading.value = false;
}
// 切换到消息编辑标签页
activeTab.value = "messages";
announce(t("contextEditor.importSuccess"), "polite");
};
const handleImportSubmit = async () => {
if (!importData.value.trim()) {
importError.value = t("contextEditor.importDataRequired");
return;
}
try {
loading.value = true;
importError.value = "";
const jsonData = JSON.parse(importData.value);
let result;
switch (selectedImportFormat.value) {
case "smart":
result = contextEditor.smartImport(jsonData);
break;
case "openai":
result = contextEditor.convertFromOpenAI(jsonData);
break;
case "langfuse":
result = contextEditor.convertFromLangFuse(jsonData);
break;
case "conversation":
// 直接设置为对话格式
if (Array.isArray(jsonData)) {
localState.value.messages = jsonData.map(
(msg: Partial<ConversationMessage>) =>
normalizeMessage(msg),
);
} else if (
jsonData.messages &&
Array.isArray(jsonData.messages)
) {
localState.value.messages = jsonData.messages.map(
(msg: Partial<ConversationMessage>) =>
normalizeMessage(msg),
);
// 不导入 variables,临时变量不持久化
localState.value.tools = jsonData.tools || [];
} else {
importError.value = t("contextEditor.invalidConversationFormat");
return;
}
handleStateChange();
showImportDialog.value = false;
importData.value = "";
importError.value = "";
activeTab.value = "messages";
announce(t("contextEditor.importSuccess"), "polite");
return;
default:
importError.value = t("contextEditor.unsupportedImportFormat");
return;
}
// 处理转换结果
if (result && result.success && contextEditor.currentData.value) {
// 将导入的数据同步到本地状态
const data = contextEditor.currentData.value;
localState.value.messages = (data.messages || []).map((msg) =>
normalizeMessage(msg),
);
// 不导入 variables,临时变量不持久化
localState.value.tools = data.tools || [];
handleStateChange();
showImportDialog.value = false;
importData.value = "";
importError.value = "";
activeTab.value = "messages";
announce(t("contextEditor.importSuccess"), "polite");
} else {
importError.value = result?.error || t("contextEditor.importFailed");
}
} catch (err) {
console.error("Import error:", err);
const errorMsg =
err instanceof Error ? err.message : t("contextEditor.invalidJsonFormat");
importError.value = errorMsg;
} finally {
loading.value = false;
}
const handleExportSuccess = () => {
announce(t("contextEditor.exportSuccess"), "polite");
};
const handleExportToFile = () => {
try {
loading.value = true;
// 准备导出数据 - 转换为 StandardPromptData 格式
const exportData: StandardPromptData = {
messages: localState.value.messages.map((msg) => ({
role: msg.role,
content: msg.content,
// 保留其他可能的属性
...(msg.name && { name: msg.name }),
...(msg.tool_calls && { tool_calls: msg.tool_calls }),
...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
})),
tools: localState.value.tools,
metadata: {
// 不导出临时变量(会话级别,不持久化)
exportTime: new Date().toISOString(),
version: "1.0",
source: "manual",
origin: "context_editor",
},
};
// 设置导出数据到contextEditor
contextEditor.setData(exportData);
// 执行导出
const success = contextEditor.exportToFile(
selectedExportFormat.value as ExportFormat,
`context-export-${Date.now()}`,
);
if (success) {
showExportDialog.value = false;
announce(t("contextEditor.exportSuccess"), "polite");
} else {
throw new Error(t("contextEditor.exportFailed"));
}
} catch (err) {
console.error("Export to file error:", err);
const errorMsg = err instanceof Error ? err.message : t("contextEditor.exportFailed");
// TODO: 显示错误提示给用户
announce(`${t("contextEditor.exportFailed")}: ${errorMsg}`, "assertive");
} finally {
loading.value = false;
}
};
const handleExportToClipboard = async () => {
try {
loading.value = true;
// 准备导出数据 - 转换为 StandardPromptData 格式
const exportData: StandardPromptData = {
messages: localState.value.messages.map((msg) => ({
role: msg.role,
content: msg.content,
// 保留其他可能的属性
...(msg.name && { name: msg.name }),
...(msg.tool_calls && { tool_calls: msg.tool_calls }),
...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
})),
tools: localState.value.tools,
metadata: {
// 不导出临时变量(会话级别,不持久化)
exportTime: new Date().toISOString(),
version: "1.0",
source: "manual",
origin: "context_editor",
},
};
// 设置导出数据到contextEditor
contextEditor.setData(exportData);
// 执行导出到剪贴板
const success = await contextEditor.exportToClipboard(
selectedExportFormat.value as ExportFormat,
);
if (success) {
showExportDialog.value = false;
announce(t("contextEditor.copySuccess"), "polite");
} else {
throw new Error(t("contextEditor.copyFailed"));
}
} catch (err) {
console.error("Export to clipboard error:", err);
const errorMsg = err instanceof Error ? err.message : t("contextEditor.exportFailed");
// TODO: 显示错误提示给用户
announce(`${t("contextEditor.copyFailed")}: ${errorMsg}`, "assertive");
} finally {
loading.value = false;
}
};
const getImportPlaceholder = () => {
switch (selectedImportFormat.value) {
case "openai":
return t("contextEditor.importPlaceholders.openai");
case "langfuse":
return t("contextEditor.importPlaceholders.langfuse");
case "conversation":
return t("contextEditor.importPlaceholders.conversation");
case "smart":
default:
return t("contextEditor.importPlaceholders.smart");
}
const handleExportError = (message?: string) => {
const fallbackMessage = message || t("contextEditor.exportFailed");
console.error(fallbackMessage);
announce(fallbackMessage, "assertive");
};
</script>
@@ -0,0 +1,613 @@
<template>
<!-- 导入模式 -->
<NModal
v-if="mode === 'import'"
v-model:show="localVisible"
preset="dialog"
:title="t('contextEditor.importTitle')"
:show-icon="false"
style="width: 600px"
:mask-closable="false"
@update:show="handleVisibilityChange"
>
<template #default>
<!-- 格式选择 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{
t("contextEditor.importFormat")
}}</label>
<NSpace size="small" wrap>
<NButton
v-for="format in importFormats"
:key="format.id"
@click="selectedImportFormat = format.id"
:type="
selectedImportFormat === format.id
? 'primary'
: 'default'
"
size="small"
>
{{ format.name }}
</NButton>
</NSpace>
<p class="text-xs text-gray-500 mt-2">
{{
importFormats.find((f) => f.id === selectedImportFormat)
?.description
}}
</p>
</div>
<!-- 文件上传 -->
<div class="mb-4">
<NSpace align="center" :size="8" class="mb-2">
<input
type="file"
ref="fileInputRef"
accept=".json,.txt"
@change="handleFileUpload"
class="hidden"
/>
<NButton
@click="fileInputRef?.click()"
secondary
size="small"
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</template>
{{ t("contextEditor.selectFile") }}
</NButton>
<NText depth="3" class="text-sm">
{{ t("contextEditor.orPasteText") }}
</NText>
</NSpace>
</div>
<!-- 文本输入区域 -->
<NInput
v-model:value="importData"
type="textarea"
:placeholder="getImportPlaceholder()"
:autosize="{ minRows: 12, maxRows: 16 }"
class="font-mono text-sm"
/>
<div v-if="importError" class="text-sm text-red-500 mt-2">
{{ importError }}
</div>
</template>
<template #action>
<NSpace justify="end">
<NButton @click="handleClose" size="small">
{{ t("common.cancel") }}
</NButton>
<NButton
@click="handleImportSubmit"
:disabled="!importData.trim()"
type="primary"
size="small"
:loading="loading"
>
{{ t("contextEditor.import") }}
</NButton>
</NSpace>
</template>
</NModal>
<!-- 导出模式 -->
<NModal
v-else
v-model:show="localVisible"
preset="dialog"
:title="t('contextEditor.exportTitle')"
:show-icon="false"
style="width: 600px"
:mask-closable="false"
@update:show="handleVisibilityChange"
>
<template #default>
<!-- 格式选择 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{
t("contextEditor.exportFormat")
}}</label>
<NSpace size="small" wrap>
<NButton
v-for="format in exportFormats"
:key="format.id"
@click="selectedExportFormat = format.id"
:type="
selectedExportFormat === format.id
? 'primary'
: 'default'
"
size="small"
>
{{ format.name }}
</NButton>
</NSpace>
<p class="text-xs text-gray-500 mt-2">
{{
exportFormats.find((f) => f.id === selectedExportFormat)
?.description
}}
</p>
</div>
<!-- 导出预览 -->
<div class="mb-4">
<label class="block text-sm font-medium mb-2">{{
t("contextEditor.exportPreview")
}}</label>
<NInput
:value="exportPreviewData"
readonly
type="textarea"
:autosize="{ minRows: 8, maxRows: 12 }"
class="font-mono text-sm"
/>
<div v-if="exportError" class="text-sm text-red-500 mt-2">
{{ exportError }}
</div>
</div>
</template>
<template #action>
<NSpace justify="space-between">
<NButton @click="handleClose" size="small">
{{ t("common.cancel") }}
</NButton>
<NSpace>
<NButton
@click="handleExportToClipboard"
secondary
size="small"
:loading="loading"
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</template>
{{ t("contextEditor.copyToClipboard") }}
</NButton>
<NButton
@click="handleExportToFile"
type="primary"
size="small"
:loading="loading"
>
<template #icon>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
</template>
{{ t("contextEditor.saveToFile") }}
</NButton>
</NSpace>
</NSpace>
</template>
</NModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from "vue-i18n";
import {
NModal,
NButton,
NSpace,
NInput,
NText,
} from "naive-ui";
import { useContextEditor } from '../../composables/context/useContextEditor';
import type { ConversationMessage, ToolDefinition } from "@prompt-optimizer/core";
import type { StandardPromptData } from "../../types";
// 类型定义
interface Props {
visible: boolean;
mode: 'import' | 'export';
messages: ConversationMessage[];
tools?: ToolDefinition[];
}
interface ImportSuccessData {
messages: ConversationMessage[];
tools?: ToolDefinition[];
}
// Props 和 Events
const props = withDefaults(defineProps<Props>(), {
tools: () => [],
});
const emit = defineEmits<{
'update:visible': [visible: boolean];
'import-success': [data: ImportSuccessData];
'export-success': [];
'export-error': [message?: string];
}>();
const { t } = useI18n();
// 复用 useContextEditor
const contextEditor = useContextEditor();
// 本地状态
const localVisible = ref(props.visible);
const loading = ref(false);
const importData = ref("");
const importError = ref("");
const exportError = ref("");
const selectedImportFormat = ref("smart");
const selectedExportFormat = ref("standard");
const fileInputRef = ref<HTMLInputElement | null>(null);
// 同步 visible 状态
watch(() => props.visible, (newVisible) => {
localVisible.value = newVisible;
if (newVisible) {
// 重置状态
importData.value = "";
importError.value = "";
exportError.value = "";
}
});
// 导入格式选项
const importFormats = computed(() => [
{ id: "smart", name: t("contextEditor.importFormats.smart.name"), description: t("contextEditor.importFormats.smart.description") },
{ id: "openai", name: t("contextEditor.importFormats.openai.name"), description: t("contextEditor.importFormats.openai.description") },
{ id: "langfuse", name: t("contextEditor.importFormats.langfuse.name"), description: t("contextEditor.importFormats.langfuse.description") },
{ id: "conversation", name: t("contextEditor.importFormats.conversation.name"), description: t("contextEditor.importFormats.conversation.description") },
]);
// 导出格式选项
type ExportFormat = "standard" | "openai";
const exportFormats = computed(() => [
{
id: "standard" as ExportFormat,
name: t("contextEditor.exportFormats.standard.name"),
description: t("contextEditor.exportFormats.standard.description"),
},
{
id: "openai" as ExportFormat,
name: t("contextEditor.exportFormats.openai.name"),
description: t("contextEditor.exportFormats.openai.description"),
},
]);
const IMPORT_JSON_SNIPPETS: Record<string, string> = {
openai: `{
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Explain quantum entanglement."
}
],
"model": "gpt-4o"
}`,
langfuse: `{
"input": {
"messages": [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "user prompt"}
]
},
"metadata": {
"traceId": "abc123"
}
}`,
conversation: `{
"messages": [
{"role": "system", "content": "System message"},
{"role": "user", "content": "User request"}
],
"tools": []
}`,
smart: ``,
};
// 导出预览数据
const buildExportPayload = () => ({
messages: props.messages.map((msg) => ({
role: msg.role,
content: msg.content,
...(msg.name && { name: msg.name }),
...(msg.tool_calls && { tool_calls: msg.tool_calls }),
...(msg.tool_call_id && { tool_call_id: msg.tool_call_id }),
})),
tools: props.tools,
metadata: {
exportTime: new Date().toISOString(),
version: "1.0",
source: "manual",
origin: "import_export_dialog",
},
});
const exportPreviewData = computed(() => {
const basePayload = buildExportPayload();
if (selectedExportFormat.value === "openai") {
const converter = contextEditor.services?.converter;
if (converter) {
const result = converter.toOpenAI(basePayload);
if (result.success && result.data) {
return JSON.stringify(result.data, null, 2);
}
console.error(
result.error || "Failed to build OpenAI preview payload",
);
}
}
return JSON.stringify(basePayload, null, 2);
});
// 事件处理
const handleVisibilityChange = (visible: boolean) => {
localVisible.value = visible;
if (!visible) {
importError.value = "";
exportError.value = "";
}
emit('update:visible', visible);
};
const handleClose = () => {
handleVisibilityChange(false);
};
// 消息规范化
const normalizeMessage = (
msg: Partial<ConversationMessage>,
): ConversationMessage => {
const normalizedRole = (msg.role ?? "user") as ConversationMessage["role"];
let content = "";
if (typeof msg.content === "string") {
content = msg.content;
} else if (msg.content != null) {
try {
content =
typeof msg.content === "object"
? JSON.stringify(msg.content)
: String(msg.content);
} catch {
content = String(msg.content);
}
}
const normalized: ConversationMessage = {
role: normalizedRole,
content,
};
if (typeof msg.name === "string") normalized.name = msg.name;
if (Array.isArray(msg.tool_calls)) {
normalized.tool_calls =
msg.tool_calls as ConversationMessage["tool_calls"];
}
if (typeof msg.tool_call_id === "string")
normalized.tool_call_id = msg.tool_call_id;
return normalized;
};
// 文件上传处理
const handleFileUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
loading.value = true;
importError.value = "";
const success = await contextEditor.importFromFile(file);
if (success && contextEditor.currentData.value) {
const data = contextEditor.currentData.value;
emit('import-success', {
messages: (data.messages || []).map((msg) => normalizeMessage(msg)),
tools: data.tools || [],
});
handleClose();
} else {
importError.value = t("contextEditor.importFailed");
}
} catch (err) {
console.error("File upload error:", err);
const errorMsg = err instanceof Error ? err.message : t("contextEditor.importFailed");
importError.value = errorMsg;
} finally {
loading.value = false;
}
};
// 导入提交处理
const handleImportSubmit = async () => {
if (!importData.value.trim()) {
importError.value = t("contextEditor.importDataRequired");
return;
}
try {
loading.value = true;
importError.value = "";
const jsonData = JSON.parse(importData.value);
let result;
switch (selectedImportFormat.value) {
case "smart":
result = contextEditor.smartImport(jsonData);
break;
case "openai":
result = contextEditor.convertFromOpenAI(jsonData);
break;
case "langfuse":
result = contextEditor.convertFromLangFuse(jsonData);
break;
case "conversation":
// 直接设置为对话格式
if (Array.isArray(jsonData)) {
emit('import-success', {
messages: jsonData.map((msg: Partial<ConversationMessage>) => normalizeMessage(msg)),
});
} else if (jsonData.messages && Array.isArray(jsonData.messages)) {
emit('import-success', {
messages: jsonData.messages.map((msg: Partial<ConversationMessage>) => normalizeMessage(msg)),
tools: jsonData.tools || [],
});
} else {
importError.value = t("contextEditor.invalidConversationFormat");
return;
}
importError.value = "";
handleClose();
return;
default:
importError.value = t("contextEditor.unsupportedImportFormat");
return;
}
// 处理转换结果
if (result && result.success && contextEditor.currentData.value) {
const data = contextEditor.currentData.value;
emit('import-success', {
messages: (data.messages || []).map((msg) => normalizeMessage(msg)),
tools: data.tools || [],
});
importError.value = "";
handleClose();
} else {
importError.value = result?.error || t("contextEditor.importFailed");
}
} catch (err) {
console.error("Import error:", err);
const errorMsg = err instanceof Error ? err.message : t("contextEditor.invalidJsonFormat");
importError.value = errorMsg;
} finally {
loading.value = false;
}
};
// 导出到文件
const notifyExportError = (key: string, err?: unknown) => {
const baseMessage = t(key);
const details = err instanceof Error ? err.message : "";
const composed = details ? `${baseMessage}: ${details}` : baseMessage;
exportError.value = composed;
emit('export-error', composed);
console.error(composed);
};
const handleExportToFile = () => {
try {
loading.value = true;
exportError.value = "";
const exportData: StandardPromptData = buildExportPayload();
contextEditor.setData(exportData);
const success = contextEditor.exportToFile(
selectedExportFormat.value as ExportFormat,
`context-export-${Date.now()}`,
);
if (success) {
emit('export-success');
handleClose();
} else {
throw new Error(t("contextEditor.exportFailed"));
}
} catch (err) {
notifyExportError("contextEditor.exportFailed", err);
} finally {
loading.value = false;
}
};
// 导出到剪贴板
const handleExportToClipboard = async () => {
try {
loading.value = true;
exportError.value = "";
const exportData: StandardPromptData = buildExportPayload();
contextEditor.setData(exportData);
const success = await contextEditor.exportToClipboard(
selectedExportFormat.value as ExportFormat,
);
if (success) {
emit('export-success');
handleClose();
} else {
throw new Error(t("contextEditor.copyFailed"));
}
} catch (err) {
notifyExportError("contextEditor.copyFailed", err);
} finally {
loading.value = false;
}
};
// 获取导入占位符
const getImportPlaceholder = () => {
const format = selectedImportFormat.value;
const prefix = t(`contextEditor.importPlaceholders.${format}`);
const snippet = IMPORT_JSON_SNIPPETS[format] || IMPORT_JSON_SNIPPETS.smart;
return snippet ? `${prefix}\n${snippet}` : prefix;
};
</script>
<style scoped>
.hidden {
display: none;
}
</style>
+7 -8
View File
@@ -1654,22 +1654,21 @@ export default {
// Import/Export formats
importFormats: {
smart: { name: "Smart Detection", description: "Auto-detect format and convert" },
conversation: { name: "Conversation", description: "Standard conversation message format" },
openai: { name: "OpenAI", description: "OpenAI API request format" },
langfuse: { name: "LangFuse", description: "LangFuse tracking data format" },
conversation: { name: "Internal Format", description: "Prompt Optimizer internal JSON structure" },
},
exportFormats: {
standard: { name: "Standard", description: "Internal standard data format" },
standard: { name: "Internal Format", description: "Prompt Optimizer internal standard format" },
openai: { name: "OpenAI", description: "OpenAI API compatible format" },
template: { name: "Template", description: "Reusable template format" },
},
// Import placeholders
// Import placeholders (localized text, JSON snippet fixed in component)
importPlaceholders: {
openai: 'OpenAI API request format, e.g.:\n{\n "messages": [...],\n "model": "gpt-4"\n}',
langfuse: 'LangFuse tracking data, e.g.:\n{\n "input": {\n "messages": [...]\n }\n}',
conversation: 'Standard conversation format, e.g.:\n{\n "messages": [\n {"role": "system", "content": "..."},\n {"role": "user", "content": "..."}\n ]\n}',
smart: "Paste any supported JSON format, system will auto-detect",
openai: "OpenAI API request format (example below):",
langfuse: "LangFuse trace payload (example below):",
conversation: "Standard conversation JSON (example below):",
smart: "Paste any supported JSON format (OpenAI, LangFuse, or conversation array). The editor will auto-detect the format.",
},
// Console errors (developer logs)
+7 -8
View File
@@ -1595,22 +1595,21 @@ export default {
// Import/Export formats
importFormats: {
smart: { name: "智能识别", description: "自动检测格式并转换" },
conversation: { name: "会话格式", description: "标准的会话消息格式" },
openai: { name: "OpenAI", description: "OpenAI API 请求格式" },
langfuse: { name: "LangFuse", description: "LangFuse 追踪数据格式" },
conversation: { name: "内部格式", description: "Prompt Optimizer 内部标准 JSON 结构" },
},
exportFormats: {
standard: { name: "标准格式", description: "内部标准数据格式" },
standard: { name: "内部格式", description: "Prompt Optimizer 内部标准数据格式" },
openai: { name: "OpenAI", description: "OpenAI API 兼容格式" },
template: { name: "模板格式", description: "可复用的模板格式" },
},
// Import placeholders
// Import placeholders (文本可本地化,JSON 示例固定为英文)
importPlaceholders: {
openai: 'OpenAI API 请求格式,例如:\n{\n "messages": [...],\n "model": "gpt-4"\n}',
langfuse: 'LangFuse 追踪数据,例如:\n{\n "input": {\n "messages": [...]\n }\n}',
conversation: '标准会话格式,例如:\n{\n "messages": [\n {"role": "system", "content": "..."},\n {"role": "user", "content": "..."}\n ]\n}',
smart: "粘贴任意支持格式的 JSON 数据,系统将自动识别",
openai: "OpenAI API 请求格式(示例见下方):",
langfuse: "LangFuse 追踪数据格式(示例见下方):",
conversation: "标准会话 JSON 格式(示例见下方):",
smart: "粘贴任意受支持的 JSON(OpenAI、LangFuse 或会话数组),系统会自动识别格式。",
},
// Console errors (开发者日志)
+7 -8
View File
@@ -1559,22 +1559,21 @@ export default {
// Import/Export formats
importFormats: {
smart: { name: "智慧識別", description: "自動偵測格式並轉換" },
conversation: { name: "會話格式", description: "標準的會話訊息格式" },
openai: { name: "OpenAI", description: "OpenAI API 請求格式" },
langfuse: { name: "LangFuse", description: "LangFuse 追蹤資料格式" },
conversation: { name: "內部格式", description: "Prompt Optimizer 內部標準 JSON 結構" },
},
exportFormats: {
standard: { name: "標準格式", description: "內部標準資料格式" },
standard: { name: "內部格式", description: "Prompt Optimizer 內部標準資料格式" },
openai: { name: "OpenAI", description: "OpenAI API 相容格式" },
template: { name: "範本格式", description: "可複用的範本格式" },
},
// Import placeholders
// Import placeholders (文字本地化,JSON 範例固定英文)
importPlaceholders: {
openai: 'OpenAI API 請求格式,例如:\n{\n "messages": [...],\n "model": "gpt-4"\n}',
langfuse: 'LangFuse 追蹤資料,例如:\n{\n "input": {\n "messages": [...]\n }\n}',
conversation: '標準會話格式,例如:\n{\n "messages": [\n {"role": "system", "content": "..."},\n {"role": "user", "content": "..."}\n ]\n}',
smart: "貼上任意支援格式的 JSON 資料,系統將自動識別",
openai: "OpenAI API 請求格式(下方示例):",
langfuse: "LangFuse 追蹤資料格式(下方示例):",
conversation: "標準會話 JSON 格式(下方示例):",
smart: "貼上任意支援的 JSON(OpenAI、LangFuse 或會話陣列),系統會自動辨識格式。",
},
// Console errors (開發者日誌)
@@ -5,6 +5,7 @@ import { nextTick } from 'vue'
// 组件导入
import ConversationManager from '../../src/components/context-mode/ConversationManager.vue'
import ContextEditor from '../../src/components/context-mode/ContextEditor.vue'
import ImportExportDialog from '../../src/components/context-mode/ImportExportDialog.vue'
import VariableManagerModal from '../../src/components/variable/VariableManagerModal.vue'
// Mock Naive UI 组件 - 简化版本用于E2E测试
@@ -214,7 +215,18 @@ vi.mock('../../src/composables/useAccessibility', () => ({
})
}))
vi.mock('../../src/composables/useContextEditor', () => ({
const mockConverter = {
toOpenAI: vi.fn().mockImplementation((data) => ({
success: true,
data: {
model: data.model || 'gpt-test',
messages: data.messages,
tools: data.tools
}
}))
}
vi.mock('../../src/composables/context/useContextEditor', () => ({
useContextEditor: () => ({
currentData: { value: null },
isLoading: { value: false },
@@ -222,9 +234,12 @@ vi.mock('../../src/composables/useContextEditor', () => ({
convertFromOpenAI: vi.fn(),
convertFromLangFuse: vi.fn(),
importFromFile: vi.fn(),
exportToFile: vi.fn().mockResolvedValue(true),
exportToFile: vi.fn().mockReturnValue(true),
exportToClipboard: vi.fn().mockResolvedValue(true),
setData: vi.fn()
setData: vi.fn(),
services: {
converter: mockConverter
}
})
}))
@@ -509,21 +524,12 @@ describe('完整用户流程E2E测试', () => {
describe('3. 导入导出和格式转换功能', () => {
it('应该支持多种格式的导入功能', async () => {
contextEditorWrapper = mount(ContextEditor, {
const dialogWrapper = mount(ImportExportDialog, {
props: {
visible: true,
state: {
messages: [],
variables: {},
tools: [],
showVariablePreview: true,
showToolManager: false,
mode: 'edit'
},
scanVariables,
replaceVariables,
isPredefinedVariable: () => false,
variableManager: createMockVariableManager()
mode: 'import',
messages: [],
tools: []
},
global: {
stubs: {},
@@ -535,40 +541,48 @@ describe('完整用户流程E2E测试', () => {
await nextTick()
// 验证导入功能存在
expect(contextEditorWrapper.vm.handleImport).toBeDefined()
expect(contextEditorWrapper.vm.handleImportSubmit).toBeDefined()
// 验证支持的导入格式
const importFormats = contextEditorWrapper.vm.importFormats
expect(importFormats).toEqual([
{ id: 'smart', name: '智能识别', description: '自动检测格式并转换' },
{ id: 'conversation', name: '会话格式', description: '标准的会话消息格式' },
{ id: 'openai', name: 'OpenAI', description: 'OpenAI API 请求格式' },
{ id: 'langfuse', name: 'LangFuse', description: 'LangFuse 追踪数据格式' }
])
const formats = dialogWrapper.vm.importFormats
expect(formats.map(f => f.id)).toEqual(['smart', 'openai', 'langfuse', 'conversation'])
// 测试对话格式导入
const conversationData = {
dialogWrapper.vm.importData = JSON.stringify({
messages: [
{ role: 'system', content: '测试系统消息' },
{ role: 'user', content: '测试用户消息' }
{ role: 'system', content: '系统消息' },
{ role: 'user', content: '用户消息' }
],
variables: { testVar: 'testValue' }
}
tools: []
})
dialogWrapper.vm.selectedImportFormat = 'conversation'
// 设置导入数据
contextEditorWrapper.vm.importData = JSON.stringify(conversationData)
contextEditorWrapper.vm.selectedImportFormat = 'conversation'
// 执行导入
await contextEditorWrapper.vm.handleImportSubmit()
await nextTick()
await dialogWrapper.vm.handleImportSubmit()
// 验证导入结果
expect(contextEditorWrapper.emitted('update:state')).toBeTruthy()
expect(dialogWrapper.emitted('import-success')).toBeTruthy()
})
it('应该支持多种格式的导出功能', async () => {
const dialogWrapper = mount(ImportExportDialog, {
props: {
visible: true,
mode: 'export',
messages: [{ role: 'user', content: '测试消息' }],
tools: []
},
global: {
stubs: {},
mocks: {
announcements: []
}
}
})
await nextTick()
const formats = dialogWrapper.vm.exportFormats
expect(formats.map(f => f.id)).toEqual(['standard', 'openai'])
await dialogWrapper.vm.handleExportToFile()
expect(dialogWrapper.emitted('export-success')).toBeTruthy()
})
})
describe('4. 变量管理的跨组件协作', () => {
@@ -887,10 +901,11 @@ describe('完整用户流程E2E测试', () => {
// 验证最终状态(简化验证)
expect(contextEditorWrapper.exists()).toBe(true)
expect(contextEditorWrapper.vm).toBeTruthy()
// 验证导出功能可用
expect(contextEditorWrapper.vm.handleExportToClipboard).toBeDefined()
expect(contextEditorWrapper.vm.handleExportToFile).toBeDefined()
// 注意:导出功能已移至 ImportExportDialog 组件
// 验证导入导出对话框可以打开
expect(contextEditorWrapper.vm.handleImport).toBeDefined()
expect(contextEditorWrapper.vm.handleExport).toBeDefined()
})
it('应该正确处理错误和边界情况', async () => {
@@ -145,9 +145,9 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: any) => {
const translations = {
'contextEditor.importPlaceholders.openai': 'OpenAI API 请求格式,例如:\n{\n "messages": [...],\n "model": "gpt-4"\n}',
'contextEditor.importPlaceholders.langfuse': 'LangFuse 追踪数据,例如:\n{\n "input": {\n "messages": [...]\n }\n}',
'contextEditor.importPlaceholders.conversation': '标准会话格式,例如:\n{\n "messages": [\n {"role": "system", "content": "..."},\n {"role": "user", "content": "..."}\n ]\n}',
'contextEditor.importPlaceholders.openai': 'OpenAI API 请求格式(示例如下):',
'contextEditor.importPlaceholders.langfuse': 'LangFuse 追踪数据格式(示例如下):',
'contextEditor.importPlaceholders.conversation': '标准会话 JSON 格式(示例如下):',
'contextEditor.importPlaceholders.smart': "粘贴任意支持格式的 JSON 数据,系统将自动识别"
}
@@ -455,7 +455,7 @@ describe('ContextEditor 综合测试', () => {
it('点击导入按钮应该打开导入对话框', async () => {
wrapper = await createWrapper()
await wrapper.vm.handleImport()
expect(wrapper.vm.showImportDialog).toBe(true)
})
@@ -467,89 +467,12 @@ describe('ContextEditor 综合测试', () => {
messages: [{ role: 'user', content: 'test' }]
}
})
await wrapper.vm.handleExport()
expect(wrapper.vm.showExportDialog).toBe(true)
})
it('导入对话框应该显示正确的格式选项', async () => {
wrapper = await createWrapper()
wrapper.vm.showImportDialog = true
await nextTick()
expect(wrapper.vm.importFormats).toHaveLength(4)
expect(wrapper.vm.importFormats.map(f => f.id)).toEqual(['smart', 'conversation', 'openai', 'langfuse'])
})
it('导出对话框应该显示正确的格式选项', async () => {
wrapper = await createWrapper()
wrapper.vm.showExportDialog = true
await nextTick()
expect(wrapper.vm.exportFormats).toHaveLength(3)
expect(wrapper.vm.exportFormats.map(f => f.id)).toEqual(['standard', 'openai', 'template'])
})
it('应该正确处理不同导入格式的占位符', async () => {
wrapper = await createWrapper()
wrapper.vm.selectedImportFormat = 'openai'
expect(wrapper.vm.getImportPlaceholder()).toContain('OpenAI API')
wrapper.vm.selectedImportFormat = 'langfuse'
expect(wrapper.vm.getImportPlaceholder()).toContain('LangFuse')
wrapper.vm.selectedImportFormat = 'conversation'
expect(wrapper.vm.getImportPlaceholder()).toContain('会话格式')
wrapper.vm.selectedImportFormat = 'smart'
expect(wrapper.vm.getImportPlaceholder()).toContain('自动识别')
})
it('导入失败时应该显示错误信息', async () => {
mockContextEditor.smartImport.mockReturnValueOnce({
success: false,
error: '导入数据格式错误'
})
wrapper = await createWrapper()
wrapper.vm.selectedImportFormat = 'smart'
wrapper.vm.importData = 'invalid json'
await wrapper.vm.handleImportSubmit()
expect(wrapper.vm.importError).toBeTruthy()
})
it('导出失败时应该处理错误', async () => {
mockContextEditor.exportToFile.mockReturnValueOnce(false)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper = await createWrapper({
state: {
...defaultProps.state,
messages: [{ role: 'user', content: 'test' }]
}
})
await wrapper.vm.handleExportToFile()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('导入成功后应该清空导入状态', async () => {
wrapper = await createWrapper()
wrapper.vm.selectedImportFormat = 'conversation'
wrapper.vm.importData = '{"messages":[{"role":"user","content":"test"}]}'
await wrapper.vm.handleImportSubmit()
expect(wrapper.vm.showImportDialog).toBe(false)
expect(wrapper.vm.importData).toBe('')
expect(wrapper.vm.importError).toBe('')
})
// 注意:导入导出的详细功能测试已移至 ImportExportDialog.spec.ts
})
describe('optimizationMode 参数传递', () => {
@@ -698,36 +621,8 @@ describe('ContextEditor 综合测试', () => {
expect(wrapper.emitted('update:visible')[0]).toEqual([false])
})
})
describe('错误处理', () => {
it('导入失败时应该显示错误信息', async () => {
// 这个测试实际走的是JSON.parse失败的分支,测试输入无效JSON的错误处理
wrapper = await createWrapper()
wrapper.vm.selectedImportFormat = 'smart'
wrapper.vm.importData = 'invalid json' // 无效JSON,触发JSON.parse失败
await wrapper.vm.handleImportSubmit()
// 根据组件代码第1262行:'数据格式错误,请检查JSON格式'
// 但实际错误信息来自JSON.parse的SyntaxError message
expect(wrapper.vm.importError).toContain('Unexpected token')
})
it('文件上传失败时应该设置错误状态', async () => {
mockContextEditor.importFromFile.mockResolvedValueOnce(false)
wrapper = await createWrapper()
const file = new File(['invalid content'], 'test.json', { type: 'application/json' })
const event = { target: { files: [file] } }
await wrapper.vm.handleFileUpload(event)
expect(wrapper.vm.importError).toBeTruthy()
})
})
})
// 导出类型以供其他测试使用
export type MockContextEditor = typeof mockContextEditor
export { mockQuickTemplates }
export { mockQuickTemplates }
@@ -0,0 +1,194 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ImportExportDialog from '../../../src/components/context-mode/ImportExportDialog.vue'
const mockConverter = {
toOpenAI: vi.fn()
}
const mockContextEditor = {
currentData: { value: null },
isLoading: { value: false },
smartImport: vi.fn(),
convertFromOpenAI: vi.fn(),
convertFromLangFuse: vi.fn(),
importFromFile: vi.fn(),
exportToFile: vi.fn(),
exportToClipboard: vi.fn(),
setData: vi.fn(),
services: {
converter: mockConverter
}
}
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('../../../src/composables/context/useContextEditor', () => ({
useContextEditor: () => mockContextEditor
}))
const naiveStubs = {
NModal: {
name: 'NModal',
template: '<div v-if="show"><slot /><slot name="action" /></div>',
props: ['show', 'title', 'preset', 'style'],
emits: ['update:show']
},
NButton: {
name: 'NButton',
template: '<button @click="$emit(\'click\')" :disabled="disabled"><slot /></button>',
props: ['type', 'disabled', 'loading', 'size', 'secondary'],
emits: ['click']
},
NSpace: {
name: 'NSpace',
template: '<div class="n-space"><slot /></div>',
props: ['size', 'align', 'justify', 'wrap']
},
NInput: {
name: 'NInput',
template: '<textarea v-if="type === \'textarea\'" :value="value" @input="$emit(\'update:value\', $event.target.value)"></textarea>',
props: ['value', 'type', 'placeholder', 'autosize', 'readonly'],
emits: ['update:value']
},
NText: {
name: 'NText',
template: '<span><slot /></span>',
props: ['depth', 'type']
}
}
type DialogProps = {
visible: boolean;
mode: 'import' | 'export';
messages: any[];
tools?: any[];
}
const createWrapper = (props?: Partial<DialogProps>) => {
return mount(ImportExportDialog, {
props: {
visible: true,
mode: 'import',
messages: [],
tools: [],
...props
},
global: {
stubs: naiveStubs
}
})
}
describe('ImportExportDialog', () => {
beforeEach(() => {
mockContextEditor.currentData.value = null
mockContextEditor.smartImport.mockReset()
mockContextEditor.convertFromOpenAI.mockReset()
mockContextEditor.convertFromLangFuse.mockReset()
mockContextEditor.importFromFile.mockReset()
mockContextEditor.exportToFile.mockReset()
mockContextEditor.exportToClipboard.mockReset()
mockContextEditor.setData.mockReset()
mockContextEditor.exportToFile.mockReturnValue(true)
mockContextEditor.exportToClipboard.mockResolvedValue(true)
mockConverter.toOpenAI.mockReset()
mockConverter.toOpenAI.mockImplementation((data) => ({
success: true,
data: {
model: data.model || 'gpt-test',
messages: data.messages,
tools: data.tools
}
}))
})
it('应该提供完整的导入格式选项', () => {
const wrapper = createWrapper()
expect(wrapper.vm.importFormats.map(f => f.id)).toEqual(['smart', 'openai', 'langfuse', 'conversation'])
})
it('会话格式导入成功后应该触发 import-success 事件', async () => {
const wrapper = createWrapper()
wrapper.vm.selectedImportFormat = 'conversation'
wrapper.vm.importData = JSON.stringify({
messages: [{ role: 'user', content: 'hello' }]
})
await wrapper.vm.handleImportSubmit()
const events = wrapper.emitted('import-success')
expect(events).toBeTruthy()
expect(events?.[0]?.[0]?.messages).toHaveLength(1)
expect(wrapper.vm.importError).toBe('')
})
it('无效 JSON 数据会设置错误信息', async () => {
const wrapper = createWrapper()
wrapper.vm.importData = 'invalid json'
wrapper.vm.selectedImportFormat = 'smart'
await wrapper.vm.handleImportSubmit()
expect(wrapper.vm.importError.length).toBeGreaterThan(0)
})
it('导出预览应该与实际导出结构一致', async () => {
const wrapper = createWrapper({
mode: 'export',
messages: [{ role: 'user', content: 'test' }],
tools: [{ type: 'function', function: { name: 'tool', description: 'desc', parameters: {} } }]
})
await nextTick()
const preview = JSON.parse(wrapper.vm.exportPreviewData)
expect(preview.messages).toHaveLength(1)
expect(preview.tools).toHaveLength(1)
expect(preview.metadata.origin).toBe('import_export_dialog')
})
it('导出成功会触发 export-success 并清除错误信息', async () => {
const wrapper = createWrapper({
mode: 'export',
messages: [{ role: 'user', content: 'test' }]
})
await wrapper.vm.handleExportToFile()
expect(wrapper.emitted('export-success')).toBeTruthy()
expect(wrapper.vm.exportError).toBe('')
})
it('导出失败会展示错误并触发 export-error', async () => {
mockContextEditor.exportToClipboard.mockResolvedValueOnce(false)
const wrapper = createWrapper({
mode: 'export',
messages: [{ role: 'user', content: 'test' }]
})
await wrapper.vm.handleExportToClipboard()
expect(wrapper.vm.exportError).toContain('contextEditor.copyFailed')
expect(wrapper.emitted('export-error')).toBeTruthy()
})
it('选择 OpenAI 格式时预览应基于转换结果', async () => {
const wrapper = createWrapper({
mode: 'export',
messages: [{ role: 'user', content: 'preview' }]
})
wrapper.vm.selectedExportFormat = 'openai'
await nextTick()
const preview = JSON.parse(wrapper.vm.exportPreviewData)
expect(preview.model).toBe('gpt-test')
expect(preview.metadata).toBeUndefined()
})
})