初始化仓库:AI 接口自动化测试平台
纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
Reference in New Issue
Block a user