quality-inspection-platform/frontend-admin/src/views/ApiManagementView.vue
qihongkun 3ca520e398 feat: 新增环境配置管理和接口编辑增强
- 新增环境配置(EnvironmentConfig)的增删改查和激活功能
- API 接口支持 description、request_params、response_params 字段
- 接口管理页面支持抽屉内编辑(替代跳转编辑页)
- 新增版本管理页面和工作流列表页面
- catalog_snapshot 返回 environments 数据

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 15:44:21 +08:00

521 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 { apiDelete, apiGet, apiPost, apiPut } 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 loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const drawerMode = ref("create");
const editingId = ref(null);
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: "",
description: "",
timeout_seconds: 10,
body_text: "{}",
query_text: "{}",
});
const headerRows = ref([{ key: "", value: "" }]);
const pathParamRows = ref([{ key: "", value: "" }]);
const requestParams = ref([]);
const responseParams = ref([]);
const paramTypeOptions = ["string", "integer", "number", "boolean", "object", "array"];
function addRequestParam() {
requestParams.value.push({ name: "", type: "string", required: false, description: "" });
}
function removeRequestParam(index) {
requestParams.value.splice(index, 1);
}
function addResponseParam() {
responseParams.value.push({ name: "", type: "string", required: false, description: "" });
}
function removeResponseParam(index) {
responseParams.value.splice(index, 1);
}
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.description = "";
formState.timeout_seconds = 10;
formState.body_text = "{}";
formState.query_text = "{}";
headerRows.value = [{ key: "", value: "" }];
pathParamRows.value = [{ key: "", value: "" }];
requestParams.value = [];
responseParams.value = [];
}
function openCreateDrawer() {
drawerMode.value = "create";
editingId.value = null;
resetForm();
drawerVisible.value = true;
}
function openCreateDrawerFromFolder(folderPath) {
drawerMode.value = "create";
editingId.value = null;
resetForm();
formState.folder_path = folderPath || "";
drawerVisible.value = true;
}
function openEditDrawer(row) {
drawerMode.value = "edit";
editingId.value = row.id;
formState.name = row.name || "";
formState.folder_path = row.folder_path || "";
formState.method = row.method || "GET";
formState.url = row.url || "";
formState.description = row.description || "";
formState.timeout_seconds = row.timeout_seconds || 10;
formState.body_text = JSON.stringify(row.body || {}, null, 2);
formState.query_text = JSON.stringify(row.query || {}, null, 2);
headerRows.value = normalizeKeyValueRows(row.headers || {});
pathParamRows.value = normalizeKeyValueRows(row.path_params || {});
requestParams.value = (row.request_params || []).map((p) => ({ ...p }));
responseParams.value = (row.response_params || []).map((p) => ({ ...p }));
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 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(),
description: formState.description.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"),
request_params: requestParams.value.filter((p) => p.name.trim()),
response_params: responseParams.value.filter((p) => p.name.trim()),
};
}
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 {
if (drawerMode.value === "edit" && editingId.value) {
await apiPut(`/api/apis/${editingId.value}`, payload);
ElMessage.success("接口已更新");
} else {
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="openEditDrawer(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="drawerMode === 'edit' ? '编辑接口' : '新建接口'"
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
v-model="formState.description"
type="textarea"
:rows="3"
placeholder="描述接口用途、注意事项等…"
/>
</el-form-item>
<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-divider content-position="left">入参结构</el-divider>
<div class="param-struct-section">
<el-table :data="requestParams" border size="small" class="param-struct-table">
<el-table-column label="字段名" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="字段名" size="small" />
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-select v-model="row.type" size="small">
<el-option v-for="t in paramTypeOptions" :key="t" :label="t" :value="t" />
</el-select>
</template>
</el-table-column>
<el-table-column label="必填" width="70" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.required" />
</template>
</el-table-column>
<el-table-column label="字段说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.description" placeholder="字段说明" size="small" />
</template>
</el-table-column>
<el-table-column label="" width="60" align="center">
<template #default="{ $index }">
<el-button text type="danger" size="small" @click="removeRequestParam($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" @click="addRequestParam" style="margin-top: 8px">+ 添加入参字段</el-button>
</div>
<el-divider content-position="left">出参结构</el-divider>
<div class="param-struct-section">
<el-table :data="responseParams" border size="small" class="param-struct-table">
<el-table-column label="字段名" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="字段名" size="small" />
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-select v-model="row.type" size="small">
<el-option v-for="t in paramTypeOptions" :key="t" :label="t" :value="t" />
</el-select>
</template>
</el-table-column>
<el-table-column label="必填" width="70" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.required" />
</template>
</el-table-column>
<el-table-column label="字段说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.description" placeholder="字段说明" size="small" />
</template>
</el-table-column>
<el-table-column label="" width="60" align="center">
<template #default="{ $index }">
<el-button text type="danger" size="small" @click="removeResponseParam($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button size="small" @click="addResponseParam" style="margin-top: 8px">+ 添加出参字段</el-button>
</div>
</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>