Procházet zdrojové kódy

feat:增加apihub模块页面。

microrain před 5 měsíci
rodič
revize
f2ed47ef00

+ 603 - 0
src/views/apihub/apilist.vue

@@ -0,0 +1,603 @@
+<template>
+	<div class="page">
+		<div class="apihub-container">
+			<!-- 左侧分组树 -->
+			<div class="apihub-sidebar">
+				<el-card shadow="never" class="group-card">
+					<template #header>
+						<div class="card-header">
+							<span>API分组</span>
+							<div class="header-actions">
+								<el-button type="primary" size="small" @click="addGroup" v-auth="'add_group'">
+									<el-icon><ele-Plus /></el-icon>
+								</el-button>
+								<el-button type="primary" size="small" @click="refreshGroups">
+									<el-icon><ele-Refresh /></el-icon>
+								</el-button>
+							</div>
+						</div>
+					</template>
+					<div class="group-search">
+						<el-input
+							v-model="groupSearchKey"
+							placeholder="搜索分组"
+							clearable
+							prefix-icon="ele-Search"
+							@input="filterGroups"
+						/>
+					</div>
+					<div class="group-tree-container">
+						<el-tree
+							ref="groupTreeRef"
+							:data="groupTreeData"
+							:props="{ label: 'Name', children: 'Children' }"
+							node-key="GroupKey"
+							highlight-current
+							:expand-on-click-node="false"
+							@node-click="handleGroupClick"
+						>
+							<template #default="{ node, data }">
+								<div class="custom-tree-node">
+									<span>{{ node.label }}</span>
+									<span class="api-count" v-if="data.ApiCount">{{ data.ApiCount }}</span>
+									<div class="node-actions">
+										<el-dropdown @command="(command) => handleGroupCommand(command, data)" trigger="click">
+											<el-icon><ele-More /></el-icon>
+											<template #dropdown>
+												<el-dropdown-menu>
+													<el-dropdown-item command="edit" v-auth="'edit_group'">编辑</el-dropdown-item>
+													<el-dropdown-item command="add_child" v-auth="'add_group'">添加子分组</el-dropdown-item>
+													<el-dropdown-item command="delete" v-auth="'delete_group'">删除</el-dropdown-item>
+												</el-dropdown-menu>
+											</template>
+										</el-dropdown>
+									</div>
+								</div>
+							</template>
+						</el-tree>
+					</div>
+				</el-card>
+			</div>
+
+			<!-- 右侧API列表 -->
+			<div class="apihub-content">
+				<el-card shadow="never">
+					<div class="api-header">
+						<div class="current-group" v-if="currentGroup.name">
+							当前分组: <span class="group-name">{{ currentGroup.name }}</span>
+						</div>
+						<div class="current-group" v-else>
+							全部API
+						</div>
+					</div>
+					<el-form :model="params" inline ref="queryRef">
+				<el-form-item label="API名称" prop="keyWord">
+					<el-input v-model="params.keyWord" placeholder="请输入API名称" clearable style="width: 180px" @keyup.enter.native="getList(1)" />
+				</el-form-item>
+				<el-form-item label="数据源" prop="dataSourceId">
+					<el-select v-model="params.dataSourceId" placeholder="请选择数据源" clearable style="width: 180px">
+						<el-option v-for="item in dataSources" :key="item.id" :label="item.name" :value="item.id" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="状态" prop="status">
+					<el-select v-model="params.status" placeholder="请选择状态" clearable style="width: 120px">
+						<el-option label="全部" value="" />
+						<el-option label="草稿" value="Draft" />
+						<el-option label="已发布" value="Published" />
+						<el-option label="已废弃" value="Deprecated" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="日期范围" prop="dateRange">
+					<el-date-picker
+						v-model="params.dateRange"
+						type="daterange"
+						range-separator="至"
+						start-placeholder="开始日期"
+						end-placeholder="结束日期"
+						value-format="YYYY-MM-DD"
+						style="width: 240px"
+					/>
+				</el-form-item>
+				<el-form-item>
+					<el-button type="primary" class="ml10" @click="getList(1)">
+						<el-icon>
+							<ele-Search />
+						</el-icon>
+						查询
+					</el-button>
+					<el-button @click="resetQuery()">
+						<el-icon>
+							<ele-Refresh />
+						</el-icon>
+						重置
+					</el-button>
+					<el-button type="primary" @click="addOrEdit()" v-auth="'add'">
+						<el-icon>
+							<ele-FolderAdd />
+						</el-icon>
+						新增API
+					</el-button>
+				</el-form-item>
+			</el-form>
+			<el-table :data="tableData" style="width: 100%" v-loading="loading" row-key="id">
+				<el-table-column type="selection" width="55" align="center" />
+				<el-table-column prop="id" label="ID" width="80" align="center" />
+				<el-table-column prop="name" label="API名称" min-width="120" show-overflow-tooltip></el-table-column>
+				<el-table-column prop="path" label="API路径" min-width="150" show-overflow-tooltip></el-table-column>
+				<el-table-column prop="method" label="请求方法" width="100" align="center">
+					<template #default="scope">
+						<el-tag
+							:type="getMethodTagType(scope.row.method)"
+							size="small"
+						>
+							{{ scope.row.method }}
+						</el-tag>
+					</template>
+				</el-table-column>
+				<el-table-column prop="dataSourceName" label="数据源" width="120" show-overflow-tooltip></el-table-column>
+				<el-table-column prop="sqlType" label="SQL类型" width="100" align="center">
+					<template #default="scope">
+						<el-tag size="small" type="info" v-if="scope.row.sqlType === 'query'">查询</el-tag>
+						<el-tag size="small" type="warning" v-else-if="scope.row.sqlType === 'procedure'">存储过程</el-tag>
+						<span v-else>{{ scope.row.sqlType }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column prop="version" label="版本" width="80" align="center"></el-table-column>
+				<el-table-column prop="status" label="状态" width="100" align="center">
+					<template #default="scope">
+						<el-tag size="small" v-if="scope.row.status === 'Draft'">草稿</el-tag>
+						<el-tag size="small" type="success" v-else-if="scope.row.status === 'Published'">已发布</el-tag>
+						<el-tag size="small" type="info" v-else-if="scope.row.status === 'Deprecated'">已废弃</el-tag>
+						<span v-else>{{ scope.row.status }}</span>
+					</template>
+				</el-table-column>
+				<el-table-column prop="createdAt" label="创建时间" width="160" align="center"></el-table-column>
+				<el-table-column label="操作" width="220" align="center">
+					<template #default="scope">
+						<el-button size="small" text type="primary" @click="viewDetail(scope.row)" v-auth="'view'">查看</el-button>
+						<el-button size="small" text type="warning" @click="addOrEdit(scope.row)" v-auth="'edit'">编辑</el-button>
+						<el-button size="small" text type="success" @click="testApi(scope.row)" v-auth="'test'">测试</el-button>
+						<el-dropdown @command="(command) => handleCommand(command, scope.row)">
+							<el-button size="small" text type="primary">
+								更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
+							</el-button>
+							<template #dropdown>
+								<el-dropdown-menu>
+									<el-dropdown-item command="publish" v-if="scope.row.status === 'Draft'" v-auth="'publish'">发布</el-dropdown-item>
+									<el-dropdown-item command="deprecate" v-if="scope.row.status === 'Published'" v-auth="'deprecate'">废弃</el-dropdown-item>
+									<el-dropdown-item command="delete" v-auth="'delete'">删除</el-dropdown-item>
+								</el-dropdown-menu>
+							</template>
+						</el-dropdown>
+					</template>
+				</el-table-column>
+			</el-table>
+					<pagination v-if="params.total" :total="params.total" v-model:page="params.pageNum" v-model:limit="params.pageSize" @pagination="getList()" />
+				</el-card>
+			</div>
+		</div>
+
+		<!-- 组件 -->
+		<EditForm ref="editFormRef" @getList="getList(1)"></EditForm>
+		<ViewDetail ref="viewDetailRef"></ViewDetail>
+		<TestApi ref="testApiRef"></TestApi>
+		<GroupForm ref="groupFormRef" @refresh="refreshGroups"></GroupForm>
+	</div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, reactive } from 'vue'
+import EditForm from './component/edit.vue'
+import ViewDetail from './component/view.vue'
+import TestApi from './component/test.vue'
+import GroupForm from './component/group.vue'
+import { useSearch } from '/@/hooks/useCommon'
+import { ElMessageBox, ElMessage } from 'element-plus'
+import { ArrowDown } from '@element-plus/icons-vue'
+import request from '/@/utils/request'
+
+// 定义API接口类型
+interface ApiDefinition {
+	id?: number
+	name: string
+	path: string
+	method: string
+	dataSourceId: number
+	dataSourceName?: string
+	sqlType: string
+	sqlContent: string
+	parameters?: any[]
+	returnFormat: string
+	version: string
+	status: string
+	plugins?: any[]
+	description?: string
+	createdAt?: string
+	updatedAt?: string
+}
+
+// 引用组件
+const editFormRef = ref()
+const viewDetailRef = ref()
+const testApiRef = ref()
+const queryRef = ref()
+const groupFormRef = ref()
+const groupTreeRef = ref()
+
+// 分组相关状态
+const groupSearchKey = ref('')
+const groupTreeData = ref([])
+const currentGroup = reactive({
+	Id: undefined,
+	GroupKey: '',
+	Name: '',
+	ParentId: 0,
+	Description: ''
+})
+const originalGroupTree = ref([])
+
+// 数据源列表
+const dataSources = ref([])
+
+// API列表请求函数
+const apiRequest = (params: any) => {
+	// 这里使用request函数发起请求
+	// 实际使用时替换为真实API路径
+	return request({
+		url: '/api/list',
+		method: 'get',
+		params
+	})
+}
+
+// 获取分组树形结构
+const getGroupTree = () => {
+	return request({
+		url: '/api_group/tree',
+		method: 'get'
+	})
+}
+
+// 删除分组
+const deleteGroup = (ids: number[]) => {
+	return request({
+		url: '/api_group/delete',
+		method: 'post',
+		data: { ids }
+	})
+}
+
+// 使用通用搜索钩子
+const { params, tableData, getList, loading } = useSearch<ApiDefinition[]>(
+	apiRequest,
+	'data',
+	{
+		keyWord: '',
+		dataSourceId: '',
+		status: '',
+		dateRange: [],
+		orderBy: '',
+		pageNum: 1,
+		pageSize: 10
+	}
+)
+
+// 页面加载时获取列表数据
+onMounted(() => {
+	getList()
+	// 获取数据源列表 - 实际使用时替换为真实API
+	// 模拟数据
+	dataSources.value = [
+		{ id: 1, name: '主数据库' },
+		{ id: 2, name: '业务数据库' },
+		{ id: 3, name: '日志数据库' }
+	]
+
+	// 加载分组树
+	refreshGroups()
+})
+
+// 刷新分组树
+const refreshGroups = async () => {
+	try {
+		// 调用API获取分组树
+		const res = await getGroupTree()
+		console.log('获取到的API分组数据:', res)
+
+		// 使用API返回的数据
+		if (res.data && res.data.list) {
+			console.log('分组数据列表:', res.data.list)
+			groupTreeData.value = res.data.list || []
+			originalGroupTree.value = JSON.parse(JSON.stringify(res.data.list || []))
+			console.log('处理后的分组数据:', groupTreeData.value)
+			return
+		}
+
+		// 如果没有数据,初始化为空数组
+		console.log('没有获取到分组数据')
+		groupTreeData.value = []
+		originalGroupTree.value = []
+	} catch (error) {
+		console.error('获取分组数据失败:', error)
+		ElMessage.error('获取分组数据失败')
+	}
+}
+
+// 搜索过滤分组
+const filterGroups = () => {
+	if (!groupSearchKey.value) {
+		// 如果搜索关键字为空,恢复原始数据
+		groupTreeData.value = JSON.parse(JSON.stringify(originalGroupTree.value))
+		return
+	}
+
+	// 递归搜索函数
+	const searchTree = (nodes) => {
+		return nodes.filter(node => {
+			// 当前节点名称匹配
+			const matchesName = node.Name.toLowerCase().includes(groupSearchKey.value.toLowerCase())
+
+			// 递归搜索子节点
+			if (node.Children && node.Children.length) {
+				node.Children = searchTree(node.Children)
+				// 如果子节点有匹配项,则保留父节点
+				return matchesName || node.Children.length > 0
+			}
+
+			return matchesName
+		})
+	}
+
+	groupTreeData.value = searchTree(JSON.parse(JSON.stringify(originalGroupTree.value)))
+}
+
+// 点击分组节点
+const handleGroupClick = (data) => {
+	// 设置当前选中分组
+	Object.assign(currentGroup, data)
+
+	// 更新查询参数,加入分组条件
+	params.groupKey = data.GroupKey
+
+	// 重新获取列表
+	getList(1)
+}
+
+// 添加分组
+const addGroup = () => {
+	groupFormRef.value.open()
+}
+
+// 处理分组操作
+const handleGroupCommand = (command, data) => {
+	switch (command) {
+		case 'edit':
+			groupFormRef.value.open(data)
+			break
+		case 'add_child':
+			groupFormRef.value.open(null, data.GroupKey)
+			break
+		case 'delete':
+			deleteGroupConfirm(data)
+			break
+	}
+}
+
+// 删除分组确认
+const deleteGroupConfirm = (data) => {
+	ElMessageBox.confirm(`确定要删除分组「${data.Name}」吗?如果包含子分组或API,将一并删除。`, '警告', {
+		confirmButtonText: '确定',
+		cancelButtonText: '取消',
+		type: 'warning'
+	}).then(async () => {
+		try {
+			// 实际使用时调用API
+			await deleteGroup([data.Id])
+			ElMessage.success('删除成功')
+
+			// 如果当前选中的是要删除的分组,则清空当前分组
+			if (currentGroup.GroupKey === data.GroupKey) {
+				Object.assign(currentGroup, {
+					Id: undefined,
+					GroupKey: '',
+					Name: '',
+					ParentId: 0,
+					Description: ''
+				})
+				params.groupKey = ''
+				getList(1)
+			}
+
+			// 刷新分组树
+			refreshGroups()
+		} catch (error) {
+			ElMessage.error('删除失败')
+		}
+	}).catch(() => {})
+}
+
+// 根据请求方法返回不同的标签类型
+const getMethodTagType = (method: string) => {
+	switch (method.toUpperCase()) {
+		case 'GET':
+			return 'success'
+		case 'POST':
+			return 'primary'
+		case 'PUT':
+			return 'warning'
+		case 'DELETE':
+			return 'danger'
+		default:
+			return 'info'
+	}
+}
+
+// 重置查询表单
+const resetQuery = () => {
+	queryRef.value.resetFields()
+	getList(1)
+}
+
+// 新增或编辑API
+const addOrEdit = (row?: ApiDefinition) => {
+	if (row) {
+		// 编辑现有API
+		// 实际使用时,可能需要先获取详情
+		editFormRef.value.open(row)
+	} else {
+		// 新增API,如果有选中分组,则传递分组标识
+		editFormRef.value.open(null, currentGroup.groupKey)
+	}
+}
+
+// 查看API详情
+const viewDetail = (row: ApiDefinition) => {
+	viewDetailRef.value.open(row)
+}
+
+// 测试API
+const testApi = (row: ApiDefinition) => {
+	testApiRef.value.open(row)
+}
+
+// 处理下拉菜单命令
+const handleCommand = (command: string, row: ApiDefinition) => {
+	switch (command) {
+		case 'publish':
+			publishApi(row)
+			break
+		case 'deprecate':
+			deprecateApi(row)
+			break
+		case 'delete':
+			deleteApi(row)
+			break
+	}
+}
+
+// 发布API
+const publishApi = (row: ApiDefinition) => {
+	ElMessageBox.confirm(`确定要发布API「${row.name}」吗?`, '提示', {
+		confirmButtonText: '确定',
+		cancelButtonText: '取消',
+		type: 'warning'
+	}).then(async () => {
+		// 实际使用时替换为真实API调用
+		// await api.apihub.publish({ id: row.id })
+		ElMessage.success('发布成功')
+		getList()
+	}).catch(() => {})
+}
+
+// 废弃API
+const deprecateApi = (row: ApiDefinition) => {
+	ElMessageBox.confirm(`确定要废弃API「${row.name}」吗?`, '提示', {
+		confirmButtonText: '确定',
+		cancelButtonText: '取消',
+		type: 'warning'
+	}).then(async () => {
+		// 实际使用时替换为真实API调用
+		// await api.apihub.deprecate({ id: row.id })
+		ElMessage.success('废弃成功')
+		getList()
+	}).catch(() => {})
+}
+
+// 删除API
+const deleteApi = (row: ApiDefinition) => {
+	ElMessageBox.confirm(`确定要删除API「${row.name}」吗?此操作不可恢复!`, '警告', {
+		confirmButtonText: '确定',
+		cancelButtonText: '取消',
+		type: 'error'
+	}).then(async () => {
+		// 实际使用时替换为真实API调用
+		// await api.apihub.delete({ ids: [row.id] })
+		ElMessage.success('删除成功')
+		getList()
+	}).catch(() => {})
+}
+</script>
+
+<style scoped>
+.ml10 {
+	margin-left: 10px;
+}
+
+.apihub-container {
+	display: flex;
+	height: calc(100vh - 160px);
+}
+
+.apihub-sidebar {
+	width: 280px;
+	margin-right: 16px;
+	overflow: hidden;
+}
+
+.apihub-content {
+	flex: 1;
+	overflow: hidden;
+}
+
+.group-card {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+}
+
+.card-header {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+}
+
+.header-actions {
+	display: flex;
+	gap: 8px;
+}
+
+.group-search {
+	margin-bottom: 12px;
+}
+
+.group-tree-container {
+	overflow-y: auto;
+	flex: 1;
+}
+
+.custom-tree-node {
+	flex: 1;
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	font-size: 14px;
+	padding-right: 8px;
+}
+
+.api-count {
+	padding: 0 6px;
+	background-color: #f0f0f0;
+	border-radius: 10px;
+	font-size: 12px;
+	color: #606266;
+}
+
+.node-actions {
+	margin-left: 8px;
+	visibility: hidden;
+}
+
+.custom-tree-node:hover .node-actions {
+	visibility: visible;
+}
+
+.api-header {
+	margin-bottom: 16px;
+	font-size: 16px;
+}
+
+.group-name {
+	font-weight: bold;
+	color: #409EFF;
+}
+</style>

+ 340 - 0
src/views/apihub/component/edit.vue

@@ -0,0 +1,340 @@
+<template>
+	<el-dialog
+		class="api-edit"
+		v-model="showDialog"
+		:title="`${formData.id ? '编辑API' : '新增API'}`"
+		width="800px"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+	>
+		<el-form ref="formRef" :model="formData" :rules="ruleForm" label-width="100px" @keyup.enter="onSubmit">
+			<el-form-item label="API名称" prop="name">
+				<el-input v-model="formData.name" placeholder="请输入API名称" />
+			</el-form-item>
+			<el-form-item label="API路径" prop="path">
+				<el-input v-model="formData.path" placeholder="请输入API路径,如/api/v1/users" />
+			</el-form-item>
+			<el-form-item label="请求方法" prop="method">
+				<el-select v-model="formData.method" placeholder="请选择请求方法">
+					<el-option label="GET" value="GET"></el-option>
+					<el-option label="POST" value="POST"></el-option>
+					<el-option label="PUT" value="PUT"></el-option>
+					<el-option label="DELETE" value="DELETE"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item label="数据源" prop="dataSourceId">
+				<el-select v-model="formData.dataSourceId" placeholder="请选择数据源" filterable>
+					<el-option v-for="item in dataSources" :key="item.id" :label="item.name" :value="item.id"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item label="SQL类型" prop="sqlType">
+				<el-radio-group v-model="formData.sqlType">
+					<el-radio label="query">查询</el-radio>
+					<el-radio label="procedure">存储过程</el-radio>
+				</el-radio-group>
+			</el-form-item>
+			<el-form-item label="所属分组" prop="groupKey">
+				<el-cascader
+					v-model="formData.groupKey"
+					:options="groupOptions"
+					:props="{ checkStrictly: true, emitPath: false, value: 'groupKey', label: 'name' }"
+					placeholder="请选择所属分组"
+					clearable
+					style="width: 100%"
+				/>
+			</el-form-item>
+			<el-form-item label="SQL内容" prop="sqlContent">
+				<el-input v-model="formData.sqlContent" type="textarea" :rows="4" placeholder="请输入SQL语句或存储过程名称" />
+			</el-form-item>
+			
+			<el-form-item label="参数定义">
+				<el-button type="primary" size="small" @click="addParameter">
+					<el-icon><ele-Plus /></el-icon>添加参数
+				</el-button>
+				<el-table :data="formData.parameters" style="width: 100%; margin-top: 10px;" border>
+					<el-table-column label="参数名" width="150">
+						<template #default="scope">
+							<el-input v-model="scope.row.name" placeholder="参数名"></el-input>
+						</template>
+					</el-table-column>
+					<el-table-column label="类型" width="120">
+						<template #default="scope">
+							<el-select v-model="scope.row.type" placeholder="类型">
+								<el-option label="string" value="string"></el-option>
+								<el-option label="int" value="int"></el-option>
+								<el-option label="float" value="float"></el-option>
+								<el-option label="bool" value="bool"></el-option>
+							</el-select>
+						</template>
+					</el-table-column>
+					<el-table-column label="必填" width="80">
+						<template #default="scope">
+							<el-checkbox v-model="scope.row.required"></el-checkbox>
+						</template>
+					</el-table-column>
+					<el-table-column label="默认值" width="120">
+						<template #default="scope">
+							<el-input v-model="scope.row.defaultValue" placeholder="默认值"></el-input>
+						</template>
+					</el-table-column>
+					<el-table-column label="描述">
+						<template #default="scope">
+							<el-input v-model="scope.row.description" placeholder="参数描述"></el-input>
+						</template>
+					</el-table-column>
+					<el-table-column label="操作" width="80">
+						<template #default="scope">
+							<el-button type="danger" size="small" @click="removeParameter(scope.$index)" text>
+								<el-icon><ele-Delete /></el-icon>
+							</el-button>
+						</template>
+					</el-table-column>
+				</el-table>
+			</el-form-item>
+			
+			<el-form-item label="返回格式" prop="returnFormat">
+				<el-select v-model="formData.returnFormat" placeholder="请选择返回格式">
+					<el-option label="JSON" value="JSON"></el-option>
+					<el-option label="XML" value="XML"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item label="版本" prop="version">
+				<el-input v-model="formData.version" placeholder="请输入版本号,如1.0" />
+			</el-form-item>
+			<el-form-item label="状态" prop="status">
+				<el-select v-model="formData.status" placeholder="请选择状态">
+					<el-option label="草稿" value="Draft"></el-option>
+					<el-option label="已发布" value="Published"></el-option>
+					<el-option label="已废弃" value="Deprecated"></el-option>
+				</el-select>
+			</el-form-item>
+			<el-form-item label="描述" prop="description">
+				<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入API描述" />
+			</el-form-item>
+		</el-form>
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="cancel">取消</el-button>
+				<el-button type="primary" @click="onSubmit">确定</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import { ruleRequired } from '/@/utils/validator'
+import request from '/@/utils/request'
+
+const emit = defineEmits(['getList'])
+
+const showDialog = ref(false)
+const formRef = ref()
+const dataSources = ref([
+	{ id: 1, name: '主数据库' },
+	{ id: 2, name: '业务数据库' },
+	{ id: 3, name: '日志数据库' }
+])
+
+// 分组选项
+const groupOptions = ref([])
+
+// 定义基础表单数据
+const baseForm = {
+	id: undefined,
+	name: '',
+	path: '',
+	method: 'GET',
+	dataSourceId: undefined,
+	sqlType: 'query',
+	sqlContent: '',
+	parameters: [],
+	returnFormat: 'JSON',
+	version: '1.0',
+	status: 'Draft',
+	plugins: [],
+	groupKey: '',
+	description: ''
+}
+
+const formData = reactive(JSON.parse(JSON.stringify(baseForm)))
+
+// 表单验证规则
+const ruleForm = {
+	name: [ruleRequired('API名称不能为空', 'change')],
+	path: [ruleRequired('API路径不能为空', 'change')],
+	method: [ruleRequired('请求方法不能为空', 'change')],
+	dataSourceId: [ruleRequired('数据源不能为空', 'change')],
+	sqlType: [ruleRequired('SQL类型不能为空', 'change')],
+	sqlContent: [ruleRequired('SQL内容不能为空', 'change')],
+	returnFormat: [ruleRequired('返回格式不能为空', 'change')],
+	version: [ruleRequired('版本不能为空', 'change')],
+	status: [ruleRequired('状态不能为空', 'change')]
+}
+
+// 添加参数
+const addParameter = () => {
+	if (!formData.parameters) {
+		formData.parameters = []
+	}
+	formData.parameters.push({
+		name: '',
+		type: 'string',
+		required: false,
+		defaultValue: '',
+		description: ''
+	})
+}
+
+// 移除参数
+const removeParameter = (index) => {
+	formData.parameters.splice(index, 1)
+}
+
+// 添加API
+const addApi = (data) => {
+	return request({
+		url: '/api/add',
+		method: 'post',
+		data
+	})
+}
+
+// 编辑API
+const editApi = (data) => {
+	return request({
+		url: '/api/edit',
+		method: 'put',
+		data
+	})
+}
+
+// 提交表单
+const onSubmit = async () => {
+	try {
+		// 表单验证
+		await formRef.value.validate()
+		
+		// 准备提交数据
+		const submitData = JSON.parse(JSON.stringify(formData))
+		
+		// 调用API
+		if (submitData.id) {
+			// 编辑模式
+			await editApi(submitData)
+		} else {
+			// 新增模式
+			await addApi(submitData)
+		}
+		
+		ElMessage.success('操作成功')
+		resetForm()
+		showDialog.value = false
+		emit('getList')
+	} catch (error) {
+		ElMessage.error(error.message || '操作失败')
+	}
+}
+
+// 重置表单
+const resetForm = () => {
+	Object.assign(formData, JSON.parse(JSON.stringify(baseForm)))
+	formRef.value && formRef.value.resetFields()
+}
+
+// 取消操作
+const cancel = () => {
+	resetForm()
+	showDialog.value = false
+}
+
+// 获取分组树形结构
+const getGroupTree = async () => {
+	try {
+		// 调用API获取分组树
+		const res = await request({
+			url: '/api_group/tree',
+			method: 'get'
+		})
+		return res.data?.list || []
+		
+		// 模拟数据
+		return [
+			{
+				id: 1,
+				groupKey: 'test_group',
+				name: '测试分组',
+				parentKey: '',
+				sort: 0,
+				description: '这是一个测试分组',
+				children: [
+					{
+						id: 3,
+						groupKey: 'test_subgroup',
+						name: '测试子分组',
+						parentKey: 'test_group',
+						sort: 0,
+						description: '这是一个测试子分组',
+						children: []
+					}
+				]
+			},
+			{
+				id: 2,
+				groupKey: 'user_api',
+				name: '用户API',
+				parentKey: '',
+				sort: 1,
+				description: '用户相关API',
+				children: []
+			}
+		]
+	} catch (error) {
+		ElMessage.error('获取分组树失败')
+		return []
+	}
+}
+
+// 加载分组选项
+const loadGroupOptions = async () => {
+	const treeData = await getGroupTree()
+	groupOptions.value = treeData
+}
+
+// 打开对话框
+const open = async (row?: any, defaultGroupKey?: string) => {
+	resetForm()
+	showDialog.value = true
+	
+	// 加载分组选项
+	await loadGroupOptions()
+	
+	if (row) {
+		// 编辑模式,填充表单数据
+		nextTick(() => {
+			// 如果是编辑模式,需要先获取详情
+			// 实际使用时,可能需要先调用API获取完整数据
+			// 这里模拟直接使用传入的行数据
+			Object.assign(formData, JSON.parse(JSON.stringify(row)))
+			
+			// 确保参数数组存在
+			if (!formData.parameters) {
+				formData.parameters = []
+			}
+		})
+	} else if (defaultGroupKey) {
+		// 新增模式,设置默认分组
+		formData.groupKey = defaultGroupKey
+	}
+}
+
+defineExpose({ open })
+</script>
+
+<style scoped>
+.dialog-footer {
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 205 - 0
src/views/apihub/component/group.vue

@@ -0,0 +1,205 @@
+<template>
+	<el-dialog
+		class="group-edit"
+		v-model="showDialog"
+		:title="`${formData.id ? '编辑分组' : '新增分组'}`"
+		width="500px"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+	>
+		<el-form ref="formRef" :model="formData" :rules="ruleForm" label-width="100px" @keyup.enter="onSubmit">
+			<el-form-item label="分组名称" prop="name">
+				<el-input v-model="formData.name" placeholder="请输入分组名称" />
+			</el-form-item>
+			<el-form-item label="分组标识" prop="groupKey">
+				<el-input v-model="formData.groupKey" placeholder="请输入分组唯一标识,留空则自动生成" />
+			</el-form-item>
+			<el-form-item label="父级分组" prop="parentKey">
+				<el-cascader
+					v-model="formData.parentKey"
+					:options="groupOptions"
+					:props="{ checkStrictly: true, emitPath: false, value: 'groupKey', label: 'name' }"
+					placeholder="请选择父级分组,不选择则为顶级分组"
+					clearable
+					style="width: 100%"
+				/>
+			</el-form-item>
+			<el-form-item label="排序号" prop="sort">
+				<el-input-number v-model="formData.sort" :min="0" :max="999" />
+			</el-form-item>
+			<el-form-item label="分组描述" prop="description">
+				<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入分组描述" />
+			</el-form-item>
+		</el-form>
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="cancel">取消</el-button>
+				<el-button type="primary" @click="onSubmit">确定</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import { ruleRequired } from '/@/utils/validator'
+import request from '/@/utils/request'
+
+const emit = defineEmits(['refresh'])
+
+const showDialog = ref(false)
+const formRef = ref()
+const groupOptions = ref([])
+
+// 定义基础表单数据
+const baseForm = {
+	id: undefined,
+	groupKey: '',
+	name: '',
+	parentKey: '',
+	sort: 0,
+	description: ''
+}
+
+const formData = reactive({...baseForm})
+
+// 表单验证规则
+const ruleForm = {
+	name: [ruleRequired('分组名称不能为空', 'change')]
+}
+
+// 获取分组树形结构
+const getGroupTree = async () => {
+	try {
+		// 调用API获取分组树
+		const res = await request({
+			url: '/api_group/tree',
+			method: 'get'
+		})
+		return res.data?.list || []
+	} catch (error) {
+		ElMessage.error('获取分组树失败')
+		return []
+	}
+}
+
+// 添加分组
+const addGroup = async (data) => {
+	return request({
+		url: '/api/v1/api_group/add',
+		method: 'post',
+		data
+	})
+}
+
+// 编辑分组
+const editGroup = async (data) => {
+	return request({
+		url: '/api/v1/api_group/edit',
+		method: 'post',
+		data
+	})
+}
+
+// 提交表单
+const onSubmit = async () => {
+	try {
+		// 表单验证
+		await formRef.value.validate()
+
+		// 准备提交数据
+		const submitData = JSON.parse(JSON.stringify(formData))
+
+		// 如果没有设置groupKey,生成一个基于名称的唯一标识
+		if (!submitData.groupKey) {
+			submitData.groupKey = 'group_' + Date.now() + '_' + Math.floor(Math.random() * 1000)
+		}
+
+		// 处理空的parent_key,将空字符串设置为null
+		if (submitData.parentKey === '') {
+			submitData.parentKey = null
+		}
+
+		// 调用API
+		if (submitData.id) {
+			// 编辑模式
+			await editGroup(submitData)
+		} else {
+			// 新增模式
+			await addGroup(submitData)
+		}
+
+		ElMessage.success('操作成功')
+		resetForm()
+		showDialog.value = false
+		emit('refresh')
+	} catch (error) {
+		ElMessage.error(error.message || '操作失败')
+	}
+}
+
+// 重置表单
+const resetForm = () => {
+	Object.assign(formData, {...baseForm})
+	formRef.value && formRef.value.resetFields()
+}
+
+// 取消操作
+const cancel = () => {
+	resetForm()
+	showDialog.value = false
+}
+
+// 加载分组选项
+const loadGroupOptions = async (excludeId) => {
+	const treeData = await getGroupTree()
+
+	// 如果是编辑模式,需要排除当前分组及其子分组
+	if (excludeId) {
+		const filterTree = (nodes) => {
+			return nodes.filter(node => {
+				if (node.id === excludeId) {
+					return false
+				}
+				if (node.children && node.children.length) {
+					node.children = filterTree(node.children)
+				}
+				return true
+			})
+		}
+
+		groupOptions.value = filterTree(treeData)
+	} else {
+		groupOptions.value = treeData
+	}
+}
+
+// 打开对话框
+const open = async (row?: any, parentKey?: string) => {
+	resetForm()
+	showDialog.value = true
+
+	// 加载分组选项
+	await loadGroupOptions(row?.id)
+
+	if (row) {
+		// 编辑模式,填充表单数据
+		nextTick(() => {
+			Object.assign(formData, JSON.parse(JSON.stringify(row)))
+		})
+	} else if (parentKey) {
+		// 新增子分组模式
+		formData.parentKey = parentKey
+	}
+}
+
+defineExpose({ open })
+</script>
+
+<style scoped>
+.dialog-footer {
+	display: flex;
+	justify-content: flex-end;
+}
+</style>

+ 318 - 0
src/views/apihub/component/test.vue

@@ -0,0 +1,318 @@
+<template>
+	<el-dialog
+		class="api-test"
+		v-model="showDialog"
+		title="测试API"
+		width="800px"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+	>
+		<el-descriptions :column="2" border>
+			<el-descriptions-item label="API名称" :span="2">{{ apiData.name }}</el-descriptions-item>
+			<el-descriptions-item label="API路径" :span="2">{{ apiData.path }}</el-descriptions-item>
+			<el-descriptions-item label="请求方法">
+				<el-tag :type="getMethodTagType(apiData.method)" size="small">{{ apiData.method }}</el-tag>
+			</el-descriptions-item>
+			<el-descriptions-item label="数据源">{{ apiData.dataSourceName }}</el-descriptions-item>
+		</el-descriptions>
+		
+		<div class="section-title">参数设置</div>
+		<el-form :model="testParams" label-width="120px" v-if="apiData.parameters && apiData.parameters.length">
+			<el-form-item 
+				v-for="param in apiData.parameters" 
+				:key="param.name" 
+				:label="param.name" 
+				:required="param.required"
+			>
+				<el-input 
+					v-if="param.type === 'string'" 
+					v-model="testParams[param.name]" 
+					:placeholder="getParamPlaceholder(param)"
+				></el-input>
+				<el-input-number 
+					v-else-if="param.type === 'int' || param.type === 'float'" 
+					v-model="testParams[param.name]" 
+					:placeholder="getParamPlaceholder(param)"
+				></el-input-number>
+				<el-switch 
+					v-else-if="param.type === 'bool'" 
+					v-model="testParams[param.name]" 
+					:active-value="true" 
+					:inactive-value="false"
+				></el-switch>
+				<el-input 
+					v-else 
+					v-model="testParams[param.name]" 
+					:placeholder="getParamPlaceholder(param)"
+				></el-input>
+				<div class="param-desc" v-if="param.description">{{ param.description }}</div>
+			</el-form-item>
+		</el-form>
+		<el-empty description="该API没有定义参数" v-else></el-empty>
+		
+		<div class="section-title">测试结果</div>
+		<div v-if="!testResult.success && !loading" class="test-placeholder">点击下方"执行测试"按钮开始测试</div>
+		<div v-else>
+			<el-alert
+				:title="testResult.message"
+				:type="testResult.success ? 'success' : 'error'"
+				:closable="false"
+				show-icon
+			></el-alert>
+			
+			<div v-if="testResult.success && testResult.data" class="result-container">
+				<el-tabs v-model="activeTab">
+					<el-tab-pane label="结果数据" name="data">
+						<pre class="result-json">{{ formatJson(testResult.data) }}</pre>
+					</el-tab-pane>
+					<el-tab-pane label="原始响应" name="raw">
+						<pre class="result-json">{{ formatJson(testResult) }}</pre>
+					</el-tab-pane>
+				</el-tabs>
+			</div>
+		</div>
+		
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="closeDialog">关闭</el-button>
+				<el-button type="primary" @click="runTest" :loading="loading">执行测试</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, watch } from 'vue'
+
+const showDialog = ref(false)
+const loading = ref(false)
+const activeTab = ref('data')
+
+// API数据
+const apiData = reactive({
+	id: undefined,
+	name: '',
+	path: '',
+	method: '',
+	dataSourceId: undefined,
+	dataSourceName: '',
+	sqlType: '',
+	sqlContent: '',
+	parameters: [],
+	returnFormat: '',
+	version: '',
+	status: '',
+	plugins: [],
+	description: '',
+	createdAt: '',
+	updatedAt: ''
+})
+
+// 测试参数
+const testParams = reactive({})
+
+// 测试结果
+const testResult = reactive({
+	success: false,
+	message: '',
+	data: null
+})
+
+// 监听API数据变化,初始化测试参数
+watch(() => apiData.parameters, (newParams) => {
+	// 清空测试参数
+	Object.keys(testParams).forEach(key => {
+		delete testParams[key]
+	})
+	
+	// 根据参数定义初始化测试参数
+	if (newParams && newParams.length) {
+		newParams.forEach(param => {
+			// 如果有默认值,使用默认值
+			if (param.defaultValue !== undefined && param.defaultValue !== '') {
+				// 根据类型转换默认值
+				if (param.type === 'int') {
+					testParams[param.name] = parseInt(param.defaultValue)
+				} else if (param.type === 'float') {
+					testParams[param.name] = parseFloat(param.defaultValue)
+				} else if (param.type === 'bool') {
+					testParams[param.name] = param.defaultValue === 'true'
+				} else {
+					testParams[param.name] = param.defaultValue
+				}
+			} else {
+				// 否则设置为空值
+				if (param.type === 'int' || param.type === 'float') {
+					testParams[param.name] = undefined
+				} else if (param.type === 'bool') {
+					testParams[param.name] = false
+				} else {
+					testParams[param.name] = ''
+				}
+			}
+		})
+	}
+}, { deep: true })
+
+// 根据请求方法返回不同的标签类型
+const getMethodTagType = (method: string) => {
+	switch (method?.toUpperCase()) {
+		case 'GET':
+			return 'success'
+		case 'POST':
+			return 'primary'
+		case 'PUT':
+			return 'warning'
+		case 'DELETE':
+			return 'danger'
+		default:
+			return 'info'
+	}
+}
+
+// 获取参数占位符文本
+const getParamPlaceholder = (param) => {
+	if (param.required) {
+		return `请输入${param.description || param.name}(必填)`
+	}
+	return `请输入${param.description || param.name}(选填)`
+}
+
+// 格式化JSON
+const formatJson = (json) => {
+	try {
+		return JSON.stringify(json, null, 2)
+	} catch (e) {
+		return json
+	}
+}
+
+// 关闭对话框
+const closeDialog = () => {
+	showDialog.value = false
+}
+
+// 执行测试
+const runTest = async () => {
+	// 重置测试结果
+	testResult.success = false
+	testResult.message = ''
+	testResult.data = null
+	
+	loading.value = true
+	
+	try {
+		// 实际使用时,应该调用API进行测试
+		// const res = await api.apihub.test({ 
+		//   id: apiData.id,
+		//   parameters: testParams
+		// })
+		
+		// 模拟API调用
+		await new Promise(resolve => setTimeout(resolve, 1000))
+		
+		// 模拟测试结果
+		testResult.success = true
+		testResult.message = '测试成功'
+		
+		// 根据API类型模拟不同的测试数据
+		if (apiData.path.includes('users')) {
+			if (apiData.method === 'GET') {
+				testResult.data = {
+					id: 1,
+					username: 'admin',
+					email: 'admin@example.com',
+					created_at: '2025-05-01 10:00:00'
+				}
+			} else {
+				testResult.data = {
+					id: 10,
+					username: testParams.username || 'newuser',
+					email: testParams.email || 'newuser@example.com',
+					created_at: '2025-05-06 11:00:00'
+				}
+			}
+		} else {
+			testResult.data = {
+				result: '测试数据',
+				timestamp: new Date().toISOString()
+			}
+		}
+	} catch (error) {
+		testResult.success = false
+		testResult.message = error.message || '测试失败'
+	} finally {
+		loading.value = false
+	}
+}
+
+// 打开对话框
+const open = async (row: any) => {
+	// 清空数据
+	Object.keys(apiData).forEach(key => {
+		apiData[key] = undefined
+	})
+	
+	// 重置测试结果
+	testResult.success = false
+	testResult.message = ''
+	testResult.data = null
+	
+	showDialog.value = true
+	
+	// 实际使用时,应该调用API获取详细信息
+	// const res = await api.apihub.getDetail({ id: row.id })
+	// Object.assign(apiData, res)
+	
+	// 这里模拟直接使用传入的行数据
+	Object.assign(apiData, row)
+}
+
+defineExpose({ open })
+</script>
+
+<style scoped>
+.dialog-footer {
+	display: flex;
+	justify-content: flex-end;
+}
+
+.section-title {
+	font-weight: bold;
+	margin: 20px 0 10px;
+	font-size: 16px;
+	border-left: 4px solid #409EFF;
+	padding-left: 10px;
+}
+
+.param-desc {
+	font-size: 12px;
+	color: #909399;
+	margin-top: 5px;
+}
+
+.test-placeholder {
+	text-align: center;
+	color: #909399;
+	padding: 20px;
+	background-color: #f5f7fa;
+	border-radius: 4px;
+}
+
+.result-container {
+	margin-top: 15px;
+	border: 1px solid #e4e7ed;
+	border-radius: 4px;
+}
+
+.result-json {
+	background-color: #f5f7fa;
+	padding: 10px;
+	font-family: monospace;
+	white-space: pre-wrap;
+	word-break: break-all;
+	margin: 0;
+	max-height: 300px;
+	overflow: auto;
+}
+</style>

+ 162 - 0
src/views/apihub/component/view.vue

@@ -0,0 +1,162 @@
+<template>
+	<el-dialog
+		class="api-view"
+		v-model="showDialog"
+		title="API详情"
+		width="800px"
+		:close-on-click-modal="false"
+		:close-on-press-escape="false"
+	>
+		<el-descriptions :column="2" border>
+			<el-descriptions-item label="API名称" :span="2">{{ apiData.name }}</el-descriptions-item>
+			<el-descriptions-item label="API路径" :span="2">{{ apiData.path }}</el-descriptions-item>
+			<el-descriptions-item label="请求方法">
+				<el-tag :type="getMethodTagType(apiData.method)" size="small">{{ apiData.method }}</el-tag>
+			</el-descriptions-item>
+			<el-descriptions-item label="状态">
+				<el-tag size="small" v-if="apiData.status === 'Draft'">草稿</el-tag>
+				<el-tag size="small" type="success" v-else-if="apiData.status === 'Published'">已发布</el-tag>
+				<el-tag size="small" type="info" v-else-if="apiData.status === 'Deprecated'">已废弃</el-tag>
+				<span v-else>{{ apiData.status }}</span>
+			</el-descriptions-item>
+			<el-descriptions-item label="数据源">{{ apiData.dataSourceName }}</el-descriptions-item>
+			<el-descriptions-item label="版本">{{ apiData.version }}</el-descriptions-item>
+			<el-descriptions-item label="SQL类型">
+				<el-tag size="small" type="info" v-if="apiData.sqlType === 'query'">查询</el-tag>
+				<el-tag size="small" type="warning" v-else-if="apiData.sqlType === 'procedure'">存储过程</el-tag>
+				<span v-else>{{ apiData.sqlType }}</span>
+			</el-descriptions-item>
+			<el-descriptions-item label="返回格式">{{ apiData.returnFormat }}</el-descriptions-item>
+			<el-descriptions-item label="SQL内容" :span="2">
+				<div class="code-block">{{ apiData.sqlContent }}</div>
+			</el-descriptions-item>
+			<el-descriptions-item label="描述" :span="2">{{ apiData.description || '暂无描述' }}</el-descriptions-item>
+			<el-descriptions-item label="创建时间">{{ apiData.createdAt }}</el-descriptions-item>
+			<el-descriptions-item label="更新时间">{{ apiData.updatedAt }}</el-descriptions-item>
+		</el-descriptions>
+		
+		<div class="section-title">参数定义</div>
+		<el-table :data="apiData.parameters || []" style="width: 100%" border v-if="apiData.parameters && apiData.parameters.length">
+			<el-table-column prop="name" label="参数名" width="150"></el-table-column>
+			<el-table-column prop="type" label="类型" width="100"></el-table-column>
+			<el-table-column label="必填" width="80">
+				<template #default="scope">
+					<el-tag size="small" type="success" v-if="scope.row.required">是</el-tag>
+					<el-tag size="small" type="info" v-else>否</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="defaultValue" label="默认值" width="120"></el-table-column>
+			<el-table-column prop="description" label="描述"></el-table-column>
+		</el-table>
+		<el-empty description="暂无参数" v-else></el-empty>
+		
+		<div class="section-title" v-if="apiData.plugins && apiData.plugins.length">关联插件</div>
+		<el-table :data="apiData.plugins || []" style="width: 100%" border v-if="apiData.plugins && apiData.plugins.length">
+			<el-table-column prop="pluginId" label="插件ID" width="100"></el-table-column>
+			<el-table-column prop="config" label="插件配置"></el-table-column>
+		</el-table>
+		
+		<template #footer>
+			<div class="dialog-footer">
+				<el-button @click="closeDialog">关闭</el-button>
+				<el-button type="primary" @click="testApi">测试API</el-button>
+			</div>
+		</template>
+	</el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive } from 'vue'
+
+const emit = defineEmits(['test'])
+const showDialog = ref(false)
+const apiData = reactive({
+	id: undefined,
+	name: '',
+	path: '',
+	method: '',
+	dataSourceId: undefined,
+	dataSourceName: '',
+	sqlType: '',
+	sqlContent: '',
+	parameters: [],
+	returnFormat: '',
+	version: '',
+	status: '',
+	plugins: [],
+	description: '',
+	createdAt: '',
+	updatedAt: ''
+})
+
+// 根据请求方法返回不同的标签类型
+const getMethodTagType = (method: string) => {
+	switch (method?.toUpperCase()) {
+		case 'GET':
+			return 'success'
+		case 'POST':
+			return 'primary'
+		case 'PUT':
+			return 'warning'
+		case 'DELETE':
+			return 'danger'
+		default:
+			return 'info'
+	}
+}
+
+// 关闭对话框
+const closeDialog = () => {
+	showDialog.value = false
+}
+
+// 测试API
+const testApi = () => {
+	closeDialog()
+	emit('test', apiData)
+}
+
+// 打开对话框
+const open = async (row: any) => {
+	// 清空数据
+	Object.keys(apiData).forEach(key => {
+		apiData[key] = undefined
+	})
+	
+	showDialog.value = true
+	
+	// 实际使用时,应该调用API获取详细信息
+	// const res = await api.apihub.getDetail({ id: row.id })
+	// Object.assign(apiData, res)
+	
+	// 这里模拟直接使用传入的行数据
+	Object.assign(apiData, row)
+}
+
+defineExpose({ open })
+</script>
+
+<style scoped>
+.dialog-footer {
+	display: flex;
+	justify-content: flex-end;
+}
+
+.section-title {
+	font-weight: bold;
+	margin: 20px 0 10px;
+	font-size: 16px;
+	border-left: 4px solid #409EFF;
+	padding-left: 10px;
+}
+
+.code-block {
+	background-color: #f5f7fa;
+	border: 1px solid #e4e7ed;
+	border-radius: 4px;
+	padding: 10px;
+	font-family: monospace;
+	white-space: pre-wrap;
+	word-break: break-all;
+}
+</style>

+ 8 - 8
yarn.lock

@@ -1096,7 +1096,7 @@ balanced-match@^1.0.0:
 
 base64-arraybuffer@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
+  resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc"
   integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==
 
 batch-processor@1.0.0:
@@ -1347,7 +1347,7 @@ cross-spawn@^7.0.2:
 
 css-line-break@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
+  resolved "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0"
   integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==
   dependencies:
     utrie "^1.0.2"
@@ -2274,7 +2274,7 @@ highlight.js@^11.8.0:
 
 html2canvas@^1.4.1:
   version "1.4.1"
-  resolved "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
+  resolved "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
   integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
   dependencies:
     css-line-break "^2.1.0"
@@ -3214,7 +3214,7 @@ tape@^4.5.1:
 
 text-segmentation@^1.0.3:
   version "1.0.3"
-  resolved "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
+  resolved "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943"
   integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==
   dependencies:
     utrie "^1.0.2"
@@ -3373,7 +3373,7 @@ uri-js@^4.2.2:
 
 utrie@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
+  resolved "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645"
   integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==
   dependencies:
     base64-arraybuffer "^1.0.2"
@@ -3422,9 +3422,9 @@ vue-clipboard3@1.0.1:
     clipboard "^2.0.6"
 
 vue-data-ui@^2.4.17:
-  version "2.6.9"
-  resolved "https://registry.npmmirror.com/vue-data-ui/-/vue-data-ui-2.6.9.tgz#08c90093f98beb4a1f57a8336068f3d9a95e0cb5"
-  integrity sha512-q3lci39zyfKwpfK6ObnsZwTL0fu0hA0DrTVHZXhmwhS4b7K+cLYxIZuNyAp35o2zeVKhZ7hbW9g9P9jrSIqeZg==
+  version "2.6.2"
+  resolved "https://registry.npmjs.org/vue-data-ui/-/vue-data-ui-2.6.2.tgz#960569237e0ccf65e797e7517756bc735ec4a075"
+  integrity sha512-YQxX04a8raB/BVk95HeQegtvTXrvmDMbaIDE52s5lEPNUArGHVIF6G5EUPuNuPRgZDtJJW6csZ/0zOYAaCafAA==
 
 vue-demi@*:
   version "0.14.10"