初始化仓库:AI 接口自动化测试平台

纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-22 15:44:50 +08:00
co-authored by Cursor
commit d810abdcee
52 changed files with 19109 additions and 0 deletions
@@ -0,0 +1,200 @@
<script setup>
import { ElMessage } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import { apiPost, apiPut } from "../api/http";
const props = defineProps({
modelValue: { type: Boolean, default: false },
target: { type: String, required: true },
mode: { type: String, default: "create" },
editPath: { type: String, default: "" },
parentPath: { type: String, default: "" },
folderOptions: { type: Array, default: () => [] },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "success"]);
const saving = ref(false);
const formState = reactive({
parent_path: "",
folder_name: "",
});
const isEditMode = computed(() => props.mode === "edit");
const dialogTitle = computed(() => {
if (props.title) return props.title;
return isEditMode.value ? "编辑目录" : "新建目录";
});
function normalizeFolderPath(value) {
const text = String(value || "")
.trim()
.replace(/\\/g, "/");
if (!text || text === "/") return "";
return text
.split("/")
.map((part) => part.trim())
.filter(Boolean)
.join("/");
}
function splitFolderPath(path) {
const normalized = normalizeFolderPath(path);
if (!normalized) {
return { parent_path: "", folder_name: "" };
}
const parts = normalized.split("/").filter(Boolean);
if (parts.length === 1) {
return { parent_path: "", folder_name: parts[0] };
}
return {
parent_path: parts.slice(0, -1).join("/"),
folder_name: parts[parts.length - 1],
};
}
function isInvalidParentOption(path) {
const current = normalizeFolderPath(props.editPath);
const candidate = normalizeFolderPath(path);
if (!current || !candidate) return false;
if (candidate === current) return true;
return candidate.startsWith(`${current}/`);
}
const resolvedPath = computed(() => {
const parent = normalizeFolderPath(formState.parent_path);
const name = normalizeFolderPath(formState.folder_name);
if (!name) return parent;
return parent ? `${parent}/${name}` : name;
});
const parentOptions = computed(() => {
const paths = new Set(
(props.folderOptions || [])
.map((item) => normalizeFolderPath(item))
.filter(Boolean)
);
if (isEditMode.value) {
const current = normalizeFolderPath(props.editPath);
if (current) paths.add(current);
}
return [
{ label: "根目录", value: "" },
...[...paths]
.filter((path) => !isInvalidParentOption(path))
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((path) => ({ label: path, value: path })),
];
});
watch(
() => props.modelValue,
(visible) => {
if (!visible) return;
if (isEditMode.value) {
const parts = splitFolderPath(props.editPath);
formState.parent_path = parts.parent_path;
formState.folder_name = parts.folder_name;
return;
}
formState.parent_path = normalizeFolderPath(props.parentPath);
formState.folder_name = "";
}
);
async function submitForm() {
const path = resolvedPath.value;
if (!path) {
ElMessage.warning("请输入目录名称");
return;
}
if (isEditMode.value) {
const fromPath = normalizeFolderPath(props.editPath);
if (!fromPath) {
ElMessage.warning("缺少原目录路径");
return;
}
if (path === fromPath) {
emit("update:modelValue", false);
return;
}
saving.value = true;
try {
const result = await apiPut("/api/folders/move", {
target: props.target,
from_path: fromPath,
to_path: path,
});
const movedResources = Number(result.moved_resources || 0);
ElMessage.success(`目录已更新:${fromPath}${path}(同步 ${movedResources} 项资源)`);
emit("update:modelValue", false);
emit("success", path);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "目录更新失败");
} finally {
saving.value = false;
}
return;
}
saving.value = true;
try {
await apiPost("/api/folders/ensure", { target: props.target, path });
ElMessage.success(`目录已创建:${path}`);
emit("update:modelValue", false);
emit("success", path);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "目录创建失败");
} finally {
saving.value = false;
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="520px"
destroy-on-close
@close="emit('update:modelValue', false)"
>
<el-form label-position="top">
<el-form-item v-if="isEditMode" label="原路径">
<el-input :model-value="normalizeFolderPath(editPath)" readonly />
</el-form-item>
<el-form-item label="上级目录">
<el-select v-model="formState.parent_path" class="full-width" filterable>
<el-option
v-for="item in parentOptions"
:key="item.value || 'root'"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="目录名称">
<el-input
v-model="formState.folder_name"
placeholder="可填单级名称;移动上级目录时在此修改"
@keyup.enter="submitForm"
/>
</el-form-item>
<el-form-item :label="isEditMode ? '新路径' : '完整路径'">
<el-input :model-value="resolvedPath || '/根目录'" readonly />
</el-form-item>
<p v-if="isEditMode" class="folder-form__hint">
保存后会同步移动该目录下的子目录以及目录内的{{ target === "workflows" ? "工作流" : "接口" }}
</p>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">
{{ isEditMode ? "保存" : "创建" }}
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,258 @@
<script setup>
import { json, jsonParseLinter } from "@codemirror/lang-json";
import { linter, lintGutter } from "@codemirror/lint";
import { EditorState } from "@codemirror/state";
import { EditorView, placeholder } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { CircleCheck, WarningFilled } from "@element-plus/icons-vue";
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from "vue";
const props = defineProps({
modelValue: {
type: String,
default: "{}",
},
minHeight: {
type: Number,
default: 140,
},
placeholder: {
type: String,
default: "{}",
},
readOnly: {
type: Boolean,
default: false,
},
requireObject: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(["update:modelValue", "validity"]);
const hostRef = shallowRef(null);
const viewRef = shallowRef(null);
const statusMessage = shallowRef("");
const isValid = shallowRef(true);
const statusType = computed(() => (isValid.value ? "success" : "error"));
function inspectJson(text) {
const trimmed = String(text ?? "").trim();
if (!trimmed) {
return { valid: true, message: "JSON 格式正确(空对象将保存为 {})" };
}
try {
const parsed = JSON.parse(trimmed);
if (props.requireObject && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
return { valid: false, message: "必须是 JSON 对象,请使用花括号 {}" };
}
return { valid: true, message: "JSON 格式正确" };
} catch (error) {
return {
valid: false,
message: error instanceof Error ? error.message : "JSON 解析失败",
};
}
}
function syncValidity(text) {
const result = inspectJson(text);
isValid.value = result.valid;
statusMessage.value = result.message;
emit("validity", result.valid);
return result;
}
function jsonObjectLinter() {
if (!props.requireObject) {
return () => [];
}
return (view) => {
const text = view.state.doc.toString().trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return [
{
from: 0,
to: view.state.doc.length,
severity: "error",
message: "必须是 JSON 对象(使用 {}",
},
];
}
} catch {
return [];
}
return [];
};
}
function buildTheme() {
return EditorView.theme({
"&": {
fontSize: "13px",
backgroundColor: "#f6f8fc",
border: "1px solid #d5e2f2",
borderRadius: "10px",
overflow: "hidden",
},
"&.cm-focused": {
outline: "none",
borderColor: "#409eff",
boxShadow: "0 0 0 1px rgba(64, 158, 255, 0.18)",
},
".cm-scroller": {
minHeight: `${props.minHeight}px`,
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
lineHeight: "1.55",
},
".cm-content": {
padding: "10px 2px",
caretColor: "#409eff",
},
".cm-gutters": {
backgroundColor: "#eef3fa",
color: "#8a9ab1",
borderRight: "1px solid #d5e2f2",
},
".cm-activeLineGutter": {
backgroundColor: "#e4edf8",
},
".cm-activeLine": {
backgroundColor: "rgba(64, 158, 255, 0.06)",
},
".cm-lintRange-error": {
backgroundImage: "none",
backgroundColor: "rgba(245, 108, 108, 0.18)",
},
".cm-tooltip-lint": {
backgroundColor: "#fff5f5",
border: "1px solid #fbc4c4",
color: "#c45656",
},
});
}
function createEditorState(doc) {
return EditorState.create({
doc,
extensions: [
basicSetup,
json(),
buildTheme(),
EditorView.lineWrapping,
placeholder(props.placeholder),
lintGutter(),
linter(jsonParseLinter()),
linter(jsonObjectLinter()),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const text = update.state.doc.toString();
emit("update:modelValue", text);
syncValidity(text);
}),
EditorState.readOnly.of(props.readOnly),
],
});
}
function mountEditor() {
if (!hostRef.value) return;
const initialDoc = props.modelValue || "";
syncValidity(initialDoc);
viewRef.value = new EditorView({
state: createEditorState(initialDoc),
parent: hostRef.value,
});
}
function replaceDocument(text) {
const view = viewRef.value;
if (!view) return;
const current = view.state.doc.toString();
if (text === current) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: text ?? "" },
});
syncValidity(text);
}
onMounted(() => {
mountEditor();
});
onBeforeUnmount(() => {
viewRef.value?.destroy();
viewRef.value = null;
});
watch(
() => props.modelValue,
(value) => {
replaceDocument(value ?? "");
}
);
watch(
() => props.minHeight,
() => {
viewRef.value?.requestMeasure();
}
);
</script>
<template>
<div class="json-code-editor" :class="{ 'is-invalid': !isValid }">
<div ref="hostRef" class="json-code-editor__host" />
<div class="json-code-editor__status" :class="`is-${statusType}`">
<el-icon v-if="isValid"><CircleCheck /></el-icon>
<el-icon v-else><WarningFilled /></el-icon>
<span>{{ statusMessage }}</span>
</div>
</div>
</template>
<style scoped>
.json-code-editor {
width: 100%;
}
.json-code-editor__host :deep(.cm-editor) {
width: 100%;
}
.json-code-editor__status {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
.json-code-editor__status.is-success {
color: #3f9f6d;
}
.json-code-editor__status.is-error {
color: #d03050;
}
.json-code-editor.is-invalid :deep(.cm-editor) {
border-color: #f5a8b8;
}
</style>
@@ -0,0 +1,49 @@
<script setup>
import { computed } from "vue";
import JsonCodeEditor from "./JsonCodeEditor.vue";
const props = defineProps({
modelValue: {
type: String,
default: "{}",
},
label: {
type: String,
default: "JSON",
},
rows: {
type: Number,
default: 5,
},
placeholder: {
type: String,
default: "{}",
},
hint: {
type: String,
default: "",
},
});
defineEmits(["update:modelValue"]);
const editorMinHeight = computed(() => Math.max(120, props.rows * 22 + 24));
</script>
<template>
<div class="json-field">
<div class="json-field__label">
<span>{{ label }}</span>
<el-tag size="small" type="info" effect="plain" class="json-field__badge">JSON</el-tag>
</div>
<JsonCodeEditor
:model-value="modelValue"
:min-height="editorMinHeight"
:placeholder="placeholder"
@update:model-value="$emit('update:modelValue', $event)"
/>
<p v-if="hint" class="json-field__hint">{{ hint }}</p>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup>
import { Delete, Plus } from "@element-plus/icons-vue";
const props = defineProps({
modelValue: {
type: Array,
default: () => [{ key: "", value: "" }],
},
keyPlaceholder: {
type: String,
default: "Key",
},
valuePlaceholder: {
type: String,
default: "Value",
},
addLabel: {
type: String,
default: "新增一行",
},
});
const emit = defineEmits(["update:modelValue"]);
function updateRows(nextRows) {
emit("update:modelValue", nextRows);
}
function updateRow(index, field, value) {
const nextRows = props.modelValue.map((row, rowIndex) =>
rowIndex === index ? { ...row, [field]: value } : row
);
updateRows(nextRows);
}
function addRow() {
updateRows([...props.modelValue, { key: "", value: "" }]);
}
function removeRow(index) {
const nextRows = props.modelValue.slice();
nextRows.splice(index, 1);
if (!nextRows.length) {
nextRows.push({ key: "", value: "" });
}
updateRows(nextRows);
}
</script>
<template>
<div class="kv-editor">
<div class="kv-editor__head">
<span class="kv-editor__col">Key</span>
<span class="kv-editor__col">Value</span>
<span class="kv-editor__action" aria-hidden="true" />
</div>
<div class="kv-editor__list">
<div v-for="(row, index) in modelValue" :key="index" class="kv-editor__row">
<el-input :model-value="row.key" :placeholder="keyPlaceholder" @update:model-value="updateRow(index, 'key', $event)" />
<el-input
:model-value="row.value"
:placeholder="valuePlaceholder"
@update:model-value="updateRow(index, 'value', $event)"
/>
<el-button circle :icon="Delete" text type="danger" @click="removeRow(index)" />
</div>
</div>
<el-button :icon="Plus" text type="primary" class="kv-editor__add" @click="addRow">
{{ addLabel }}
</el-button>
</div>
</template>
@@ -0,0 +1,179 @@
<script setup>
import { CopyDocument, Delete, Plus, Refresh, View, Hide } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, ref } from "vue";
import { apiDelete, apiGet, apiPost } from "../api/http";
const loading = ref(false);
const creating = ref(false);
const apiKeys = ref([]);
const expiresAt = ref("");
const createdKeyVisible = ref(false);
const createdKeyValue = ref("");
function maskApiKey(row) {
return row.masked_key || `${row.key_prefix}••••••••${row.key_hint}`;
}
function formatExpires(value) {
if (!value) return "永久有效";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN");
}
async function loadApiKeys() {
loading.value = true;
try {
apiKeys.value = await apiGet("/api/me/api-keys");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "API Key 加载失败");
} finally {
loading.value = false;
}
}
async function createApiKey() {
creating.value = true;
try {
const payload = expiresAt.value ? { expires_at: expiresAt.value } : { expires_at: null };
const row = await apiPost("/api/me/api-keys", payload);
createdKeyValue.value = row.api_key || "";
createdKeyVisible.value = false;
expiresAt.value = "";
await loadApiKeys();
await ElMessageBox.alert(
"请立即复制保存,关闭后将无法再次查看完整 Key。",
"API Key 已创建",
{
confirmButtonText: "我已复制",
type: "success",
}
);
if (createdKeyValue.value) {
await copyText(createdKeyValue.value, "已复制完整 API Key");
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "创建失败");
} finally {
creating.value = false;
}
}
async function revokeApiKey(row) {
try {
await ElMessageBox.confirm(`确认吊销 API Key「${maskApiKey(row)}」吗?`, "吊销确认", {
type: "warning",
confirmButtonText: "吊销",
cancelButtonText: "取消",
});
await apiDelete(`/api/me/api-keys/${row.id}`);
ElMessage.success("API Key 已吊销");
await loadApiKeys();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "吊销失败");
}
}
}
async function copyText(text, successMessage = "已复制") {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
ElMessage.success(successMessage);
} catch {
ElMessage.error("复制失败,请手动复制");
}
}
function toggleCreatedKeyVisible() {
createdKeyVisible.value = !createdKeyVisible.value;
}
function displayCreatedKey() {
if (!createdKeyValue.value) return "—";
if (createdKeyVisible.value) return createdKeyValue.value;
const key = createdKeyValue.value;
if (key.length <= 12) return `${key.slice(0, 7)}••••`;
return `${key.slice(0, 8)}••••••••${key.slice(-4)}`;
}
onMounted(() => {
loadApiKeys();
});
</script>
<template>
<div class="profile-api-key-panel">
<el-alert
type="info"
:closable="false"
show-icon
title="MCP 连接说明"
description="在 Cursor / MCP 配置中设置环境变量 AI_TEST_API_KEY,或在请求头携带 Authorization: Bearer <api_key>。Key 以 sto- 开头。"
/>
<section class="profile-api-key-panel__create">
<div class="profile-api-key-panel__create-head">
<div>
<strong>新建 API Key</strong>
<p>不填写有效期表示永久有效</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadApiKeys">刷新</el-button>
</div>
<div class="profile-api-key-panel__create-form">
<el-date-picker
v-model="expiresAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="有效期(留空=永久)"
clearable
class="profile-api-key-panel__expires"
/>
<el-button type="primary" :icon="Plus" :loading="creating" @click="createApiKey">生成 API Key</el-button>
</div>
</section>
<section v-if="createdKeyValue" class="profile-api-key-panel__latest">
<div class="profile-api-key-panel__latest-head">
<strong>最近生成的 Key</strong>
<div class="toolbar-actions">
<el-button text :icon="createdKeyVisible ? Hide : View" @click="toggleCreatedKeyVisible">
{{ createdKeyVisible ? "隐藏" : "显示" }}
</el-button>
<el-button text type="primary" :icon="CopyDocument" @click="copyText(createdKeyValue, '已复制完整 API Key')">
复制
</el-button>
</div>
</div>
<code class="profile-api-key-panel__key-text">{{ displayCreatedKey() }}</code>
</section>
<el-table :data="apiKeys" v-loading="loading" empty-text="还没有 API Key">
<el-table-column label="API Key" min-width="220">
<template #default="{ row }">
<code class="profile-api-key-panel__masked">{{ maskApiKey(row) }}</code>
</template>
</el-table-column>
<el-table-column label="有效期" min-width="180">
<template #default="{ row }">
{{ formatExpires(row.expires_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" effect="plain">
{{ row.is_active ? "有效" : "无效" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="180" prop="created_at" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button text type="danger" :icon="Delete" @click="revokeApiKey(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
@@ -0,0 +1,127 @@
<script setup>
import { computed, ref } from "vue";
import JsonField from "./JsonField.vue";
import KeyValueEditor from "./KeyValueEditor.vue";
const props = defineProps({
overlay: {
type: Boolean,
default: false,
},
});
const headerRows = defineModel("headerRows", {
type: Array,
default: () => [{ key: "", value: "" }],
});
const bodyText = defineModel("bodyText", {
type: String,
default: "{}",
});
const queryText = defineModel("queryText", {
type: String,
default: "{}",
});
const pathParamRows = defineModel("pathParamRows", {
type: Array,
default: () => [{ key: "", value: "" }],
});
const activeTab = ref("path");
const suffix = computed(() => (props.overlay ? "(覆盖)" : ""));
</script>
<template>
<div class="request-params-tabs">
<el-tabs v-model="activeTab" class="request-params-tabs__inner">
<el-tab-pane name="path">
<template #label>路径参数</template>
<p class="request-params-tabs__hint">
替换 URL 路径里的占位符例如 URL <code>/api/users/{id}</code> 可配置
<code>id=1001</code>最终请求 <code>/api/users/1001</code>
</p>
<KeyValueEditor
v-model="pathParamRows"
key-placeholder="占位符名 id"
value-placeholder="替换值 1001 {{user_id}}"
:add-label="`新增路径参数${suffix}`"
/>
</el-tab-pane>
<el-tab-pane name="query">
<template #label>查询参数</template>
<p class="request-params-tabs__hint">
拼接到 URL 问号后面形成 <code>?key=value</code>常用于分页筛选例如
<code>page=1</code><code>status=active</code>
</p>
<JsonField
v-model="queryText"
:label="`Query${suffix}`"
:rows="overlay ? 6 : 7"
placeholder="{}"
/>
</el-tab-pane>
<el-tab-pane name="headers">
<template #label>请求头</template>
<p class="request-params-tabs__hint">
HTTP 请求头用于鉴权内容类型等例如 <code>Authorization</code><code>Content-Type</code>
</p>
<KeyValueEditor
v-model="headerRows"
key-placeholder="Header Key"
value-placeholder="Header Value"
:add-label="`新增 Header${suffix}`"
/>
</el-tab-pane>
<el-tab-pane name="body">
<template #label>请求体</template>
<p class="request-params-tabs__hint">
请求体 JSON <code>POST</code> / <code>PUT</code> / <code>PATCH</code> / <code>DELETE</code>
时作为 <code>application/json</code> 发送<code>GET</code> 一般不使用
</p>
<JsonField
v-model="bodyText"
:label="`Body${suffix}`"
:rows="overlay ? 6 : 7"
placeholder="{}"
/>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.request-params-tabs {
width: 100%;
}
.request-params-tabs__inner :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.request-params-tabs__hint {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 10px;
background: #f7faff;
border: 1px solid #e8eef6;
color: #6e7f96;
font-size: 12px;
line-height: 1.7;
}
.request-params-tabs__hint code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 11px;
color: #31517e;
background: #fff;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid #e4ebf5;
}
</style>
@@ -0,0 +1,14 @@
<script setup>
import { floatingWindows } from "../utils/sshScriptRunner";
import SshFloatingTerminalWindow from "./SshFloatingTerminalWindow.vue";
</script>
<template>
<teleport to="body">
<SshFloatingTerminalWindow
v-for="item in floatingWindows"
:key="item.sessionId"
:window-state="item"
/>
</teleport>
</template>
@@ -0,0 +1,319 @@
<script setup>
import { Close, Minus, Rank, SwitchButton } from "@element-plus/icons-vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
bringFloatingToFront,
closeFloatingWindow,
fitSessionTerminal,
FLOAT_WINDOW_DEFAULT_SIZE,
FLOAT_WINDOW_MIN_SIZE,
getSession,
registerTerminalContainer,
stopSession,
toggleFloatingMinimize,
updateFloatingPosition,
updateFloatingSize,
} from "../utils/sshScriptRunner";
const props = defineProps({
windowState: {
type: Object,
required: true,
},
});
const dragging = ref(false);
const resizing = ref(false);
const resizeAxis = ref("");
const dragOffset = ref({ x: 0, y: 0 });
const resizeStart = ref({ x: 0, y: 0, width: 0, height: 0, posX: 0, posY: 0 });
const windowRef = ref(null);
let resizeObserver = null;
let fitFrame = 0;
const session = computed(() => getSession(props.windowState.sessionId));
const windowSize = computed(() => {
const size = props.windowState.size;
if (size?.width && size?.height) return size;
return { ...FLOAT_WINDOW_DEFAULT_SIZE };
});
const statusTagType = computed(() => {
const status = session.value?.status;
if (status === "running" || status === "connecting") return "success";
if (status === "failed") return "danger";
if (status === "success") return "info";
return "warning";
});
const statusLabel = computed(() => {
const status = session.value?.status;
if (status === "running" || status === "connecting") return "运行中";
if (status === "success") return "完成";
if (status === "failed") return "失败";
return "已停止";
});
const canStop = computed(
() => session.value && (session.value.status === "running" || session.value.status === "connecting")
);
const windowStyle = computed(() => {
const style = {
left: `${props.windowState.position.x}px`,
top: `${props.windowState.position.y}px`,
zIndex: props.windowState.zIndex,
};
if (!props.windowState.minimized) {
style.width = `${windowSize.value.width}px`;
style.height = `${windowSize.value.height}px`;
}
return style;
});
function scheduleFitTerminal() {
if (fitFrame) cancelAnimationFrame(fitFrame);
fitFrame = requestAnimationFrame(() => {
fitFrame = 0;
fitSessionTerminal(props.windowState.sessionId);
});
}
function handleFocus() {
bringFloatingToFront(props.windowState.sessionId);
}
function handleClose() {
closeFloatingWindow(props.windowState.sessionId);
}
function handleMinimize() {
toggleFloatingMinimize(props.windowState.sessionId);
}
function handleStop() {
if (session.value) {
stopSession(session.value);
}
}
function clampPosition(x, y, width, height) {
const maxX = Math.max(8, window.innerWidth - width - 8);
const maxY = Math.max(8, window.innerHeight - height - 8);
return {
x: Math.min(Math.max(8, x), maxX),
y: Math.min(Math.max(8, y), maxY),
};
}
function onHeaderMouseDown(event) {
if (event.button !== 0 || resizing.value) return;
handleFocus();
const rect = windowRef.value?.getBoundingClientRect();
if (!rect) return;
dragging.value = true;
dragOffset.value = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
document.addEventListener("mousemove", onDragMove);
document.addEventListener("mouseup", onDragEnd);
}
function onDragMove(event) {
if (!dragging.value) return;
const width = windowRef.value?.offsetWidth || windowSize.value.width;
const height = windowRef.value?.offsetHeight || windowSize.value.height;
const next = clampPosition(event.clientX - dragOffset.value.x, event.clientY - dragOffset.value.y, width, height);
updateFloatingPosition(props.windowState.sessionId, next);
}
function onDragEnd() {
dragging.value = false;
document.removeEventListener("mousemove", onDragMove);
document.removeEventListener("mouseup", onDragEnd);
}
function onResizeMouseDown(event, axis) {
if (event.button !== 0 || props.windowState.minimized) return;
event.preventDefault();
event.stopPropagation();
handleFocus();
resizing.value = true;
resizeAxis.value = axis;
resizeStart.value = {
x: event.clientX,
y: event.clientY,
width: windowSize.value.width,
height: windowSize.value.height,
posX: props.windowState.position.x,
posY: props.windowState.position.y,
};
document.addEventListener("mousemove", onResizeMove);
document.addEventListener("mouseup", onResizeEnd);
}
function onResizeMove(event) {
if (!resizing.value) return;
const dx = event.clientX - resizeStart.value.x;
const dy = event.clientY - resizeStart.value.y;
const axis = resizeAxis.value;
let width = resizeStart.value.width;
let height = resizeStart.value.height;
let posX = resizeStart.value.posX;
let posY = resizeStart.value.posY;
if (axis.includes("e")) {
width = resizeStart.value.width + dx;
}
if (axis.includes("w")) {
width = resizeStart.value.width - dx;
posX = resizeStart.value.posX + dx;
}
if (axis.includes("s")) {
height = resizeStart.value.height + dy;
}
if (axis.includes("n")) {
height = resizeStart.value.height - dy;
posY = resizeStart.value.posY + dy;
}
const maxWidth = Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 16);
const maxHeight = Math.max(FLOAT_WINDOW_MIN_SIZE.height, window.innerHeight - 16);
width = Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.width, width), maxWidth);
height = Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.height, height), maxHeight);
if (axis.includes("w")) {
posX = resizeStart.value.posX + (resizeStart.value.width - width);
}
if (axis.includes("n")) {
posY = resizeStart.value.posY + (resizeStart.value.height - height);
}
updateFloatingSize(props.windowState.sessionId, { width, height });
const nextPos = clampPosition(posX, posY, width, height);
updateFloatingPosition(props.windowState.sessionId, nextPos);
scheduleFitTerminal();
}
function onResizeEnd() {
resizing.value = false;
resizeAxis.value = "";
document.removeEventListener("mousemove", onResizeMove);
document.removeEventListener("mouseup", onResizeEnd);
scheduleFitTerminal();
}
function cleanupListeners() {
onDragEnd();
onResizeEnd();
if (fitFrame) {
cancelAnimationFrame(fitFrame);
fitFrame = 0;
}
}
watch(
() => props.windowState.minimized,
(minimized) => {
if (!minimized) {
requestAnimationFrame(() => fitSessionTerminal(props.windowState.sessionId));
}
}
);
onMounted(() => {
if (!props.windowState.size) {
updateFloatingSize(props.windowState.sessionId, { ...FLOAT_WINDOW_DEFAULT_SIZE });
}
if (!props.windowState.minimized && windowRef.value) {
resizeObserver = new ResizeObserver(() => scheduleFitTerminal());
const body = windowRef.value.querySelector(".ssh-float-window__body");
if (body) resizeObserver.observe(body);
}
});
onBeforeUnmount(() => {
cleanupListeners();
resizeObserver?.disconnect();
resizeObserver = null;
});
</script>
<template>
<div
ref="windowRef"
class="ssh-float-window"
:class="{
'ssh-float-window--minimized': windowState.minimized,
'ssh-float-window--dragging': dragging,
'ssh-float-window--resizing': resizing,
}"
:style="windowStyle"
@mousedown="handleFocus"
>
<div class="ssh-float-window__header" @mousedown="onHeaderMouseDown">
<div class="ssh-float-window__title">
<el-icon class="ssh-float-window__drag-icon"><Rank /></el-icon>
<div>
<strong>{{ session?.scriptName || "脚本执行" }}</strong>
<p>{{ session?.profileHost || "" }}</p>
</div>
<el-tag size="small" :type="statusTagType" effect="plain">{{ statusLabel }}</el-tag>
</div>
<div class="ssh-float-window__actions" @mousedown.stop>
<el-button text :icon="Minus" @click="handleMinimize" />
<el-button text type="danger" :icon="SwitchButton" :disabled="!canStop" @click="handleStop" />
<el-button text type="danger" :icon="Close" @click="handleClose" />
</div>
</div>
<div v-show="!windowState.minimized" class="ssh-float-window__body">
<div class="ssh-terminal-shell ssh-float-window__terminal">
<div class="ssh-terminal-shell__meta">
<span>{{ session?.statusText || "等待输出" }}</span>
</div>
<div
:ref="(el) => registerTerminalContainer(windowState.sessionId, el)"
class="ssh-terminal-canvas ssh-float-window__terminal-canvas"
/>
</div>
</div>
<template v-if="!windowState.minimized">
<div
class="ssh-float-window__resize ssh-float-window__resize--n"
@mousedown="onResizeMouseDown($event, 'n')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--s"
@mousedown="onResizeMouseDown($event, 's')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--e"
@mousedown="onResizeMouseDown($event, 'e')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--w"
@mousedown="onResizeMouseDown($event, 'w')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--ne"
@mousedown="onResizeMouseDown($event, 'ne')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--nw"
@mousedown="onResizeMouseDown($event, 'nw')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--se"
@mousedown="onResizeMouseDown($event, 'se')"
/>
<div
class="ssh-float-window__resize ssh-float-window__resize--sw"
@mousedown="onResizeMouseDown($event, 'sw')"
/>
</template>
</div>
</template>
@@ -0,0 +1,523 @@
<script setup>
import { computed, inject } from "vue";
import { Handle, Position } from "@vue-flow/core";
import { NodeToolbar } from "@vue-flow/node-toolbar";
const props = defineProps({
id: {
type: String,
required: true,
},
data: {
type: Object,
default: () => ({}),
},
selected: {
type: Boolean,
default: false,
},
connectable: {
type: [Boolean, Function, Object, String],
default: true,
},
readonly: {
type: Boolean,
default: false,
},
});
const canvasActions = inject("workflowCanvasActions", null);
const nodeType = computed(() => String(props.data?.type || "http").toLowerCase());
const isStart = computed(() => nodeType.value === "start");
const isEnd = computed(() => nodeType.value === "end");
const isCondition = computed(() => nodeType.value === "condition");
const isLoop = computed(() => nodeType.value === "loop");
const isLocked = computed(() => Boolean(props.data?._isLocked));
const badges = computed(() => (Array.isArray(props.data?._badges) ? props.data._badges : []));
const runtimeState = computed(() => props.data?._runtimeState || null);
const runtimeStatus = computed(() => String(runtimeState.value?.status || ""));
const runtimeLabel = computed(() => {
const map = {
running: "正在执行",
success: "已完成",
failed: "失败",
skipped: "跳过",
};
return map[runtimeStatus.value] || "";
});
function runAction(action, ...args) {
action?.(...args);
}
</script>
<template>
<NodeToolbar
v-if="selected && !readonly"
class="workflow-node-toolbar"
:is-visible="selected"
:position="Position.Top"
:offset="20"
>
<div class="workflow-node-toolbar__inner" @mousedown.stop>
<button
v-if="!isStart"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.insertPrevNode, 'http', id)"
>
前置 HTTP
</button>
<button
v-if="!isEnd"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', '', id)"
>
后接 HTTP
</button>
<button
v-if="!isEnd"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'condition', '', id)"
>
后接 条件
</button>
<button
v-if="isCondition"
type="button"
class="workflow-node-toolbar__btn workflow-node-toolbar__btn--if"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'if', id)"
>
IF
</button>
<button
v-if="isCondition"
type="button"
class="workflow-node-toolbar__btn workflow-node-toolbar__btn--else"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'else', id)"
>
ELSE
</button>
<button
v-if="isLoop"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'body', id)"
>
循环体
</button>
<button
v-if="isLoop"
type="button"
class="workflow-node-toolbar__btn"
@mousedown.stop
@click.stop="runAction(canvasActions?.appendNextNode, 'http', 'done', id)"
>
循环后
</button>
<button
type="button"
class="workflow-node-toolbar__btn workflow-node-toolbar__btn--danger"
:disabled="isLocked"
@mousedown.stop
@click.stop="runAction(canvasActions?.deleteNodeById, id)"
>
删除
</button>
</div>
</NodeToolbar>
<div
class="workflow-node-card"
:class="[
`workflow-node-card--${nodeType}`,
{
'is-selected': selected,
'is-current-api': data?._isCurrentApi,
[`is-runtime-${runtimeStatus}`]: runtimeStatus,
},
]"
>
<Handle
v-if="!isStart && !readonly"
id="in"
type="target"
:position="Position.Top"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--target"
/>
<div class="workflow-node-card__surface">
<div class="workflow-node-card__eyebrow">
<span
v-for="badge in badges"
:key="`${badge.kind}-${badge.text}`"
class="workflow-node-card__badge"
:class="`workflow-node-card__badge--${badge.kind}`"
>
{{ badge.text }}
</span>
</div>
<strong class="workflow-node-card__title">{{ data?.label || "节点" }}</strong>
<p class="workflow-node-card__summary">{{ data?._summary }}</p>
<span v-if="runtimeLabel" class="workflow-node-card__runtime">{{ runtimeLabel }}</span>
<div v-if="isCondition" class="workflow-node-card__branches">
<span class="workflow-node-card__branch workflow-node-card__branch--true">IF</span>
<span class="workflow-node-card__branch workflow-node-card__branch--false">ELSE</span>
</div>
<div v-if="isLoop" class="workflow-node-card__branches">
<span class="workflow-node-card__branch workflow-node-card__branch--body">BODY</span>
<span class="workflow-node-card__branch workflow-node-card__branch--done">DONE</span>
</div>
</div>
<template v-if="isCondition && !readonly">
<Handle
id="true"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--true"
/>
<Handle
id="false"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--false"
/>
</template>
<template v-else-if="isLoop && !readonly">
<Handle
id="body"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--body"
style="left: 34%"
/>
<Handle
id="done"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source workflow-node-card__handle--done"
style="left: 66%"
/>
</template>
<Handle
v-else-if="!isEnd && !readonly"
id="out"
type="source"
:position="Position.Bottom"
:connectable="connectable"
class="workflow-node-card__handle workflow-node-card__handle--source"
/>
</div>
</template>
<style scoped>
.workflow-node-card {
position: relative;
width: 248px;
min-height: 138px;
border-radius: 22px;
border: 1px solid #d9e5f3;
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.workflow-node-card.is-selected {
border-color: #73a6ff;
box-shadow:
0 0 0 5px rgba(107, 160, 255, 0.16),
0 20px 38px rgba(15, 23, 42, 0.12);
}
.workflow-node-card.is-current-api {
border-color: #16a34a;
box-shadow:
0 0 0 4px rgba(34, 197, 94, 0.12),
0 18px 36px rgba(15, 23, 42, 0.1);
}
.workflow-node-card.is-runtime-running {
border-color: #409eff;
box-shadow:
0 0 0 4px rgba(64, 158, 255, 0.14),
0 18px 36px rgba(15, 23, 42, 0.1);
}
.workflow-node-card.is-runtime-success {
border-color: #67c23a;
}
.workflow-node-card.is-runtime-failed {
border-color: #f56c6c;
}
.workflow-node-card.is-runtime-skipped {
border-style: dashed;
opacity: 0.78;
}
.workflow-node-card--start,
.workflow-node-card--end {
border-radius: 999px;
}
.workflow-node-card--start {
background: linear-gradient(180deg, #f4fff8 0%, #ffffff 100%);
border-color: #9ddab2;
}
.workflow-node-card--end {
background: linear-gradient(180deg, #fff5f5 0%, #ffffff 100%);
border-color: #f0b3b3;
}
.workflow-node-card--condition {
background: linear-gradient(180deg, #fffaf0 0%, #ffffff 100%);
border-color: #efcf82;
}
.workflow-node-card--extract {
background: linear-gradient(180deg, #f8f6ff 0%, #ffffff 100%);
border-color: #cfc0ff;
}
.workflow-node-card__surface {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 18px 20px;
min-height: 138px;
}
.workflow-node-card__eyebrow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.workflow-node-card__badge {
display: inline-flex;
align-items: center;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
line-height: 1;
color: #315b96;
background: #ebf3ff;
}
.workflow-node-card__badge--start {
color: #0f766e;
background: #defcf5;
}
.workflow-node-card__badge--end {
color: #b42318;
background: #fee4e2;
}
.workflow-node-card__badge--condition {
color: #8a6116;
background: #fff2cf;
}
.workflow-node-card__badge--extract {
color: #6d28d9;
background: #efe7ff;
}
.workflow-node-card__badge--current {
color: #166534;
background: #dcfce7;
}
.workflow-node-card__badge--link {
color: #0f6fa0;
background: #e0f2fe;
}
.workflow-node-card__badge--stage {
color: #0f766e;
background: #dffaf6;
}
.workflow-node-card__badge--var {
color: #7c3aed;
background: #f1e8ff;
}
.workflow-node-card__title {
color: #1f2d3d;
font-size: 15px;
line-height: 1.45;
word-break: break-word;
}
.workflow-node-card__summary {
margin: 0;
color: #6f8199;
font-size: 12px;
line-height: 1.7;
word-break: break-word;
}
.workflow-node-card__runtime {
display: inline-flex;
align-self: flex-start;
margin-top: -2px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1;
color: #1d4ed8;
background: #eaf2ff;
}
.workflow-node-card__branches {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.workflow-node-card__branch {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.workflow-node-card__branch--true {
color: #166534;
background: #dcfce7;
}
.workflow-node-card__branch--false {
color: #b42318;
background: #fee4e2;
}
.workflow-node-card__branch--body {
color: #1d4ed8;
background: #dbeafe;
}
.workflow-node-card__branch--done {
color: #6d28d9;
background: #ede9fe;
}
.workflow-node-card--loop {
border-color: #c4d7ff;
}
:deep(.workflow-node-card__handle) {
width: 14px;
height: 14px;
border: 2px solid #fff;
background: #6888b5;
box-shadow: 0 0 0 2px rgba(104, 136, 181, 0.18);
}
:deep(.workflow-node-card__handle--target) {
top: -8px;
}
:deep(.workflow-node-card__handle--source) {
bottom: -8px;
}
:deep(.workflow-node-card__handle--true) {
left: 30% !important;
background: #16a34a;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.18);
}
:deep(.workflow-node-card__handle--false) {
left: 70% !important;
background: #dc2626;
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.18);
}
:deep(.workflow-node-toolbar) {
pointer-events: all;
}
.workflow-node-toolbar__inner {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
max-width: 360px;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(31, 41, 55, 0.08);
background: rgba(15, 23, 42, 0.94);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.22);
}
.workflow-node-toolbar__btn {
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
padding: 7px 10px;
border-radius: 10px;
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.workflow-node-toolbar__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.workflow-node-toolbar__btn--if {
background: rgba(34, 197, 94, 0.22);
}
.workflow-node-toolbar__btn--else {
background: rgba(239, 68, 68, 0.22);
}
.workflow-node-toolbar__btn--danger {
background: rgba(248, 113, 113, 0.24);
}
</style>
File diff suppressed because it is too large Load Diff