初始化仓库:AI 接口自动化测试平台
纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -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>
|
||||
Generated
+1974
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user