初始化仓库: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
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>质量检测平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1974
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "ai-auto-test-admin",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lint": "^6.9.6",
"@element-plus/icons-vue": "^2.3.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vue-flow/node-toolbar": "^1.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"element-plus": "^2.11.5",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.3.5"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+154
View File
@@ -0,0 +1,154 @@
import { clearSession, getAccessToken } from "../auth/session";
const explicitBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim();
export function buildUrl(path) {
if (/^https?:\/\//.test(path)) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (explicitBaseUrl) {
return `${explicitBaseUrl}${normalizedPath}`;
}
return normalizedPath;
}
export function buildWebSocketUrl(path, params = {}) {
const httpUrl = buildUrl(path);
const url = new URL(httpUrl, window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
export async function apiGet(path, init = {}) {
return request(path, {
method: "GET",
headers: {
Accept: "application/json",
...(init.headers || {}),
},
...init,
});
}
export async function apiPost(path, body, init = {}) {
return request(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(init.headers || {}),
},
body: JSON.stringify(body),
...init,
});
}
export async function apiPut(path, body, init = {}) {
return request(path, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(init.headers || {}),
},
body: JSON.stringify(body),
...init,
});
}
export async function apiDelete(path, init = {}) {
return request(path, {
method: "DELETE",
headers: {
Accept: "application/json",
...(init.headers || {}),
},
...init,
});
}
export async function apiUploadForm(path, formData, init = {}) {
const token = getAccessToken();
const headers = { ...(init.headers || {}) };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(buildUrl(path), {
method: "POST",
headers,
body: formData,
...init,
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
try {
const rawText = await response.text();
if (rawText) {
try {
const payload = JSON.parse(rawText);
errorMessage = payload.detail || JSON.stringify(payload);
} catch {
errorMessage = rawText;
}
}
} catch {
errorMessage = `Request failed: ${response.status}`;
}
if (response.status === 401) {
clearSession();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new Error(errorMessage);
}
return response.json();
}
async function request(path, init = {}) {
const token = getAccessToken();
const headers = { ...(init.headers || {}) };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(buildUrl(path), {
...init,
headers,
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
try {
const rawText = await response.text();
if (rawText) {
try {
const payload = JSON.parse(rawText);
errorMessage = payload.detail || JSON.stringify(payload);
} catch {
errorMessage = rawText;
}
}
} catch {
errorMessage = `Request failed: ${response.status}`;
}
if (response.status === 401) {
clearSession();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
throw new Error(errorMessage);
}
return response.json();
}
+42
View File
@@ -0,0 +1,42 @@
import { reactive } from "vue";
const TOKEN_KEY = "ai-auto-test-token";
const USER_KEY = "ai-auto-test-user";
function loadUser() {
const raw = window.localStorage.getItem(USER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export const authState = reactive({
token: window.localStorage.getItem(TOKEN_KEY) || "",
user: loadUser(),
});
export function getAccessToken() {
return authState.token || "";
}
export function setSession(token, user) {
authState.token = token || "";
authState.user = user || null;
if (token) {
window.localStorage.setItem(TOKEN_KEY, token);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (user) {
window.localStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
window.localStorage.removeItem(USER_KEY);
}
}
export function clearSession() {
setSession("", null);
}
@@ -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
+201
View File
@@ -0,0 +1,201 @@
<script setup>
import {
ArrowDown,
Connection,
DataBoard,
Key,
Link,
Lock,
Monitor,
Operation,
Setting,
SwitchButton,
User,
VideoPlay,
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import ProfileApiKeyPanel from "../components/ProfileApiKeyPanel.vue";
import SshFloatingTerminalLayer from "../components/SshFloatingTerminalLayer.vue";
import { authState, clearSession } from "../auth/session";
const route = useRoute();
const router = useRouter();
const collapsed = ref(false);
const profileDrawerVisible = ref(false);
const menuItems = computed(() => [
{ index: "/", label: "工作台", icon: DataBoard },
{ index: "/workflow-batches", label: "跑批工作流执行", icon: VideoPlay },
{ index: "/apis", label: "接口管理", icon: Connection },
{ index: "/ssh", label: "SSH 管理", icon: Key },
...(authState.user?.role === "superadmin" ? [{ index: "/users", label: "用户管理", icon: User }] : []),
{ index: "/mcp-config", label: "MCP 配置", icon: Setting },
]);
const activeMenu = computed(() => {
const path = route.path || "/";
if (path.startsWith("/workflow-batches") || path.startsWith("/workflow-runtime")) return "/workflow-batches";
if (path.startsWith("/apis")) return "/apis";
if (path.startsWith("/ssh")) return "/ssh";
if (path.startsWith("/mcp-config")) return "/mcp-config";
if (path.startsWith("/users")) return "/users";
return "/";
});
const displayName = computed(() => authState.user?.display_name || authState.user?.username || "未登录");
const roleLabel = computed(() => (authState.user?.role === "superadmin" ? "超管" : "普通用户"));
const accountLabel = computed(() => authState.user?.username || "--");
const avatarText = computed(() => {
const source = displayName.value || accountLabel.value || "U";
return String(source).trim().slice(0, 1).toUpperCase();
});
function handleUserCommand(command) {
if (command === "logout") {
logout();
return;
}
if (command === "profile") {
profileDrawerVisible.value = true;
return;
}
const commandText = {
password: "修改密码",
bind: "账号绑定",
}[command];
ElMessage.info(`${commandText} 暂未开放`);
}
function logout() {
clearSession();
router.replace("/login");
}
</script>
<template>
<div class="admin-shell">
<aside class="admin-sidebar" :class="{ collapsed }">
<div class="admin-brand">
<div class="admin-brand__logo">质检</div>
<div v-if="!collapsed" class="admin-brand__meta">
<strong>质量检测平台</strong>
</div>
</div>
<el-scrollbar class="admin-sidebar__scroll">
<el-menu
:default-active="activeMenu"
:collapse="collapsed"
:collapse-transition="false"
class="admin-menu"
router
>
<el-menu-item
v-for="item in menuItems"
:key="item.index"
:index="item.index"
:disabled="item.disabled"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
</el-menu-item>
</el-menu>
</el-scrollbar>
</aside>
<div class="admin-main">
<header class="admin-header">
<div class="admin-header__left">
<el-button text @click="collapsed = !collapsed">
<el-icon><Operation /></el-icon>
</el-button>
<div>
<h1>质量检测平台</h1>
</div>
</div>
<div class="admin-header__right">
<el-dropdown
trigger="click"
placement="bottom-end"
popper-class="user-dropdown-popper"
@command="handleUserCommand"
>
<button class="admin-user-trigger" type="button">
<div class="admin-user-trigger__meta">
<strong>{{ displayName }}</strong>
<p>{{ roleLabel }}</p>
</div>
<el-avatar :size="40" class="admin-user-trigger__avatar">
{{ avatarText }}
</el-avatar>
<el-icon class="admin-user-trigger__arrow"><ArrowDown /></el-icon>
</button>
<template #dropdown>
<div class="user-dropdown-card">
<div class="user-dropdown-card__summary">
<div class="user-dropdown-card__avatar">
<el-icon :size="18"><Monitor /></el-icon>
</div>
<div>
<strong>{{ displayName }}</strong>
<p>{{ roleLabel }}</p>
<span>账号 {{ accountLabel }}</span>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item command="password">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-dropdown-item>
<el-dropdown-item command="bind">
<el-icon><Link /></el-icon>
<span>账号绑定</span>
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
</div>
</header>
<main class="admin-content">
<router-view />
</main>
</div>
<el-drawer v-model="profileDrawerVisible" title="个人中心" size="760px" destroy-on-close>
<div class="profile-drawer">
<section class="profile-drawer__summary panel-card">
<div class="profile-drawer__avatar">{{ avatarText }}</div>
<div>
<strong>{{ displayName }}</strong>
<p>{{ roleLabel }} · 账号 {{ accountLabel }}</p>
</div>
</section>
<section class="profile-drawer__section">
<div class="panel-card__header">
<div>
<strong>API Key</strong>
<p>用于 MCP Bridge 与自动化调用平台接口</p>
</div>
</div>
<ProfileApiKeyPanel />
</section>
</div>
</el-drawer>
<SshFloatingTerminalLayer />
</div>
</template>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from "vue";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
import router from "./router";
import "./styles/index.css";
createApp(App).use(router).use(ElementPlus).mount("#app");
+114
View File
@@ -0,0 +1,114 @@
import { createRouter, createWebHistory } from "vue-router";
import { authState, getAccessToken } from "../auth/session";
import AdminLayout from "../layout/AdminLayout.vue";
import ApiEditorView from "../views/ApiEditorView.vue";
import ApiManagementView from "../views/ApiManagementView.vue";
import DashboardView from "../views/DashboardView.vue";
import LoginView from "../views/LoginView.vue";
import McpManagementView from "../views/McpManagementView.vue";
import SshManagementView from "../views/SshManagementView.vue";
import UserManagementView from "../views/UserManagementView.vue";
import WorkflowBatchView from "../views/WorkflowBatchView.vue";
const routes = [
{
path: "/login",
name: "login",
component: LoginView,
meta: {
public: true,
title: "登录",
},
},
{
path: "/",
component: AdminLayout,
children: [
{
path: "",
name: "dashboard",
component: DashboardView,
meta: {
title: "工作台",
},
},
{
path: "apis",
name: "apis",
component: ApiManagementView,
meta: {
title: "接口管理",
},
},
{
path: "ssh",
name: "ssh",
component: SshManagementView,
meta: {
title: "SSH 管理",
},
},
{
path: "workflow-batches",
name: "workflow-batches",
component: WorkflowBatchView,
meta: {
title: "跑批工作流执行",
},
},
{
path: "workflow-runtime",
redirect: "/workflow-batches",
},
{
path: "mcp-config",
name: "mcp",
component: McpManagementView,
meta: {
title: "MCP 配置",
},
},
{
path: "apis/:apiId/edit",
name: "api-edit",
component: ApiEditorView,
meta: {
title: "接口编辑",
},
},
{
path: "users",
name: "users",
component: UserManagementView,
meta: {
title: "用户管理",
requiresSuperadmin: true,
},
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const token = getAccessToken();
if (to.meta.public) {
if (token && to.path === "/login") {
return "/";
}
return true;
}
if (!token) {
return "/login";
}
if (to.meta.requiresSuperadmin && authState.user?.role !== "superadmin") {
return "/";
}
return true;
});
export default router;
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
export function normalizeFolderPath(value) {
const text = String(value || "")
.trim()
.replace(/\\/g, "/");
if (!text || text === "/") return "";
return text
.split("/")
.map((part) => part.trim())
.filter(Boolean)
.join("/");
}
export function collectFolderPaths(rows, extraFolderPaths = []) {
const paths = new Set();
rows.forEach((row) => {
const path = normalizeFolderPath(row.folder_path);
if (path) paths.add(path);
});
extraFolderPaths.forEach((path) => {
const normalized = normalizeFolderPath(path);
if (normalized) paths.add(normalized);
});
return [...paths];
}
export function buildFolderTreeRows(rows, extraFolderPaths = []) {
const folderMap = new Map();
const rootNodes = [];
function ensureFolderNode(path) {
if (!path) return null;
if (folderMap.has(path)) return folderMap.get(path);
const parts = path.split("/").filter(Boolean);
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join("/");
const node = {
rowKey: `folder:${path}`,
nodeType: "folder",
name,
folder_path: path,
creator_name: "",
method: "",
url: "",
timeout_seconds: "",
children: [],
isEmptyFolder: true,
};
folderMap.set(path, node);
const parent = parentPath ? ensureFolderNode(parentPath) : null;
if (parent) {
parent.children.push(node);
} else {
rootNodes.push(node);
}
return node;
}
collectFolderPaths(rows, extraFolderPaths).forEach((path) => ensureFolderNode(path));
rows.forEach((row) => {
const folderNode = ensureFolderNode(normalizeFolderPath(row.folder_path || ""));
const apiNode = {
...row,
rowKey: `api:${row.id}`,
nodeType: "api",
children: [],
isEmptyFolder: false,
};
if (folderNode) {
folderNode.isEmptyFolder = false;
folderNode.children.push(apiNode);
} else {
rootNodes.push(apiNode);
}
});
function sortNodes(nodes) {
nodes.sort((left, right) => {
if (left.nodeType !== right.nodeType) {
return left.nodeType === "folder" ? -1 : 1;
}
return String(left.name || "").localeCompare(String(right.name || ""), "zh-CN");
});
nodes.forEach((node) => {
if (node.children?.length) {
sortNodes(node.children);
}
});
}
sortNodes(rootNodes);
return rootNodes;
}
+29
View File
@@ -0,0 +1,29 @@
export function normalizeKeyValueRows(source) {
if (!source || typeof source !== "object" || Array.isArray(source)) {
return [{ key: "", value: "" }];
}
const rows = Object.entries(source).map(([key, value]) => ({
key: String(key),
value: typeof value === "string" ? value : JSON.stringify(value ?? ""),
}));
return rows.length ? rows : [{ key: "", value: "" }];
}
export function collectKeyValueObject(rows, duplicateLabel = "Key") {
const output = {};
const seen = new Set();
rows.forEach((row) => {
const key = String(row.key || "").trim();
if (!key) return;
if (seen.has(key)) {
throw new Error(`${duplicateLabel} 存在重复 Key${key}`);
}
seen.add(key);
output[key] = String(row.value ?? "");
});
return output;
}
@@ -0,0 +1,47 @@
const STORAGE_KEY = "qip_ssh_pinned_scripts";
export function loadPinnedScripts() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function savePinnedScripts(items) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
export function isScriptPinned(scriptId) {
return loadPinnedScripts().some((item) => Number(item.scriptId) === Number(scriptId));
}
export function togglePinScript(entry) {
const scriptId = Number(entry.scriptId);
const profileId = Number(entry.profileId);
const items = loadPinnedScripts();
const index = items.findIndex((item) => Number(item.scriptId) === scriptId);
if (index >= 0) {
items.splice(index, 1);
savePinnedScripts(items);
return false;
}
items.unshift({
scriptId,
profileId,
scriptName: entry.scriptName || "",
profileName: entry.profileName || "",
profileHost: entry.profileHost || "",
description: entry.description || "",
pinnedAt: Date.now(),
});
savePinnedScripts(items);
return true;
}
export function removePinnedScript(scriptId) {
const items = loadPinnedScripts().filter((item) => Number(item.scriptId) !== Number(scriptId));
savePinnedScripts(items);
}
+428
View File
@@ -0,0 +1,428 @@
import { ElMessage, ElMessageBox } from "element-plus";
import { nextTick, ref } from "vue";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { buildWebSocketUrl } from "../api/http";
import { getAccessToken } from "../auth/session";
import "@xterm/xterm/css/xterm.css";
let nextRunSeq = 1;
let nextZIndex = 3200;
export const sessions = ref([]);
export const tabSessionIds = ref([]);
export const activeTabSessionId = ref("");
export const floatingWindows = ref([]);
export const FLOAT_WINDOW_DEFAULT_SIZE = { width: 760, height: 420 };
export const FLOAT_WINDOW_MIN_SIZE = { width: 360, height: 200 };
const terminalContainers = new Map();
function createTerminalTheme() {
return {
background: "#0f172a",
foreground: "#e2e8f0",
cursor: "#64748b",
black: "#0f172a",
red: "#f87171",
green: "#4ade80",
yellow: "#facc15",
blue: "#60a5fa",
magenta: "#c084fc",
cyan: "#22d3ee",
white: "#e2e8f0",
brightBlack: "#334155",
brightRed: "#fca5a5",
brightGreen: "#86efac",
brightYellow: "#fde047",
brightBlue: "#93c5fd",
brightMagenta: "#d8b4fe",
brightCyan: "#67e8f9",
brightWhite: "#f8fafc",
};
}
export function getSession(sessionId) {
return sessions.value.find((item) => item.id === sessionId) || null;
}
export function registerTerminalContainer(sessionId, element) {
if (element) {
terminalContainers.set(sessionId, element);
const session = getSession(sessionId);
if (session && !session.terminal) {
nextTick(() => initSessionTerminal(session));
}
return;
}
terminalContainers.delete(sessionId);
}
export function initSessionTerminal(session) {
const container = terminalContainers.get(session.id);
if (!container || session.terminal) return;
const terminal = new Terminal({
disableStdin: true,
cursorBlink: false,
convertEol: true,
scrollback: 8000,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
fontSize: 13,
lineHeight: 1.3,
theme: createTerminalTheme(),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
fitAddon.fit();
terminal.writeln("\x1b[36mSSH 脚本执行日志\x1b[0m\r\n");
session.terminal = terminal;
session.fitAddon = fitAddon;
fitSessionTerminal(session.id);
}
export function disposeSessionTerminal(session) {
if (!session) return;
session.terminal?.dispose();
session.terminal = null;
session.fitAddon = null;
terminalContainers.delete(session.id);
}
export function fitSessionTerminal(sessionId) {
const session = getSession(sessionId);
if (!session?.terminal || !session.fitAddon) return;
session.fitAddon.fit();
session.terminal.scrollToBottom();
}
function scrollSessionTerminal(session) {
session.terminal?.scrollToBottom();
}
function writeSessionLine(session, text, color = "") {
if (!session.terminal) return;
if (color) {
session.terminal.writeln(`${color}${text}\x1b[0m`);
} else {
session.terminal.writeln(text);
}
scrollSessionTerminal(session);
}
function appendSessionOutput(session, text, stream = "stdout") {
if (!session.terminal || !text) return;
if (stream === "stderr") {
session.terminal.write(`\x1b[31m${text}\x1b[0m`);
} else {
session.terminal.write(text);
}
scrollSessionTerminal(session);
}
function finishSession(session, status, statusText, tailLine = "") {
session.status = status;
session.statusText = statusText;
if (tailLine) {
writeSessionLine(session, tailLine, status === "success" ? "\x1b[32m" : "\x1b[33m");
}
}
function closeSessionSocket(session) {
if (session.socket && session.socket.readyState === WebSocket.OPEN) {
session.socket.close();
}
session.socket = null;
}
export function stopSession(session, options = {}) {
const { silent = false } = options;
if (!session) return;
if (session.status === "running" || session.status === "connecting") {
if (session.socket?.readyState === WebSocket.OPEN) {
session.socket.send(JSON.stringify({ type: "stop" }));
}
closeSessionSocket(session);
finishSession(session, "stopped", "已手动停止", ">>> 用户手动停止执行");
if (!silent) {
ElMessage.success(`已停止:${session.scriptName}`);
}
}
}
function buildProfileHost(profile) {
return `${profile.username}@${profile.host}:${profile.port || 22}`;
}
function createSession(script, profile) {
return {
id: `run-${Date.now()}-${nextRunSeq++}`,
scriptId: script.id,
scriptName: script.name,
profileId: profile.id,
profileName: profile.name,
profileHost: buildProfileHost(profile),
status: "connecting",
statusText: "正在连接并执行脚本...",
socket: null,
terminal: null,
fitAddon: null,
};
}
export async function executeScript(script, profile, password, target = "tab") {
const session = createSession(script, profile);
sessions.value.push(session);
if (target === "float") {
const offset = floatingWindows.value.length * 28;
const maxWidth =
typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 32) : 760;
floatingWindows.value.push({
sessionId: session.id,
minimized: false,
position: { x: 72 + offset, y: 72 + offset },
size: {
width: Math.min(FLOAT_WINDOW_DEFAULT_SIZE.width, maxWidth),
height: FLOAT_WINDOW_DEFAULT_SIZE.height,
},
zIndex: ++nextZIndex,
});
} else {
tabSessionIds.value.push(session.id);
activeTabSessionId.value = session.id;
}
await nextTick();
initSessionTerminal(session);
if (!session.terminal) {
await nextTick();
initSessionTerminal(session);
}
writeSessionLine(
session,
`>>> 执行脚本: ${script.name} @ ${buildProfileHost(profile)}`,
"\x1b[36m"
);
const token = getAccessToken();
const socket = new WebSocket(buildWebSocketUrl("/ws/ssh-script-run", { token }));
session.socket = socket;
socket.onopen = () => {
socket.send(
JSON.stringify({
type: "start",
profile_id: profile.id,
script_id: script.id,
password,
})
);
};
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "started") {
session.status = "running";
session.statusText = "脚本执行中,日志实时输出...";
return;
}
if (payload.type === "meta") {
writeSessionLine(session, `$ ${payload.command || ""}`, "\x1b[90m");
return;
}
if (payload.type === "stdout" || payload.type === "stderr") {
appendSessionOutput(session, payload.data || "", payload.type);
return;
}
if (payload.type === "done") {
const exitCode = payload.exit_status ?? 0;
const ok = Boolean(payload.ok);
finishSession(
session,
ok ? "success" : "failed",
ok ? `执行成功,exit code ${exitCode}` : `执行失败,exit code ${exitCode}`,
`>>> 执行结束,exit code ${exitCode}`
);
closeSessionSocket(session);
return;
}
if (payload.type === "stopped") {
finishSession(session, "stopped", "已手动停止", ">>> 用户手动停止执行");
closeSessionSocket(session);
return;
}
if (payload.type === "error") {
finishSession(session, "failed", payload.detail || "执行失败", `\x1b[31m>>> ${payload.detail || "执行失败"}`);
closeSessionSocket(session);
}
} catch {
appendSessionOutput(session, String(event.data || ""));
}
};
socket.onerror = () => {
finishSession(session, "failed", "脚本执行连接异常", "\x1b[31m>>> WebSocket connection error");
closeSessionSocket(session);
};
socket.onclose = () => {
if (session.status === "running" || session.status === "connecting") {
finishSession(session, "stopped", "脚本执行连接已关闭");
}
session.socket = null;
};
return session;
}
export async function promptExecutePassword(scriptName) {
try {
const result = await ElMessageBox.prompt("请输入执行密码,仅用于本次执行,不会保存。", `执行脚本:${scriptName}`, {
confirmButtonText: "执行",
cancelButtonText: "取消",
inputType: "password",
inputPlaceholder: "执行密码(必填)",
inputValidator: (value) => {
if (!value || !String(value).trim()) {
return "执行前必须输入密码";
}
return true;
},
});
return String(result.value || "").trim();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "已取消执行");
}
return null;
}
}
export async function promptAndExecuteScript({ script, profile, target = "tab" }) {
const password = await promptExecutePassword(script.name);
if (!password) return null;
return executeScript(script, profile, password, target);
}
export function parseProfileHost(profileHost) {
const raw = String(profileHost || "").trim();
const withPort = raw.match(/^([^@]+)@([^:]+):(\d+)$/);
if (withPort) {
return {
username: withPort[1],
host: withPort[2],
port: Number(withPort[3]),
};
}
const withoutPort = raw.match(/^([^@]+)@(.+)$/);
if (withoutPort) {
return {
username: withoutPort[1],
host: withoutPort[2],
port: 22,
};
}
return { username: "", host: "", port: 22 };
}
export async function promptAndExecutePinnedScript(item) {
const parsed = parseProfileHost(item.profileHost);
const script = { id: Number(item.scriptId), name: item.scriptName || "脚本" };
const profile = {
id: Number(item.profileId),
name: item.profileName || "主机",
username: parsed.username,
host: parsed.host,
port: parsed.port,
};
return promptAndExecuteScript({ script, profile, target: "float" });
}
export function removeTabSession(tabId) {
const session = getSession(tabId);
if (!session) return;
stopSession(session, { silent: true });
disposeSessionTerminal(session);
sessions.value = sessions.value.filter((item) => item.id !== tabId);
tabSessionIds.value = tabSessionIds.value.filter((id) => id !== tabId);
if (activeTabSessionId.value === tabId) {
activeTabSessionId.value = tabSessionIds.value[tabSessionIds.value.length - 1] || "";
}
nextTick(() => fitSessionTerminal(activeTabSessionId.value));
}
export function closeFloatingWindow(sessionId) {
const session = getSession(sessionId);
if (session) {
stopSession(session, { silent: true });
disposeSessionTerminal(session);
sessions.value = sessions.value.filter((item) => item.id !== sessionId);
}
floatingWindows.value = floatingWindows.value.filter((item) => item.sessionId !== sessionId);
tabSessionIds.value = tabSessionIds.value.filter((id) => id !== sessionId);
}
export function toggleFloatingMinimize(sessionId) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.minimized = !target.minimized;
if (!target.minimized) {
nextTick(() => fitSessionTerminal(sessionId));
}
}
export function bringFloatingToFront(sessionId) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.zIndex = ++nextZIndex;
}
export function updateFloatingPosition(sessionId, position) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.position = position;
}
export function updateFloatingSize(sessionId, size) {
const target = floatingWindows.value.find((item) => item.sessionId === sessionId);
if (!target) return;
const maxWidth =
typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 16) : 2000;
const maxHeight =
typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.height, window.innerHeight - 16) : 1200;
target.size = {
width: Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.width, size.width), maxWidth),
height: Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.height, size.height), maxHeight),
};
}
export function cleanupTabSessions() {
const ids = [...tabSessionIds.value];
ids.forEach((id) => {
const session = getSession(id);
if (session) {
stopSession(session, { silent: true });
disposeSessionTerminal(session);
}
});
sessions.value = sessions.value.filter((item) => !ids.includes(item.id));
tabSessionIds.value = [];
activeTabSessionId.value = "";
}
export function cleanupAllSessions() {
sessions.value.forEach((session) => {
stopSession(session, { silent: true });
disposeSessionTerminal(session);
});
sessions.value = [];
tabSessionIds.value = [];
activeTabSessionId.value = "";
floatingWindows.value = [];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
<script setup>
import { Delete, Edit, FolderAdd, FolderOpened, Link, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { apiDelete, apiGet, apiPost } from "../api/http";
import FolderCreateDialog from "../components/FolderCreateDialog.vue";
import RequestParamsTabs from "../components/RequestParamsTabs.vue";
import { buildFolderTreeRows, collectFolderPaths, normalizeFolderPath } from "../utils/folderTree";
import { collectKeyValueObject, normalizeKeyValueRows } from "../utils/keyValue";
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const searchKeyword = ref("");
const rawRows = ref([]);
const declaredFolderPaths = ref([]);
const folderDialogVisible = ref(false);
const folderDialogMode = ref("create");
const folderDialogParentPath = ref("");
const folderDialogEditPath = ref("");
const formState = reactive({
name: "",
folder_path: "",
method: "GET",
url: "",
timeout_seconds: 10,
body_text: "{}",
query_text: "{}",
});
const headerRows = ref([{ key: "", value: "" }]);
const pathParamRows = ref([{ key: "", value: "" }]);
const methodOptions = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const visibleRows = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return rawRows.value;
return rawRows.value.filter((row) =>
[row.name, row.folder_path, row.method, row.url, row.creator_name].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
});
const visibleFolderPaths = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
const paths = collectFolderPaths(visibleRows.value, declaredFolderPaths.value);
if (!keyword) return paths;
return paths.filter((path) => path.toLowerCase().includes(keyword));
});
const treeRows = computed(() => buildFolderTreeRows(visibleRows.value, visibleFolderPaths.value));
const folderCount = computed(() => visibleFolderPaths.value.length);
const folderOptions = computed(() => collectFolderPaths(rawRows.value, declaredFolderPaths.value));
function resetForm() {
formState.name = "";
formState.folder_path = "";
formState.method = "GET";
formState.url = "";
formState.timeout_seconds = 10;
formState.body_text = "{}";
formState.query_text = "{}";
headerRows.value = [{ key: "", value: "" }];
pathParamRows.value = [{ key: "", value: "" }];
}
function openCreateDrawer() {
resetForm();
drawerVisible.value = true;
}
function openCreateDrawerFromFolder(folderPath) {
resetForm();
formState.folder_path = folderPath || "";
drawerVisible.value = true;
}
function openCreateFolderDialog(parentPath = "") {
folderDialogMode.value = "create";
folderDialogEditPath.value = "";
folderDialogParentPath.value = normalizeFolderPath(parentPath);
folderDialogVisible.value = true;
}
function openEditFolderDialog(folderPath) {
folderDialogMode.value = "edit";
folderDialogEditPath.value = normalizeFolderPath(folderPath);
folderDialogParentPath.value = "";
folderDialogVisible.value = true;
}
async function handleFolderCreated() {
await loadApis();
}
function openEditPage(row) {
router.push({ name: "api-edit", params: { apiId: row.id } });
}
function parseJsonObject(text, label) {
try {
const parsed = JSON.parse(text || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
return parsed;
} catch (error) {
throw new Error(error instanceof Error ? error.message : `${label} 解析失败`);
}
}
function buildPayload() {
return {
name: formState.name.trim(),
folder_path: formState.folder_path.trim(),
method: formState.method,
url: formState.url.trim(),
timeout_seconds: Number(formState.timeout_seconds || 10),
headers: collectKeyValueObject(headerRows.value, "Headers"),
body: parseJsonObject(formState.body_text, "Body"),
query: parseJsonObject(formState.query_text, "Query"),
path_params: collectKeyValueObject(pathParamRows.value, "Path Params"),
};
}
async function loadApis() {
loading.value = true;
try {
const [apis, folders] = await Promise.all([
apiGet("/api/apis"),
apiGet("/api/folders?target=apis"),
]);
rawRows.value = apis;
declaredFolderPaths.value = folders.folders || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "接口列表加载失败");
} finally {
loading.value = false;
}
}
async function syncFromWorkflows() {
try {
await ElMessageBox.confirm(
"将从当前可见工作流的 HTTP 节点提取 method+URL,补全到接口库(已存在的不会重复创建)。是否继续?",
"从工作流同步接口",
{
type: "info",
confirmButtonText: "同步",
cancelButtonText: "取消",
}
);
} catch {
return;
}
loading.value = true;
try {
const result = await apiPost("/api/apis/sync-from-workflows", {});
ElMessage.success(`同步完成:新增 ${result.created || 0} 个,跳过 ${result.skipped || 0} 个,当前共 ${result.total || 0}`);
await loadApis();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "同步失败");
} finally {
loading.value = false;
}
}
async function submitForm() {
let payload;
try {
payload = buildPayload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "表单校验失败");
return;
}
if (!payload.name || !payload.url) {
ElMessage.warning("名称和 URL 不能为空");
return;
}
saving.value = true;
try {
await apiPost("/api/apis", payload);
ElMessage.success("接口已创建");
drawerVisible.value = false;
await loadApis();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
saving.value = false;
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确认删除接口「${row.name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/apis/${row.id}`);
ElMessage.success("接口已删除");
await loadApis();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
onMounted(() => {
loadApis();
});
</script>
<template>
<div class="page-stack">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>接口管理</strong>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadApis">刷新</el-button>
<el-button :icon="FolderAdd" @click="openCreateFolderDialog()">新建目录</el-button>
<el-button @click="syncFromWorkflows">从工作流同步</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新建接口</el-button>
</div>
</div>
</template>
<div class="api-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索名称、目录、方法、URL、创建人"
clearable
class="api-toolbar__search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="toolbar-actions">
<el-tag type="info" effect="plain">目录 {{ folderCount }}</el-tag>
<el-tag type="success" effect="plain">接口 {{ visibleRows.length }}</el-tag>
</div>
</div>
<el-table
:data="treeRows"
row-key="rowKey"
default-expand-all
v-loading="loading"
class="api-table"
:tree-props="{ children: 'children' }"
:row-class-name="({ row }) => (row.nodeType === 'folder' ? 'table-row-folder' : 'table-row-api')"
>
<el-table-column label="目录 / 接口" min-width="360">
<template #default="{ row }">
<div v-if="row.nodeType === 'folder'" class="tree-title tree-title--folder">
<el-icon><FolderOpened /></el-icon>
<strong>{{ row.name || "/" }}</strong>
<el-tag v-if="row.isEmptyFolder" size="small" type="info" effect="plain">空目录</el-tag>
</div>
<div v-else class="tree-title">
<span class="tree-title__api-icon">
<el-icon><Link /></el-icon>
</span>
<strong>{{ row.name }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="Method" width="110">
<template #default="{ row }">
<template v-if="row.nodeType === 'api'">
<el-tag :type="row.method === 'GET' ? 'success' : 'primary'" effect="light">{{ row.method }}</el-tag>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="URL" min-width="300">
<template #default="{ row }">
<span class="table-url">{{ row.nodeType === "api" ? row.url : row.folder_path || "/" }}</span>
</template>
</el-table-column>
<el-table-column label="超时(s)" width="110">
<template #default="{ row }">
<span>{{ row.nodeType === "api" ? row.timeout_seconds : "-" }}</span>
</template>
</el-table-column>
<el-table-column label="创建人" min-width="140">
<template #default="{ row }">
<span>{{ row.nodeType === "folder" ? "-" : row.creator_name }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="360" fixed="right">
<template #default="{ row }">
<div v-if="row.nodeType === 'folder'" class="row-actions">
<el-button text :icon="Setting" @click="openEditFolderDialog(row.folder_path)">编辑</el-button>
<el-button text :icon="FolderAdd" @click="openCreateFolderDialog(row.folder_path)">子目录</el-button>
<el-button text :icon="Plus" @click="openCreateDrawerFromFolder(row.folder_path)">新建接口</el-button>
</div>
<div v-else class="row-actions">
<el-button text :icon="Edit" @click="openEditPage(row)">编辑</el-button>
<el-button text type="danger" :icon="Delete" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="drawerVisible"
title="新建接口"
size="760px"
destroy-on-close
>
<div class="api-form">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="接口名称">
<el-input v-model="formState.name" placeholder="登录接口" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目录">
<el-input v-model="formState.folder_path" placeholder="auth/login" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="Method">
<el-select v-model="formState.method" class="full-width">
<el-option v-for="item in methodOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="URL">
<el-input v-model="formState.url" placeholder="/api/example" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="超时">
<el-input-number v-model="formState.timeout_seconds" :min="1" :step="1" class="full-width" />
</el-form-item>
<el-divider content-position="left">请求参数</el-divider>
<RequestParamsTabs
v-model:header-rows="headerRows"
v-model:body-text="formState.body_text"
v-model:query-text="formState.query_text"
v-model:path-param-rows="pathParamRows"
/>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</div>
</template>
</el-drawer>
<FolderCreateDialog
v-model="folderDialogVisible"
target="apis"
:mode="folderDialogMode"
:edit-path="folderDialogEditPath"
:parent-path="folderDialogParentPath"
:folder-options="folderOptions"
@success="handleFolderCreated"
/>
</div>
</template>
+248
View File
@@ -0,0 +1,248 @@
<script setup>
import { Connection, FolderAdd, Key, MagicStick, Refresh, Setting, StarFilled, VideoPlay } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { apiGet } from "../api/http";
import FolderCreateDialog from "../components/FolderCreateDialog.vue";
import { loadPinnedScripts, removePinnedScript } from "../utils/sshPinnedScripts";
import { promptAndExecutePinnedScript } from "../utils/sshScriptRunner";
const stats = reactive({
apis: "-",
workflows: "-",
mocks: "-",
sshProfiles: "-",
});
const loading = ref(false);
const loadError = ref("");
const pinnedScripts = ref([]);
const workflowFolderLoading = ref(false);
const workflowFolderPaths = ref([]);
const workflowFolderDialogVisible = ref(false);
const workflowFolderDialogMode = ref("create");
const workflowFolderDialogParentPath = ref("");
const workflowFolderDialogEditPath = ref("");
const overviewCards = [
{
key: "apis",
title: "接口定义",
description: "当前接口定义总数。",
icon: Connection,
},
{
key: "workflows",
title: "工作流编排",
description: "当前工作流总数。",
icon: VideoPlay,
},
{
key: "mocks",
title: "Mock 数据",
description: "当前 Mock 数据集总数。",
icon: MagicStick,
},
{
key: "sshProfiles",
title: "SSH 主机",
description: "已登记的 SSH 主机配置总数。",
icon: Key,
},
];
function loadPinnedList() {
pinnedScripts.value = loadPinnedScripts();
}
async function loadDashboardStats() {
loading.value = true;
loadError.value = "";
try {
const [apis, workflows, mocks, sshProfiles] = await Promise.all([
apiGet("/api/apis"),
apiGet("/api/workflows"),
apiGet("/api/mocks"),
apiGet("/api/ssh-profiles"),
]);
stats.apis = String(apis.length);
stats.workflows = String(workflows.length);
stats.mocks = String(mocks.length);
stats.sshProfiles = String(sshProfiles.length);
} catch (error) {
loadError.value = error instanceof Error ? error.message : "加载统计失败";
} finally {
loading.value = false;
}
}
async function runPinnedScript(item) {
await promptAndExecutePinnedScript(item);
}
function unpinScript(item) {
removePinnedScript(item.scriptId);
loadPinnedList();
ElMessage.success("已取消首页置顶");
}
async function loadWorkflowFolders() {
workflowFolderLoading.value = true;
try {
const data = await apiGet("/api/folders?target=workflows");
workflowFolderPaths.value = data.folders || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "工作流目录加载失败");
} finally {
workflowFolderLoading.value = false;
}
}
function openWorkflowFolderDialog(parentPath = "") {
workflowFolderDialogMode.value = "create";
workflowFolderDialogEditPath.value = "";
workflowFolderDialogParentPath.value = parentPath;
workflowFolderDialogVisible.value = true;
}
function openEditWorkflowFolderDialog(folderPath) {
workflowFolderDialogMode.value = "edit";
workflowFolderDialogEditPath.value = folderPath;
workflowFolderDialogParentPath.value = "";
workflowFolderDialogVisible.value = true;
}
async function handleWorkflowFolderCreated() {
await loadWorkflowFolders();
}
onMounted(() => {
loadPinnedList();
loadDashboardStats();
loadWorkflowFolders();
});
</script>
<template>
<div class="dashboard-page">
<el-row :gutter="18">
<el-col v-for="card in overviewCards" :key="card.title" :xs="24" :sm="12" :xl="6">
<el-card shadow="hover" class="overview-card">
<div class="overview-card__icon">
<el-icon :size="20"><component :is="card.icon" /></el-icon>
</div>
<div class="overview-card__body">
<span>{{ card.title }}</span>
<strong>{{ stats[card.key] }}</strong>
<p>{{ card.description }}</p>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="panel-card dashboard-folder-card" shadow="never" v-loading="workflowFolderLoading">
<template #header>
<div class="panel-card__header">
<div>
<strong>工作流目录</strong>
<p>保存或移动工作流到非根目录前需先在此登记目录路径</p>
</div>
<div class="toolbar-actions">
<el-tag type="info" effect="plain">{{ workflowFolderPaths.length }} 个目录</el-tag>
<el-button :icon="Refresh" @click="loadWorkflowFolders">刷新</el-button>
<el-button type="primary" :icon="FolderAdd" @click="openWorkflowFolderDialog()">新建目录</el-button>
</div>
</div>
</template>
<div v-if="workflowFolderPaths.length" class="dashboard-folder-list">
<div v-for="path in workflowFolderPaths" :key="path" class="dashboard-folder-item">
<code>{{ path }}</code>
<div class="dashboard-folder-item__actions">
<el-button text :icon="Setting" @click="openEditWorkflowFolderDialog(path)">编辑</el-button>
<el-button text :icon="FolderAdd" @click="openWorkflowFolderDialog(path)">子目录</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂无工作流目录,点击「新建目录」创建" />
</el-card>
<FolderCreateDialog
v-model="workflowFolderDialogVisible"
target="workflows"
:mode="workflowFolderDialogMode"
:edit-path="workflowFolderDialogEditPath"
:parent-path="workflowFolderDialogParentPath"
:folder-options="workflowFolderPaths"
@success="handleWorkflowFolderCreated"
/>
<section class="dashboard-pinned-section">
<div class="dashboard-pinned-section__header">
<div>
<strong>置顶 SSH 脚本</strong>
<p> SSH 管理页脚本旁的星标置顶在此执行将弹出独立终端窗口可拖动可最小化</p>
</div>
<div class="toolbar-actions">
<el-tag class="dashboard-pinned-count" type="info" effect="plain">{{ pinnedScripts.length }} 个置顶</el-tag>
<el-button :icon="Refresh" @click="loadPinnedList">刷新</el-button>
</div>
</div>
<div v-if="pinnedScripts.length" class="dashboard-pinned-grid">
<div v-for="item in pinnedScripts" :key="item.scriptId" class="dashboard-pinned-item">
<div class="dashboard-pinned-item__head">
<div class="dashboard-pinned-item__title">
<span class="dashboard-pinned-item__icon">
<el-icon><Key /></el-icon>
</span>
<div>
<strong>{{ item.scriptName }}</strong>
<p>{{ item.profileName }}</p>
</div>
</div>
<el-icon class="dashboard-pinned-item__star"><StarFilled /></el-icon>
</div>
<div class="dashboard-pinned-item__host">
<span>目标主机</span>
<code>{{ item.profileHost }}</code>
</div>
<p class="dashboard-pinned-item__desc">{{ item.description || "无说明" }}</p>
<div class="dashboard-pinned-item__actions">
<el-button type="primary" :icon="VideoPlay" @click="runPinnedScript(item)">执行</el-button>
<el-button @click="unpinScript(item)">取消置顶</el-button>
</div>
</div>
</div>
<div v-else class="dashboard-pinned-empty dashboard-pinned-empty-card">
<el-empty>
<template #description>
<strong>暂无置顶脚本</strong>
<p> SSH 管理页为常用脚本点击星标后这里会显示快捷执行入口</p>
</template>
</el-empty>
</div>
</section>
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>系统概览</strong>
<p>工作台保留总览统计具体资源在左侧菜单进入独立管理页面</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboardStats">刷新统计</el-button>
</div>
</template>
<el-alert
v-if="loadError"
:closable="false"
class="dashboard-alert"
title="后端连接失败"
type="error"
:description="loadError"
/>
</el-card>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup>
import { Lock, User } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { apiPost } from "../api/http";
import { setSession } from "../auth/session";
const router = useRouter();
const loading = ref(false);
const formState = reactive({
username: "admin",
password: "admin123456",
});
async function submitLogin() {
if (!formState.username.trim() || !formState.password.trim()) {
ElMessage.warning("请输入用户名和密码");
return;
}
loading.value = true;
try {
const data = await apiPost("/api/auth/login", {
username: formState.username.trim(),
password: formState.password,
});
setSession(data.token, data.user);
ElMessage.success("登录成功");
router.replace("/");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "登录失败");
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<div class="login-page__backdrop"></div>
<el-card class="login-card" shadow="never">
<div class="login-card__meta">
<span class="login-card__eyebrow">QUALITY INSPECTION</span>
<h1>登录质量检测平台</h1>
<p>资源按创建人隔离超管可查看全量数据</p>
</div>
<el-form label-position="top" @submit.prevent="submitLogin">
<el-form-item label="用户名">
<el-input v-model="formState.username" :prefix-icon="User" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="formState.password"
:prefix-icon="Lock"
type="password"
show-password
placeholder="请输入密码"
@keyup.enter="submitLogin"
/>
</el-form-item>
<el-button type="primary" class="login-card__submit" :loading="loading" @click="submitLogin">
登录
</el-button>
</el-form>
<div class="login-card__hint">
<span>默认超管账号admin</span>
<span>默认密码admin123456</span>
</div>
</el-card>
</div>
</template>
@@ -0,0 +1,369 @@
<script setup>
import { CircleCheck, Edit, Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { apiGet, apiPost } from "../api/http";
const loading = ref(false);
const saving = ref(false);
const searchKeyword = ref("");
const drawerVisible = ref(false);
const toolConfigs = ref([]);
const toolSpecs = ref([]);
const formState = reactive({
id: null,
name: "",
enabled: true,
config_text: "{}",
});
const isEditing = computed(() => Number.isInteger(formState.id));
const filteredConfigs = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return toolConfigs.value;
return toolConfigs.value.filter((item) =>
[item.name, item.creator_name, JSON.stringify(item.config || {})].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
});
function prettyJson(value) {
return JSON.stringify(value ?? {}, null, 2);
}
function resetForm() {
formState.id = null;
formState.name = "";
formState.enabled = true;
formState.config_text = "{}";
}
function openCreateDrawer() {
resetForm();
drawerVisible.value = true;
}
function openFromSpec(spec) {
const existing = toolConfigs.value.find((item) => item.name === spec.name);
if (existing) {
openEditDrawer(existing);
return;
}
resetForm();
formState.name = spec.name || "";
drawerVisible.value = true;
}
function openEditDrawer(row) {
formState.id = row.id;
formState.name = row.name || "";
formState.enabled = Boolean(row.enabled);
formState.config_text = prettyJson(row.config || {});
drawerVisible.value = true;
}
function parseConfigText() {
try {
const parsed = JSON.parse(formState.config_text || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("配置必须是 JSON 对象");
}
return parsed;
} catch (error) {
throw new Error(error instanceof Error ? error.message : "配置解析失败");
}
}
function specFieldSummary(spec) {
const schema = spec.input_schema || {};
const properties = Object.keys(schema.properties || {});
if (!properties.length) return "无需额外参数";
return `参数:${properties.join(" / ")}`;
}
async function loadPage() {
loading.value = true;
try {
const [configResult, specResult] = await Promise.allSettled([apiGet("/api/mcp-tools"), apiGet("/mcp/tools")]);
if (configResult.status === "fulfilled") {
toolConfigs.value = configResult.value || [];
} else {
toolConfigs.value = [];
ElMessage.error(configResult.reason instanceof Error ? configResult.reason.message : "MCP 配置加载失败");
}
if (specResult.status === "fulfilled") {
toolSpecs.value = specResult.value?.tools || [];
} else {
toolSpecs.value = [];
ElMessage.warning(specResult.reason instanceof Error ? specResult.reason.message : "MCP 工具目录加载失败");
}
} finally {
loading.value = false;
}
}
async function submitForm() {
if (!formState.name.trim()) {
ElMessage.warning("工具名不能为空");
return;
}
let config;
try {
config = parseConfigText();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "配置校验失败");
return;
}
saving.value = true;
try {
await apiPost("/api/mcp-tools", {
name: formState.name.trim(),
enabled: Boolean(formState.enabled),
config,
});
ElMessage.success(isEditing.value ? "MCP 配置已更新" : "MCP 配置已创建");
drawerVisible.value = false;
await loadPage();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
saving.value = false;
}
}
onMounted(() => {
loadPage();
});
</script>
<template>
<div class="page-stack">
<el-row :gutter="18">
<el-col :xs="24" :xl="16">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>MCP 配置</strong>
<p>维护工具启用状态和工具级附加配置</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadPage">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新建配置</el-button>
</div>
</div>
</template>
<div class="api-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索工具名、创建人或配置 JSON"
clearable
class="api-toolbar__search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-tag type="info" effect="plain">已配置 {{ filteredConfigs.length }}</el-tag>
</div>
<el-table :data="filteredConfigs" stripe v-loading="loading">
<el-table-column label="工具名" min-width="220">
<template #default="{ row }">
<div class="mcp-tool-cell">
<div class="mcp-tool-cell__icon">
<el-icon><Setting /></el-icon>
</div>
<div>
<strong>{{ row.name }}</strong>
<p>{{ row.creator_name || "--" }}</p>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" effect="light">
{{ row.enabled ? "启用" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="配置预览" min-width="360">
<template #default="{ row }">
<pre class="mcp-config-preview">{{ prettyJson(row.config || {}) }}</pre>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text :icon="Edit" @click="openEditDrawer(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :xl="8">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>内置 MCP 工具目录</strong>
<p>点击后可快速创建或编辑对应配置</p>
</div>
</div>
</template>
<div class="mcp-spec-list" v-loading="loading">
<button
v-for="spec in toolSpecs"
:key="spec.name"
type="button"
class="mcp-spec-card"
@click="openFromSpec(spec)"
>
<div class="mcp-spec-card__head">
<strong>{{ spec.name }}</strong>
<el-icon><CircleCheck /></el-icon>
</div>
<p>{{ spec.description || "暂无说明" }}</p>
<span>{{ specFieldSummary(spec) }}</span>
</button>
<el-empty v-if="!toolSpecs.length && !loading" description="暂无可用 MCP 工具目录" />
</div>
</el-card>
</el-col>
</el-row>
<el-drawer
v-model="drawerVisible"
:title="isEditing ? '编辑 MCP 配置' : '新建 MCP 配置'"
size="560px"
destroy-on-close
>
<el-form label-position="top">
<el-form-item label="工具名">
<el-input v-model="formState.name" placeholder="workflow_run / catalog_snapshot" />
</el-form-item>
<el-form-item label="启用状态">
<el-switch v-model="formState.enabled" inline-prompt active-text="启用" inactive-text="禁用" />
</el-form-item>
<el-form-item label="配置 JSON">
<el-input v-model="formState.config_text" type="textarea" :rows="18" class="code-textarea" />
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存配置</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.mcp-tool-cell {
display: flex;
align-items: center;
gap: 12px;
}
.mcp-tool-cell__icon {
width: 38px;
height: 38px;
flex: 0 0 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, #eef5ff 0%, #e4fff7 100%);
color: #4f79b3;
}
.mcp-tool-cell strong,
.mcp-tool-cell p {
margin: 0;
}
.mcp-tool-cell p {
margin-top: 4px;
color: #8a9ab1;
font-size: 12px;
}
.mcp-config-preview {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.7;
color: #4a5b73;
}
.mcp-spec-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-spec-card {
border: 1px solid #e4ebf5;
border-radius: 18px;
padding: 14px 16px;
text-align: left;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.mcp-spec-card:hover {
transform: translateY(-1px);
border-color: #bed2fb;
box-shadow: 0 14px 28px rgba(54, 98, 172, 0.1);
}
.mcp-spec-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mcp-spec-card strong,
.mcp-spec-card p,
.mcp-spec-card span {
display: block;
}
.mcp-spec-card p {
margin: 10px 0 8px;
color: #516173;
font-size: 13px;
line-height: 1.7;
}
.mcp-spec-card span {
color: #8a9ab1;
font-size: 12px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
@@ -0,0 +1,764 @@
<script setup>
import {
Delete,
Document,
Edit,
Folder,
Monitor,
Plus,
Refresh,
Star,
StarFilled,
SwitchButton,
Upload,
VideoPlay,
} from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiDelete, apiGet, apiPost, apiPut, apiUploadForm } from "../api/http";
import { loadPinnedScripts, togglePinScript } from "../utils/sshPinnedScripts";
import {
activeTabSessionId as activeRunTabId,
cleanupTabSessions,
fitSessionTerminal,
promptAndExecuteScript as runScriptInTab,
registerTerminalContainer,
removeTabSession,
sessions,
stopSession,
tabSessionIds,
} from "../utils/sshScriptRunner";
const route = useRoute();
const router = useRouter();
const treeLoading = ref(false);
const profileSaving = ref(false);
const scriptSaving = ref(false);
const drawerVisible = ref(false);
const scriptDrawerVisible = ref(false);
const uploadDrawerVisible = ref(false);
const editingProfileId = ref(null);
const editingScriptId = ref(null);
const treeKeyword = ref("");
const treeData = ref([]);
const selectedNodeKey = ref("");
const pinnedScriptIds = ref(new Set());
const uploadProfileId = ref(null);
const uploadForm = reactive({
name: "",
description: "",
file: null,
});
const profileForm = reactive({
name: "",
host: "",
port: 22,
username: "",
auth_type: "key",
key_path: "",
});
const scriptForm = reactive({
profile_id: null,
name: "",
description: "",
content: "",
});
const runSessions = computed(() =>
tabSessionIds.value.map((id) => sessions.value.find((item) => item.id === id)).filter(Boolean)
);
const activeSession = computed(
() => runSessions.value.find((session) => session.id === activeRunTabId.value) || null
);
const hasRunningSession = computed(() =>
runSessions.value.some((session) => session.status === "running" || session.status === "connecting")
);
let resizeObserver = null;
const filteredTreeData = computed(() => {
const keyword = treeKeyword.value.trim().toLowerCase();
if (!keyword) {
return buildTreeNodes(treeData.value);
}
const filtered = [];
for (const profile of treeData.value) {
const profileMatched = [profile.name, profile.host, profile.username].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
);
const scripts = (profile.scripts || []).filter((script) =>
[script.name, script.description].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
if (profileMatched || scripts.length) {
filtered.push({
...profile,
scripts: profileMatched ? profile.scripts || [] : scripts,
});
}
}
return buildTreeNodes(filtered);
});
function buildTreeNodes(profiles) {
return profiles.map((profile) => ({
id: `profile-${profile.id}`,
label: profile.name,
nodeType: "profile",
profileId: profile.id,
profile,
children: (profile.scripts || []).map((script) => ({
id: `script-${script.id}`,
label: script.name,
nodeType: "script",
profileId: profile.id,
scriptId: script.id,
script,
profile,
isLeaf: true,
})),
}));
}
function refreshPinnedState() {
pinnedScriptIds.value = new Set(loadPinnedScripts().map((item) => Number(item.scriptId)));
}
function scriptIsPinned(scriptId) {
return pinnedScriptIds.value.has(Number(scriptId));
}
function handleTogglePin(data) {
const { script, profile } = data;
const pinned = togglePinScript({
scriptId: script.id,
profileId: profile.id,
scriptName: script.name,
profileName: profile.name,
profileHost: `${profile.username}@${profile.host}:${profile.port}`,
description: script.description || "",
});
refreshPinnedState();
ElMessage.success(pinned ? "已置顶到首页" : "已取消首页置顶");
}
function resetProfileForm() {
editingProfileId.value = null;
profileForm.name = "";
profileForm.host = "";
profileForm.port = 22;
profileForm.username = "";
profileForm.auth_type = "key";
profileForm.key_path = "";
}
function fillProfileForm(profile) {
editingProfileId.value = profile.id;
profileForm.name = profile.name;
profileForm.host = profile.host;
profileForm.port = Number(profile.port || 22);
profileForm.username = profile.username;
profileForm.auth_type = profile.auth_type || "key";
profileForm.key_path = profile.key_path || "";
}
function resetScriptForm(profileId = null) {
editingScriptId.value = null;
scriptForm.profile_id = profileId;
scriptForm.name = "";
scriptForm.description = "";
scriptForm.content = "";
}
function fillScriptForm(script, profileId) {
editingScriptId.value = script.id;
scriptForm.profile_id = profileId;
scriptForm.name = script.name || "";
scriptForm.description = script.description || "";
scriptForm.content = "";
}
async function loadScriptContent(scriptId) {
const row = await apiGet(`/api/ssh-scripts/${scriptId}`);
scriptForm.content = row.content || "";
}
function openCreateDrawer() {
resetProfileForm();
drawerVisible.value = true;
}
function openEditDrawer(profile) {
fillProfileForm(profile);
drawerVisible.value = true;
}
function openCreateScriptDrawer(profileId) {
resetScriptForm(profileId);
scriptDrawerVisible.value = true;
}
async function openEditScriptDrawer(script, profileId) {
fillScriptForm(script, profileId);
scriptDrawerVisible.value = true;
await loadScriptContent(script.id);
}
function openUploadDrawer(profileId) {
uploadProfileId.value = profileId;
uploadForm.name = "";
uploadForm.description = "";
uploadForm.file = null;
uploadDrawerVisible.value = true;
}
function buildProfilePayload() {
const payload = {
name: profileForm.name.trim(),
host: profileForm.host.trim(),
port: Number(profileForm.port || 22),
username: profileForm.username.trim(),
auth_type: profileForm.auth_type,
key_path: profileForm.key_path.trim(),
presets: [],
};
if (!payload.name || !payload.host || !payload.username) {
throw new Error("名称、主机和用户名不能为空");
}
if (!Number.isFinite(payload.port) || payload.port <= 0) {
throw new Error("端口必须是有效数字");
}
if (payload.auth_type === "key" && !payload.key_path) {
throw new Error("密钥认证需要填写 key_path");
}
return payload;
}
function buildScriptPayload() {
const payload = {
profile_id: Number(scriptForm.profile_id || 0),
name: scriptForm.name.trim(),
description: scriptForm.description.trim(),
source_type: "inline",
content: scriptForm.content.trim(),
};
if (!payload.profile_id) {
throw new Error("请选择所属 SSH 主机");
}
if (!payload.name || !payload.content) {
throw new Error("脚本名称和内容不能为空");
}
return payload;
}
async function loadTree() {
treeLoading.value = true;
try {
const rows = await apiGet("/api/ssh-tree");
treeData.value = rows;
refreshPinnedState();
if (selectedNodeKey.value) {
const stillExists = rows.some((profile) => {
if (selectedNodeKey.value === `profile-${profile.id}`) return true;
return (profile.scripts || []).some((script) => selectedNodeKey.value === `script-${script.id}`);
});
if (!stillExists) {
selectedNodeKey.value = "";
}
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "SSH 树加载失败");
} finally {
treeLoading.value = false;
}
}
async function submitProfile() {
let payload;
try {
payload = buildProfilePayload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "表单校验失败");
return;
}
profileSaving.value = true;
try {
if (editingProfileId.value) {
await apiPut(`/api/ssh-profiles/${editingProfileId.value}`, payload);
ElMessage.success("SSH 主机已更新");
} else {
await apiPost("/api/ssh-profiles", payload);
ElMessage.success("SSH 主机已创建");
}
drawerVisible.value = false;
await loadTree();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "SSH 主机保存失败");
} finally {
profileSaving.value = false;
}
}
async function submitScript() {
let payload;
try {
payload = buildScriptPayload();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "表单校验失败");
return;
}
scriptSaving.value = true;
try {
if (editingScriptId.value) {
await apiPut(`/api/ssh-scripts/${editingScriptId.value}`, {
name: payload.name,
description: payload.description,
content: payload.content,
});
ElMessage.success("脚本已更新");
} else {
await apiPost("/api/ssh-scripts", payload);
ElMessage.success("脚本已创建");
}
scriptDrawerVisible.value = false;
await loadTree();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "脚本保存失败");
} finally {
scriptSaving.value = false;
}
}
async function submitUpload() {
if (!uploadProfileId.value) {
ElMessage.warning("请选择 SSH 主机");
return;
}
if (!uploadForm.file) {
ElMessage.warning("请选择脚本文件");
return;
}
const formData = new FormData();
formData.append("profile_id", String(uploadProfileId.value));
formData.append("name", uploadForm.name.trim());
formData.append("description", uploadForm.description.trim());
formData.append("file", uploadForm.file);
scriptSaving.value = true;
try {
await apiUploadForm("/api/ssh-scripts/upload", formData);
ElMessage.success("脚本文件已上传");
uploadDrawerVisible.value = false;
await loadTree();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "脚本上传失败");
} finally {
scriptSaving.value = false;
}
}
async function handleDeleteProfile(profile) {
try {
await ElMessageBox.confirm(`确认删除 SSH 主机「${profile.name}」及其脚本吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/ssh-profiles/${profile.id}`);
if (selectedNodeKey.value === `profile-${profile.id}`) {
selectedNodeKey.value = "";
}
ElMessage.success("SSH 主机已删除");
await loadTree();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
async function handleDeleteScript(script) {
try {
await ElMessageBox.confirm(`确认删除脚本「${script.name}」吗?`, "删除确认", {
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消",
});
await apiDelete(`/api/ssh-scripts/${script.id}`);
if (selectedNodeKey.value === `script-${script.id}`) {
selectedNodeKey.value = "";
}
ElMessage.success("脚本已删除");
await loadTree();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(error instanceof Error ? error.message : "删除失败");
}
}
}
function handleTreeSelect(node) {
if (!node) return;
selectedNodeKey.value = node.id;
}
function handleTreeNodeClick(data) {
selectedNodeKey.value = data.id;
}
async function handleExecuteScript(data) {
const { script, profile } = data;
selectedNodeKey.value = `script-${script.id}`;
await runScriptInTab({ script, profile, target: "tab" });
}
function stopActiveSession() {
if (activeSession.value) {
stopSession(activeSession.value);
}
}
function handleTabRemove(tabId) {
removeTabSession(tabId);
}
function findTreeScript(profileId, scriptId) {
const profile = treeData.value.find((item) => Number(item.id) === Number(profileId));
if (!profile) return null;
const script = (profile.scripts || []).find((item) => Number(item.id) === Number(scriptId));
if (!script) return null;
return { script, profile };
}
async function maybeAutoRunFromQuery() {
if (route.query.run !== "1") return;
const profileId = Number(route.query.profile_id || 0);
const scriptId = Number(route.query.script_id || 0);
if (!profileId || !scriptId) return;
const target = findTreeScript(profileId, scriptId);
if (!target) {
ElMessage.warning("脚本不存在或已删除");
router.replace({ path: "/ssh" });
return;
}
selectedNodeKey.value = `script-${scriptId}`;
await runScriptInTab({
script: target.script,
profile: target.profile,
target: "tab",
});
router.replace({ path: "/ssh" });
}
function onUploadFileChange(file) {
uploadForm.file = file?.raw || null;
if (!uploadForm.name.trim() && file?.name) {
uploadForm.name = file.name;
}
}
watch(activeRunTabId, () => {
nextTick(() => fitSessionTerminal(activeRunTabId.value));
});
watch(
() => route.query.run,
() => {
if (treeData.value.length) {
maybeAutoRunFromQuery();
}
}
);
onMounted(async () => {
resetProfileForm();
resetScriptForm();
refreshPinnedState();
await loadTree();
await maybeAutoRunFromQuery();
const mainPanel = document.querySelector(".ssh-page__main");
if (mainPanel) {
resizeObserver = new ResizeObserver(() => fitSessionTerminal(activeRunTabId.value));
resizeObserver.observe(mainPanel);
}
});
onBeforeUnmount(() => {
cleanupTabSessions();
resizeObserver?.disconnect();
resizeObserver = null;
});
</script>
<template>
<div class="ssh-page">
<aside class="ssh-page__sidebar panel-card" v-loading="treeLoading">
<div class="panel-card__header ssh-page__sidebar-header">
<div>
<strong>SSH 脚本</strong>
<p>按主机维护脚本选择脚本后可执行置顶或编辑</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadTree">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新增主机</el-button>
</div>
</div>
<el-input v-model="treeKeyword" placeholder="搜索主机或脚本" clearable class="ssh-page__search">
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
<el-scrollbar class="ssh-page__tree-scroll">
<el-tree
v-if="filteredTreeData.length"
:data="filteredTreeData"
node-key="id"
highlight-current
default-expand-all
:expand-on-click-node="false"
:current-node-key="selectedNodeKey"
@node-click="handleTreeNodeClick"
@current-change="handleTreeSelect"
>
<template #default="{ data }">
<div class="ssh-tree-node">
<span class="ssh-tree-node__label">
<span class="ssh-tree-node__icon">
<el-icon v-if="data.nodeType === 'profile'"><Monitor /></el-icon>
<el-icon v-else><Document /></el-icon>
</span>
<span class="ssh-tree-node__text">
<span class="ssh-tree-node__title">{{ data.label }}</span>
<span v-if="data.nodeType === 'profile'" class="ssh-tree-node__meta">
{{ data.profile.username }}@{{ data.profile.host }}:{{ data.profile.port }}
</span>
<span v-else-if="data.script.description" class="ssh-tree-node__meta">
{{ data.script.description }}
</span>
</span>
</span>
<span v-if="data.nodeType === 'profile'" class="ssh-tree-node__actions" @click.stop>
<el-button text :icon="Plus" @click="openCreateScriptDrawer(data.profileId)">脚本</el-button>
<el-button text :icon="Upload" @click="openUploadDrawer(data.profileId)">上传</el-button>
<el-button text :icon="Edit" @click="openEditDrawer(data.profile)" />
<el-button text type="danger" :icon="Delete" @click="handleDeleteProfile(data.profile)" />
</span>
<span v-else class="ssh-tree-node__actions" @click.stop>
<el-button text type="primary" :icon="VideoPlay" @click="handleExecuteScript(data)">执行</el-button>
<el-button
text
:type="scriptIsPinned(data.scriptId) ? 'warning' : 'default'"
@click="handleTogglePin(data)"
>
<el-icon><StarFilled v-if="scriptIsPinned(data.scriptId)" /><Star v-else /></el-icon>
</el-button>
<el-button text :icon="Edit" @click="openEditScriptDrawer(data.script, data.profileId)" />
<el-button text type="danger" :icon="Delete" @click="handleDeleteScript(data.script)" />
</span>
</div>
</template>
</el-tree>
<el-empty v-else description="还没有 SSH 主机,先新增主机并添加脚本" />
</el-scrollbar>
</aside>
<section class="ssh-page__main panel-card">
<div class="ssh-run-panel">
<div class="ssh-run-panel__toolbar">
<div class="ssh-run-panel__title">
<strong>执行日志</strong>
<p>脚本运行输出按 Tab 保留切换 Tab 查看不同会话</p>
</div>
<div class="toolbar-actions">
<el-button
type="danger"
:icon="SwitchButton"
:disabled="!activeSession || (activeSession.status !== 'running' && activeSession.status !== 'connecting')"
@click="stopActiveSession"
>
停止当前脚本
</el-button>
<el-tag v-if="hasRunningSession" type="success" effect="plain">有脚本正在运行</el-tag>
</div>
</div>
<el-tabs
v-if="runSessions.length"
v-model="activeRunTabId"
type="card"
class="ssh-run-tabs"
closable
@tab-remove="handleTabRemove"
>
<el-tab-pane v-for="session in runSessions" :key="session.id" :name="session.id" :closable="true">
<template #label>
<span class="ssh-run-tab__label">
<span>{{ session.scriptName }}</span>
<el-tag
size="small"
:type="
session.status === 'running' || session.status === 'connecting'
? 'success'
: session.status === 'failed'
? 'danger'
: session.status === 'success'
? 'info'
: 'warning'
"
effect="plain"
>
{{
session.status === "running" || session.status === "connecting"
? "运行中"
: session.status === "success"
? "完成"
: session.status === "failed"
? "失败"
: "已停止"
}}
</el-tag>
</span>
</template>
<div class="ssh-terminal-shell ssh-page__terminal">
<div class="ssh-terminal-shell__meta">
<span>{{ session.statusText }}</span>
<span>{{ session.profileHost }}</span>
</div>
<div
:ref="(el) => registerTerminalContainer(session.id, el)"
class="ssh-terminal-canvas ssh-page__terminal-canvas"
/>
</div>
</el-tab-pane>
</el-tabs>
<div v-else class="ssh-run-empty">
<el-empty description="在左侧树中执行脚本,将自动打开日志 Tab" />
</div>
</div>
</section>
<el-drawer
v-model="drawerVisible"
:title="editingProfileId ? '编辑 SSH 主机' : '新增 SSH 主机'"
size="640px"
destroy-on-close
>
<el-form label-position="top">
<div class="ssh-drawer-grid">
<el-form-item label="名称">
<el-input v-model="profileForm.name" placeholder="例如:测试机-01" />
</el-form-item>
<el-form-item label="主机">
<el-input v-model="profileForm.host" placeholder="例如:10.10.10.8" />
</el-form-item>
<el-form-item label="端口">
<el-input-number v-model="profileForm.port" :min="1" :max="65535" controls-position="right" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="profileForm.username" placeholder="例如:root" />
</el-form-item>
<el-form-item label="认证方式">
<el-segmented
v-model="profileForm.auth_type"
:options="[
{ label: '密钥', value: 'key' },
{ label: '密码', value: 'password' },
]"
/>
</el-form-item>
<el-form-item label="Key Path">
<el-input
v-model="profileForm.key_path"
:disabled="profileForm.auth_type !== 'key'"
placeholder="例如:/Users/name/.ssh/id_rsa"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="profileSaving" @click="submitProfile">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer v-model="scriptDrawerVisible" :title="editingScriptId ? '编辑脚本' : '新增脚本'" size="720px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="所属主机">
<el-select v-model="scriptForm.profile_id" placeholder="选择 SSH 主机" :disabled="Boolean(editingScriptId)">
<el-option
v-for="profile in treeData"
:key="profile.id"
:label="`${profile.name} (${profile.username}@${profile.host})`"
:value="profile.id"
/>
</el-select>
</el-form-item>
<el-form-item label="脚本名称">
<el-input v-model="scriptForm.name" placeholder="例如:部署应用" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="scriptForm.description" placeholder="可选" />
</el-form-item>
<el-form-item label="脚本内容">
<el-input
v-model="scriptForm.content"
type="textarea"
:rows="16"
resize="vertical"
placeholder="#!/bin/bash&#10;echo hello"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="scriptDrawerVisible = false">取消</el-button>
<el-button type="primary" :loading="scriptSaving" @click="submitScript">保存</el-button>
</div>
</template>
</el-drawer>
<el-drawer v-model="uploadDrawerVisible" title="上传脚本文件" size="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="脚本名称">
<el-input v-model="uploadForm.name" placeholder="默认识别文件名" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="uploadForm.description" placeholder="可选" />
</el-form-item>
<el-form-item label="脚本文件">
<el-upload :auto-upload="false" :limit="1" accept=".sh,.bash,.txt" :on-change="onUploadFileChange">
<el-button :icon="Upload">选择文件</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="uploadDrawerVisible = false">取消</el-button>
<el-button type="primary" :loading="scriptSaving" @click="submitUpload">上传</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -0,0 +1,241 @@
<script setup>
import { CircleCheck, Lock, Plus, Refresh, Search, User } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { apiGet, apiPost, apiPut } from "../api/http";
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const searchKeyword = ref("");
const users = ref([]);
const formState = reactive({
id: null,
username: "",
display_name: "",
role: "user",
is_active: true,
password: "",
});
const isEditing = computed(() => Number.isInteger(formState.id));
const filteredUsers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return users.value;
return users.value.filter((item) =>
[item.username, item.display_name, item.role].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword)
)
);
});
function resetForm() {
formState.id = null;
formState.username = "";
formState.display_name = "";
formState.role = "user";
formState.is_active = true;
formState.password = "";
}
function openCreateDrawer() {
resetForm();
drawerVisible.value = true;
}
function openEditDrawer(row) {
formState.id = row.id;
formState.username = row.username;
formState.display_name = row.display_name || "";
formState.role = row.role || "user";
formState.is_active = Boolean(row.is_active);
formState.password = "";
drawerVisible.value = true;
}
async function loadUsers() {
loading.value = true;
try {
users.value = await apiGet("/api/users");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "用户列表加载失败");
} finally {
loading.value = false;
}
}
async function submitForm() {
const payload = {
display_name: formState.display_name.trim() || formState.username.trim(),
role: formState.role,
is_active: Boolean(formState.is_active),
password: formState.password,
};
if (!isEditing.value) {
if (!formState.username.trim() || !formState.password.trim()) {
ElMessage.warning("新建用户时,用户名和密码不能为空");
return;
}
}
saving.value = true;
try {
if (isEditing.value) {
await apiPut(`/api/users/${formState.id}`, payload);
ElMessage.success("用户已更新");
} else {
await apiPost("/api/users", {
username: formState.username.trim(),
password: formState.password,
display_name: payload.display_name,
role: payload.role,
});
ElMessage.success("用户已创建");
}
drawerVisible.value = false;
await loadUsers();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
saving.value = false;
}
}
async function toggleActive(row, nextValue) {
try {
await apiPut(`/api/users/${row.id}`, {
display_name: row.display_name,
role: row.role,
is_active: nextValue,
password: "",
});
ElMessage.success(nextValue ? "用户已启用" : "用户已停用");
await loadUsers();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "状态更新失败");
}
}
onMounted(() => {
loadUsers();
});
</script>
<template>
<div class="page-stack">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>用户管理</strong>
<p>仅超管可访问支持创建修改角色启停用和重置密码</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" @click="loadUsers">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreateDrawer">新建用户</el-button>
</div>
</div>
</template>
<div class="api-toolbar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名、显示名或角色"
clearable
class="api-toolbar__search"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-tag type="info" effect="plain"> {{ filteredUsers.length }} 位用户</el-tag>
</div>
<el-table :data="filteredUsers" stripe v-loading="loading">
<el-table-column label="用户" min-width="220">
<template #default="{ row }">
<div class="user-cell">
<div class="user-cell__avatar">
<el-icon><User /></el-icon>
</div>
<div>
<strong>{{ row.display_name }}</strong>
<p>{{ row.username }}</p>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="130">
<template #default="{ row }">
<el-tag :type="row.role === 'superadmin' ? 'danger' : 'primary'" effect="light">
{{ row.role === "superadmin" ? "超管" : "普通用户" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="140">
<template #default="{ row }">
<el-switch
:model-value="row.is_active"
inline-prompt
active-text="启用"
inactive-text="停用"
@change="toggleActive(row, $event)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button text :icon="CircleCheck" @click="openEditDrawer(row)">编辑</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer
v-model="drawerVisible"
:title="isEditing ? '编辑用户' : '新建用户'"
size="520px"
destroy-on-close
>
<el-form label-position="top">
<el-form-item label="用户名">
<el-input v-model="formState.username" :disabled="isEditing" placeholder="alice" />
</el-form-item>
<el-form-item label="显示名">
<el-input v-model="formState.display_name" placeholder="Alice" />
</el-form-item>
<el-form-item label="角色">
<el-radio-group v-model="formState.role">
<el-radio-button label="user">普通用户</el-radio-button>
<el-radio-button label="superadmin">超管</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="isEditing" label="账号状态">
<el-switch v-model="formState.is_active" inline-prompt active-text="启用" inactive-text="停用" />
</el-form-item>
<el-form-item :label="isEditing ? '重置密码(留空则不修改)' : '初始密码'">
<el-input
v-model="formState.password"
:prefix-icon="Lock"
type="password"
show-password
placeholder="请输入密码"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
@@ -0,0 +1,718 @@
<script setup>
import { Link, Plus, Refresh, VideoPlay } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { apiGet, apiPost, apiPut } from "../api/http";
const pageTab = ref("batch");
const loading = ref(false);
const batchSaving = ref(false);
const batchRunning = ref(false);
const historyLoading = ref(false);
const replayLoading = ref(false);
const lokiLoading = ref(false);
const workflows = ref([]);
const batches = ref([]);
const activeBatchId = ref(null);
const activeBatch = ref(null);
const workflowTableRef = ref(null);
const batchForm = ref({
name: "",
base_url: "",
fail_fast: true,
});
const selectedWorkflowIds = ref([]);
const runs = ref([]);
const historyWorkflowFilter = ref("");
const selectedRun = ref(null);
const runDetailVisible = ref(false);
const activeNodeId = ref("");
const lokiLink = ref(null);
const activeBatchIsDraft = computed(() => activeBatch.value?.status === "draft");
const draftBatches = computed(() => batches.value.filter((item) => item.status === "draft"));
const executedBatches = computed(() => batches.value.filter((item) => item.status !== "draft"));
const historyWorkflowOptions = computed(() => {
const map = new Map();
runs.value.forEach((item) => {
if (item.workflow_id) {
map.set(item.workflow_id, item.workflow_name || `工作流 #${item.workflow_id}`);
}
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
});
const runNodeResults = computed(() => {
const payload = selectedRun.value?.payload || {};
if (selectedRun.value?.run_type === "node") {
const result = payload.result;
return result ? [result] : [];
}
return Array.isArray(payload.results) ? payload.results : [];
});
const activeNodeResult = computed(() =>
runNodeResults.value.find((item) => String(item.node_id) === String(activeNodeId.value))
);
async function loadWorkflows() {
loading.value = true;
try {
workflows.value = await apiGet("/api/workflows");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "工作流列表加载失败");
} finally {
loading.value = false;
}
}
async function loadBatches() {
try {
const data = await apiGet("/api/workflow-batches?limit=50");
batches.value = data.items || [];
if (activeBatchId.value && !batches.value.some((item) => item.id === activeBatchId.value)) {
activeBatchId.value = null;
activeBatch.value = null;
}
} catch {
batches.value = [];
}
}
async function loadActiveBatchDetail() {
if (!activeBatchId.value) {
activeBatch.value = null;
selectedWorkflowIds.value = [];
return;
}
try {
activeBatch.value = await apiGet(`/api/workflow-batches/${activeBatchId.value}`);
batchForm.value = {
name: activeBatch.value.name || "",
base_url: activeBatch.value.base_url || "",
fail_fast: activeBatch.value.fail_fast !== false,
};
selectedWorkflowIds.value = [...(activeBatch.value.workflow_ids || [])];
await nextTick();
syncWorkflowTableSelection();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "批次详情加载失败");
activeBatch.value = null;
}
}
function syncWorkflowTableSelection() {
const table = workflowTableRef.value;
if (!table) return;
table.clearSelection();
const idSet = new Set(selectedWorkflowIds.value.map((id) => Number(id)));
workflows.value.forEach((row) => {
if (idSet.has(Number(row.id))) {
table.toggleRowSelection(row, true);
}
});
}
async function loadRunHistory() {
historyLoading.value = true;
try {
const params = new URLSearchParams({ limit: "80" });
if (historyWorkflowFilter.value) {
params.set("workflow_id", String(historyWorkflowFilter.value));
}
const data = await apiGet(`/api/workflow-runs?${params.toString()}`);
runs.value = data.items || [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "执行历史加载失败");
} finally {
historyLoading.value = false;
}
}
async function createBatch() {
batchSaving.value = true;
try {
const created = await apiPost("/api/workflow-batches", {
name: "",
base_url: "",
fail_fast: true,
workflow_ids: [],
});
await loadBatches();
activeBatchId.value = created.id;
await loadActiveBatchDetail();
ElMessage.success(`已创建批跑任务 #${created.id},请选择工作流后保存并执行`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "创建批跑失败");
} finally {
batchSaving.value = false;
}
}
async function saveActiveBatch() {
if (!activeBatchId.value || !activeBatchIsDraft.value) return;
batchSaving.value = true;
try {
const updated = await apiPut(`/api/workflow-batches/${activeBatchId.value}`, {
name: batchForm.value.name.trim(),
base_url: batchForm.value.base_url.trim(),
fail_fast: batchForm.value.fail_fast,
workflow_ids: selectedWorkflowIds.value,
});
activeBatch.value = updated;
await loadBatches();
ElMessage.success("批跑任务已保存");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally {
batchSaving.value = false;
}
}
async function runActiveBatch() {
if (!activeBatchId.value || !activeBatchIsDraft.value) return;
if (!selectedWorkflowIds.value.length) {
ElMessage.warning("请先选择至少一个工作流并保存");
return;
}
try {
await ElMessageBox.confirm(
`将执行批跑 #${activeBatchId.value},共 ${selectedWorkflowIds.value.length} 个工作流。是否继续?`,
"执行批跑",
{ type: "info", confirmButtonText: "执行", cancelButtonText: "取消" }
);
} catch {
return;
}
batchRunning.value = true;
try {
await saveActiveBatch();
const result = await apiPost(`/api/workflow-batches/${activeBatchId.value}/run`, {});
ElMessage.success(`批跑完成:${result.status}`);
await Promise.all([loadBatches(), loadRunHistory()]);
pageTab.value = "history";
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "批跑执行失败");
} finally {
batchRunning.value = false;
}
}
function selectBatch(row) {
activeBatchId.value = row.id;
}
function handleWorkflowSelection(rows) {
if (!activeBatchIsDraft.value) return;
selectedWorkflowIds.value = rows.map((item) => item.id);
}
async function openRunDetail(row) {
historyLoading.value = true;
try {
selectedRun.value = await apiGet(`/api/workflow-runs/${row.id}`);
const firstHttp = runNodeResults.value.find((item) => item.node_type === "http");
activeNodeId.value = firstHttp ? String(firstHttp.node_id) : String(runNodeResults.value[0]?.node_id || "");
lokiLink.value = null;
runDetailVisible.value = true;
if (activeNodeId.value) {
await loadLokiLink();
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "执行详情加载失败");
} finally {
historyLoading.value = false;
}
}
async function loadLokiLink() {
if (!selectedRun.value?.id || !activeNodeId.value) {
lokiLink.value = null;
return;
}
lokiLoading.value = true;
try {
const params = new URLSearchParams({ node_id: activeNodeId.value });
lokiLink.value = await apiGet(`/api/workflow-runs/${selectedRun.value.id}/loki-link?${params.toString()}`);
} catch (error) {
lokiLink.value = { enabled: false, hint: error instanceof Error ? error.message : "Loki 链接生成失败" };
} finally {
lokiLoading.value = false;
}
}
async function handleNodeChange(nodeId) {
activeNodeId.value = nodeId;
await loadLokiLink();
}
async function replaySelectedRun() {
if (!selectedRun.value?.id) return;
try {
await ElMessageBox.confirm("将按该次执行的快照重放工作流,是否继续?", "重放确认", {
type: "info",
confirmButtonText: "重放",
cancelButtonText: "取消",
});
} catch {
return;
}
replayLoading.value = true;
try {
const request = selectedRun.value.payload?.request || {};
const result = await apiPost(`/api/workflow-runs/${selectedRun.value.id}/replay`, {
base_url: request.base_url || "",
fail_fast: request.fail_fast !== false,
});
ElMessage.success(`重放完成:${result.status}(新记录 #${result.run_id || "-"}`);
await loadRunHistory();
if (result.run_id) {
await openRunDetail({ id: result.run_id });
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "重放失败");
} finally {
replayLoading.value = false;
}
}
function openLokiExplore() {
if (!lokiLink.value?.explore_url) {
ElMessage.warning(lokiLink.value?.hint || "未配置 Loki 探索地址");
return;
}
window.open(lokiLink.value.explore_url, "_blank", "noopener,noreferrer");
}
function formatJson(value) {
try {
return JSON.stringify(value ?? {}, null, 2);
} catch {
return String(value ?? "");
}
}
function statusTagType(status) {
if (status === "success") return "success";
if (status === "failed") return "danger";
if (status === "partial") return "warning";
if (status === "running") return "warning";
if (status === "draft") return "info";
return "info";
}
watch(activeBatchId, () => {
loadActiveBatchDetail();
});
onMounted(async () => {
await Promise.all([loadWorkflows(), loadBatches(), loadRunHistory()]);
});
</script>
<template>
<div class="page-stack workflow-batch-page">
<el-card class="panel-card" shadow="never">
<template #header>
<div class="panel-card__header">
<div>
<strong>跑批工作流执行</strong>
<p>先创建批跑任务再勾选要执行的工作流保存后执行执行历史可查看响应重放与 Loki 日志</p>
</div>
<div class="toolbar-actions">
<el-button :icon="Refresh" :loading="loading || historyLoading" @click="loadWorkflows(); loadBatches(); loadRunHistory();">
刷新
</el-button>
</div>
</div>
</template>
<el-tabs v-model="pageTab">
<el-tab-pane label="跑批任务" name="batch">
<div class="batch-layout">
<aside class="batch-layout__side">
<div class="toolbar-actions batch-layout__side-head">
<el-button type="primary" :icon="Plus" :loading="batchSaving" @click="createBatch">新建批跑</el-button>
</div>
<p class="batch-layout__section-title">待执行</p>
<div class="batch-list">
<button
v-for="item in draftBatches"
:key="item.id"
type="button"
class="batch-list__item"
:class="{ 'is-active': activeBatchId === item.id }"
@click="selectBatch(item)"
>
<div class="batch-list__item-head">
<strong>#{{ item.id }} {{ item.name }}</strong>
<el-tag size="small" type="info" effect="plain">draft</el-tag>
</div>
<span>已选 {{ item.workflow_ids?.length || 0 }} 个工作流</span>
</button>
<div v-if="!draftBatches.length" class="text-muted">暂无待执行批跑点击新建批跑创建</div>
</div>
<p class="batch-layout__section-title">已执行</p>
<div class="batch-list">
<button
v-for="item in executedBatches"
:key="item.id"
type="button"
class="batch-list__item"
:class="{ 'is-active': activeBatchId === item.id }"
@click="selectBatch(item)"
>
<div class="batch-list__item-head">
<strong>#{{ item.id }} {{ item.name }}</strong>
<el-tag size="small" :type="statusTagType(item.status)" effect="light">{{ item.status }}</el-tag>
</div>
<span>
成功 {{ item.summary?.success || 0 }} / 失败 {{ item.summary?.failed || 0 }}
</span>
</button>
</div>
</aside>
<section class="batch-layout__main">
<el-empty v-if="!activeBatchId" description="请选择或新建一个批跑任务" />
<template v-else>
<div class="batch-editor__head">
<div>
<strong>批跑 #{{ activeBatch.id }}{{ activeBatch.name }}</strong>
<p>
状态<el-tag size="small" :type="statusTagType(activeBatch.status)" effect="light">{{ activeBatch.status }}</el-tag>
· 已选 {{ selectedWorkflowIds.length }} 个工作流
</p>
</div>
<div v-if="activeBatchIsDraft" class="toolbar-actions">
<el-button :loading="batchSaving" @click="saveActiveBatch">保存</el-button>
<el-button type="primary" :icon="VideoPlay" :loading="batchRunning" @click="runActiveBatch">
执行批跑
</el-button>
</div>
</div>
<el-form label-position="top" class="batch-form" :disabled="!activeBatchIsDraft">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="批次名称">
<el-input v-model="batchForm.name" placeholder="例如:每日回归批跑" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Base URL">
<el-input v-model="batchForm.base_url" placeholder="https://api.example.com" />
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-checkbox v-model="batchForm.fail_fast">遇失败立即停止后续工作流</el-checkbox>
</el-form-item>
</el-form>
<el-divider content-position="left">选择工作流</el-divider>
<el-table
ref="workflowTableRef"
v-loading="loading"
:data="workflows"
row-key="id"
class="batch-workflow-table"
@selection-change="handleWorkflowSelection"
>
<el-table-column type="selection" width="48" :selectable="() => activeBatchIsDraft" />
<el-table-column prop="name" label="工作流" min-width="180" />
<el-table-column prop="folder_path" label="目录" min-width="140" />
<el-table-column label="最近执行" width="120">
<template #default="{ row }">
<el-tag v-if="row.last_run?.status" :type="statusTagType(row.last_run.status)" effect="light" size="small">
{{ row.last_run.status }}
</el-tag>
<span v-else class="text-muted">未执行</span>
</template>
</el-table-column>
</el-table>
<template v-if="!activeBatchIsDraft && activeBatch.runs?.length">
<el-divider content-position="left">本批次执行记录</el-divider>
<el-table :data="activeBatch.runs" size="small">
<el-table-column prop="id" label="记录" width="80" />
<el-table-column prop="workflow_name" label="工作流" min-width="160" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button text type="primary" @click="openRunDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</template>
</template>
</section>
</div>
</el-tab-pane>
<el-tab-pane label="执行历史" name="history">
<div class="history-toolbar">
<el-select v-model="historyWorkflowFilter" clearable placeholder="按工作流筛选" class="history-toolbar__filter" @change="loadRunHistory">
<el-option v-for="item in historyWorkflowOptions" :key="item.id" :label="item.name" :value="String(item.id)" />
</el-select>
</div>
<el-table v-loading="historyLoading" :data="runs" row-key="id" class="run-history-table">
<el-table-column prop="id" label="记录" width="80" />
<el-table-column prop="workflow_name" label="工作流" min-width="160" />
<el-table-column prop="batch_id" label="批次" width="80" />
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag effect="plain" size="small">{{ row.run_type === "node" ? "单节点" : "全量" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" effect="light" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="汇总" min-width="180">
<template #default="{ row }">
成功 {{ row.summary?.success || 0 }} / 失败 {{ row.summary?.failed || 0 }} / {{ row.summary?.total || 0 }}
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时(ms)" width="110" />
<el-table-column prop="created_at" label="时间" min-width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button text type="primary" @click="openRunDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-drawer v-model="runDetailVisible" size="72%" title="执行详情" destroy-on-close>
<div v-if="selectedRun" class="run-detail">
<div class="run-detail__head">
<div>
<strong>{{ selectedRun.workflow_name }}</strong>
<p>
记录 #{{ selectedRun.id }}
<span v-if="selectedRun.batch_id"> · 批次 #{{ selectedRun.batch_id }}</span>
· {{ selectedRun.created_at }}
</p>
</div>
<div class="toolbar-actions">
<el-tag :type="statusTagType(selectedRun.status)" effect="light">{{ selectedRun.status }}</el-tag>
<el-button type="primary" :loading="replayLoading" @click="replaySelectedRun">重放</el-button>
</div>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-card shadow="never" class="panel-card">
<template #header><strong>节点结果</strong></template>
<el-menu :default-active="activeNodeId" @select="handleNodeChange">
<el-menu-item v-for="item in runNodeResults" :key="item.node_id" :index="String(item.node_id)">
<span>{{ item.node_id }}</span>
<el-tag size="small" :type="statusTagType(item.status)" effect="plain">{{ item.status }}</el-tag>
</el-menu-item>
</el-menu>
</el-card>
</el-col>
<el-col :span="16">
<el-card v-if="activeNodeResult" shadow="never" class="panel-card run-detail__node-card">
<template #header>
<div class="panel-card__header">
<strong>{{ activeNodeResult.node_id }}{{ activeNodeResult.node_type }}</strong>
<el-button
v-if="activeNodeResult.node_type === 'http'"
:icon="Link"
:loading="lokiLoading"
@click="openLokiExplore"
>
General Loki 日志
</el-button>
</div>
</template>
<el-alert
v-if="activeNodeResult.node_type === 'http' && lokiLink"
:closable="false"
:type="lokiLink.enabled ? 'success' : 'warning'"
:title="lokiLink.hint"
class="run-detail__loki-alert"
>
<p v-if="lokiLink.logql" class="run-detail__logql">{{ lokiLink.logql }}</p>
<p v-if="lokiLink.method || lokiLink.url" class="run-detail__logql">{{ lokiLink.method }} {{ lokiLink.url }}</p>
</el-alert>
<el-collapse>
<el-collapse-item v-if="activeNodeResult.output?.request" title="请求" name="request">
<pre class="run-detail__json">{{ formatJson(activeNodeResult.output.request) }}</pre>
</el-collapse-item>
<el-collapse-item v-if="activeNodeResult.output?.response" title="服务器响应" name="response">
<pre class="run-detail__json">{{ formatJson(activeNodeResult.output.response) }}</pre>
</el-collapse-item>
<el-collapse-item v-if="activeNodeResult.error" title="错误" name="error">
<pre class="run-detail__json run-detail__json--error">{{ activeNodeResult.error }}</pre>
</el-collapse-item>
</el-collapse>
</el-card>
<el-empty v-else description="请选择节点查看结果" />
</el-col>
</el-row>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.batch-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.batch-layout__side-head {
margin-bottom: 12px;
}
.batch-layout__section-title {
margin: 14px 0 8px;
font-size: 12px;
font-weight: 600;
color: #6e7f96;
}
.batch-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.batch-list__item {
width: 100%;
text-align: left;
border: 1px solid #e5ebf4;
border-radius: 12px;
background: #fff;
padding: 10px 12px;
cursor: pointer;
}
.batch-list__item.is-active {
border-color: #9fc0f4;
box-shadow: 0 10px 24px rgba(64, 104, 180, 0.1);
}
.batch-list__item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.batch-list__item span {
color: #7c8ca4;
font-size: 12px;
}
.batch-editor__head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.batch-editor__head p {
margin: 6px 0 0;
color: #7c8ca4;
font-size: 13px;
}
.batch-form {
margin-bottom: 8px;
}
.history-toolbar {
margin-bottom: 12px;
}
.history-toolbar__filter {
width: 280px;
}
.run-detail__head {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.run-detail__head p {
margin: 6px 0 0;
color: #7c8ca4;
font-size: 13px;
}
.run-detail__node-card :deep(.el-menu-item) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.run-detail__json {
margin: 0;
padding: 12px;
border-radius: 10px;
background: #f6f8fc;
border: 1px solid #e4ebf5;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
overflow: auto;
}
.run-detail__json--error {
color: #c45656;
background: #fff5f5;
border-color: #fbc4c4;
}
.run-detail__logql {
margin: 6px 0 0;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
}
.text-muted {
color: #9aa8bd;
font-size: 12px;
}
@media (max-width: 1100px) {
.batch-layout {
grid-template-columns: 1fr;
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
host: "0.0.0.0",
port: 5173,
proxy: {
"^/api(?:/|$)": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/docs(?:/|$)": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/openapi\\.json$": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/mcp(?:/|$)": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
"^/ws(?:/|$)": {
target: "ws://127.0.0.1:8000",
ws: true,
changeOrigin: true,
},
},
},
});