- 新增环境配置(EnvironmentConfig)的增删改查和激活功能 - API 接口支持 description、request_params、response_params 字段 - 接口管理页面支持抽屉内编辑(替代跳转编辑页) - 新增版本管理页面和工作流列表页面 - catalog_snapshot 返回 environments 数据 Co-authored-by: Cursor <cursoragent@cursor.com>
521 lines
19 KiB
Vue
521 lines
19 KiB
Vue
<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>
|