Browse Source

feat:增加APIHUB客户端权限管理

microrain 5 months ago
parent
commit
a82be6260a

+ 3 - 1
src/api/modules/apiHub.ts

@@ -30,6 +30,8 @@ export default {
     add: (data: object) => post('/client/add', data),
     edit: (data: object) => put('/client/edit', data),
     delete: (ids: number[]) => del('/client/delete', { ids }),
-    resetSecret: (id: number) => put('/client/reset-secret', { id })
+    resetSecret: (id: number) => put('/client/reset-secret', { id }),
+    getApis: (clientId: string) => get('/client/apis', { clientId }),
+    saveApis: (clientId: string, apiKeys: string[]) => post('/client/apis', { clientId, apiKeys })
   }
 }

+ 49 - 2
src/views/apihub/client.vue

@@ -53,11 +53,12 @@
             </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" fixed="right">
+          <el-table-column label="操作" width="300" align="center" fixed="right">
             <template #default="scope">
               <div class="flex-row">
                 <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="primary" @click="manageApiAuth(scope.row)" v-auth="'edit'">API权限</el-button>
                 <el-button size="small" text type="success" @click="resetSecret(scope.row)" v-auth="'reset_secret'">重置密钥</el-button>
                 <el-button size="small" text type="danger" @click="deleteClient(scope.row)" v-auth="'delete'">删除</el-button>
               </div>
@@ -104,6 +105,17 @@
       </template>
     </el-dialog>
 
+    <!-- API权限管理弹窗 -->
+    <el-dialog v-model="apiAuthVisible" :title="`客户端 '${currentClient.name}' 的API权限管理`" width="900px" destroy-on-close>
+      <client-api-relation :client-id="currentClient.clientId" @update:apiKeys="handleApiKeysUpdate" />
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="apiAuthVisible = false">取消</el-button>
+          <el-button type="primary" @click="saveApiRelations" :loading="apiSaveLoading">保存</el-button>
+        </span>
+      </template>
+    </el-dialog>
+
     <!-- 客户端详情弹窗 -->
     <el-dialog v-model="detailVisible" title="客户端详情" width="600px" destroy-on-close>
       <el-descriptions :column="1" border>
@@ -165,6 +177,7 @@
 import { ref, reactive, onMounted } from "vue";
 import { ElMessageBox, ElMessage } from "element-plus";
 import api from "/@/api/modules/apiHub";
+import ClientApiRelation from "./component/ClientApiRelation.vue";
 
 // 定义客户端接口类型
 interface ClientInfo {
@@ -187,6 +200,10 @@ const formRef = ref();
 
 // 表格数据和加载状态
 const loading = ref(false);
+const apiSaveLoading = ref(false);
+const selectedApiKeys = ref<string[]>([]);
+const apiAuthVisible = ref(false);
+const currentClient = ref<ClientInfo>({} as ClientInfo);
 const params = reactive({
   keyWord: '',
   status: '',
@@ -351,7 +368,7 @@ const resetSecret = async (row: ClientInfo) => {
       cancelButtonText: '取消',
       type: 'warning'
     });
-    
+
     const res = await api.client.resetSecret(row.id as number);
     if (res) {
       ElMessage.success('密钥重置成功');
@@ -397,6 +414,36 @@ const copyText = (text: string) => {
     ElMessage.error('复制失败,请手动复制');
   });
 };
+
+// 处理API Keys更新
+const handleApiKeysUpdate = (apiKeys: string[]) => {
+  selectedApiKeys.value = apiKeys;
+};
+
+// 打开API权限管理窗口
+const manageApiAuth = (row: ClientInfo) => {
+  currentClient.value = {...row};
+  apiAuthVisible.value = true;
+};
+
+// 保存API关联
+const saveApiRelations = async () => {
+  if (!currentClient.value.clientId) {
+    ElMessage.warning('客户端信息不完整');
+    return;
+  }
+
+  apiSaveLoading.value = true;
+  try {
+    await api.client.saveApis(currentClient.value.clientId, selectedApiKeys.value);
+    ElMessage.success('API权限关联保存成功');
+    apiAuthVisible.value = false;
+  } catch (error) {
+    ElMessage.error('API权限关联保存失败');
+  } finally {
+    apiSaveLoading.value = false;
+  }
+};
 </script>
 
 <style scoped>

+ 521 - 0
src/views/apihub/component/ClientApiRelation.vue

@@ -0,0 +1,521 @@
+<template>
+  <div class="client-api-relation">
+    <div class="filter-container">
+      <el-row :gutter="20">
+        <el-col :span="6">
+          <el-input v-model="searchKey" placeholder="搜索API名称或路径" clearable @keyup.enter="handleSearch" />
+        </el-col>
+        <el-col :span="5">
+          <el-select v-model="selectedGroup" placeholder="API分组" clearable @change="handleSearch">
+            <el-option v-for="group in apiGroups" :key="group.id" :label="group.name" :value="group.id" />
+          </el-select>
+        </el-col>
+        <el-col :span="5">
+          <el-select v-model="selectedMethod" placeholder="请求方法" clearable @change="handleSearch">
+            <el-option v-for="method in methods" :key="method" :label="method" :value="method" />
+          </el-select>
+        </el-col>
+        <el-col :span="5">
+          <el-select v-model="selectedStatus" placeholder="状态" clearable @change="handleSearch">
+            <el-option label="已发布" value="Published" />
+            <el-option label="已弃用" value="Deprecated" />
+            <el-option label="未发布" value="Draft" />
+          </el-select>
+        </el-col>
+        <el-col :span="3">
+          <el-button type="primary" @click="handleSearch">
+            <el-icon><ele-Search /></el-icon>
+            搜索
+          </el-button>
+        </el-col>
+      </el-row>
+    </div>
+
+    <div class="relation-container">
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <div class="api-list-container">
+            <div class="api-list-header">
+              <h4>可选API列表</h4>
+              <div class="api-list-actions">
+                <el-button type="primary" size="small" @click="addSelectedApis">添加选中</el-button>
+                <el-button size="small" @click="selectAllVisible">选择当前页</el-button>
+                <el-button size="small" @click="applyTemplate('readonly')">应用只读模板</el-button>
+                <el-button size="small" @click="applyTemplate('fullaccess')">应用完全访问</el-button>
+              </div>
+            </div>
+            <el-table 
+              ref="apiTable" 
+              :data="availableApis" 
+              style="width: 100%" 
+              height="500px"
+              v-loading="loading"
+              @selection-change="handleSelectionChange">
+              <el-table-column type="selection" width="55" />
+              <el-table-column prop="name" label="API名称" show-overflow-tooltip />
+              <el-table-column prop="method" label="请求方法" width="100">
+                <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="path" label="API路径" show-overflow-tooltip />
+              <el-table-column prop="status" label="状态" width="80">
+                <template #default="scope">
+                  <el-tag 
+                    :type="getStatusTagType(scope.row.status)" 
+                    size="small">
+                    {{ getStatusText(scope.row.status) }}
+                  </el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column label="操作" width="80" fixed="right">
+                <template #default="scope">
+                  <el-button 
+                    type="primary" 
+                    size="small" 
+                    @click="addApi(scope.row)"
+                    :disabled="isApiSelected(scope.row.key)">
+                    添加
+                  </el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+            <div class="pagination-container">
+              <el-pagination
+                v-model:current-page="pageNum"
+                v-model:page-size="pageSize"
+                :page-sizes="[10, 20, 50, 100]"
+                layout="total, sizes, prev, pager, next, jumper"
+                :total="total"
+                @size-change="handleSizeChange"
+                @current-change="handleCurrentChange"
+              />
+            </div>
+          </div>
+        </el-col>
+        <el-col :span="12">
+          <div class="selected-api-container">
+            <div class="selected-api-header">
+              <h4>已授权API ({{ selectedApis.length }})</h4>
+              <el-button type="danger" size="small" @click="removeAllSelected">移除全部</el-button>
+            </div>
+            <el-table 
+              :data="selectedApis" 
+              style="width: 100%" 
+              height="500px">
+              <el-table-column prop="name" label="API名称" show-overflow-tooltip />
+              <el-table-column prop="method" label="请求方法" width="100">
+                <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="path" label="API路径" show-overflow-tooltip />
+              <el-table-column label="操作" width="80" fixed="right">
+                <template #default="scope">
+                  <el-button 
+                    type="danger" 
+                    size="small" 
+                    @click="removeApi(scope.row)">
+                    移除
+                  </el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, watch, defineProps, defineEmits } from 'vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import api from '/@/api/modules/apiHub';
+
+// 定义API数据类型
+interface ApiInfo {
+  id: number;
+  key: string;
+  name: string;
+  method: string;
+  path: string;
+  status: string;
+  groupId?: number;
+  groupName?: string;
+}
+
+const props = defineProps({
+  clientId: {
+    type: String,
+    required: true
+  }
+});
+
+const emit = defineEmits(['update:apiKeys']);
+
+// 数据状态
+const loading = ref(false);
+const searchKey = ref('');
+const selectedGroup = ref('');
+const selectedMethod = ref('');
+const selectedStatus = ref('');
+const pageNum = ref(1);
+const pageSize = ref(20);
+const total = ref(0);
+
+// API 数据
+const availableApis = ref<ApiInfo[]>([]);
+const apiGroups = ref<{id: number, name: string}[]>([]);
+const selectedApis = ref<ApiInfo[]>([]);
+const selectedApiKeys = ref<string[]>([]);
+const apiTable = ref();
+const selectedRows = ref<ApiInfo[]>([]);
+
+// 预设模板
+const permissionTemplates = {
+  readonly: {
+    name: '只读访问',
+    filter: (api: ApiInfo) => api.method === 'GET'
+  },
+  fullaccess: {
+    name: '完全访问',
+    filter: (api: ApiInfo) => true
+  }
+};
+
+// 请求方法列表
+const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
+
+// 监听选中的API变化,通知父组件
+watch(selectedApiKeys, (newKeys) => {
+  emit('update:apiKeys', newKeys);
+});
+
+// 获取API分组
+const getApiGroups = async () => {
+  try {
+    const res = await api.group.tree();
+    if (res) {
+      apiGroups.value = res;
+    }
+  } catch (error) {
+    ElMessage.error('获取API分组失败');
+  }
+};
+
+// 获取API列表
+const getApiList = async () => {
+  loading.value = true;
+  try {
+    const params = {
+      pageNum: pageNum.value,
+      pageSize: pageSize.value,
+      keyWord: searchKey.value,
+      groupId: selectedGroup.value,
+      method: selectedMethod.value,
+      status: selectedStatus.value
+    };
+    
+    const res = await api.list(params);
+    if (res) {
+      availableApis.value = res.list.map((item: any) => ({
+        id: item.id,
+        key: item.key || `${item.id}`,
+        name: item.name,
+        method: item.method,
+        path: item.path,
+        status: item.status,
+        groupId: item.groupId,
+        groupName: item.groupName
+      }));
+      total.value = res.total;
+    }
+  } catch (error) {
+    ElMessage.error('获取API列表失败');
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 获取客户端已关联的API
+const getClientApis = async () => {
+  if (!props.clientId) return;
+  
+  try {
+    const res = await api.client.getApis(props.clientId);
+    if (res && Array.isArray(res)) {
+      selectedApis.value = res;
+      selectedApiKeys.value = res.map(item => item.key);
+    }
+  } catch (error) {
+    ElMessage.error('获取客户端API失败');
+  }
+};
+
+// 处理表格选择变化
+const handleSelectionChange = (selection: ApiInfo[]) => {
+  selectedRows.value = selection;
+};
+
+// 选择当前页所有API
+const selectAllVisible = () => {
+  apiTable.value?.toggleAllSelection();
+};
+
+// 应用权限模板
+const applyTemplate = async (templateId: string) => {
+  const template = permissionTemplates[templateId];
+  if (!template) return;
+  
+  loading.value = true;
+  try {
+    // 获取所有API
+    const params = {
+      pageNum: 1,
+      pageSize: 1000,  // 获取较大数量的API
+    };
+    
+    const res = await api.list(params);
+    if (res && res.list) {
+      const filteredApis = res.list
+        .filter((api: any) => template.filter({
+          id: api.id,
+          key: api.key || `${api.id}`,
+          name: api.name,
+          method: api.method,
+          path: api.path,
+          status: api.status
+        }))
+        .map((api: any) => ({
+          id: api.id,
+          key: api.key || `${api.id}`,
+          name: api.name,
+          method: api.method,
+          path: api.path,
+          status: api.status,
+          groupId: api.groupId,
+          groupName: api.groupName
+        }));
+      
+      // 确认是否应用模板
+      await ElMessageBox.confirm(
+        `确定要应用"${template.name}"模板吗?这将会添加${filteredApis.length}个API到客户端权限。`,
+        '确认应用模板',
+        {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning'
+        }
+      );
+      
+      // 合并已有的API和模板中的API
+      const existingKeys = new Set(selectedApiKeys.value);
+      const newApis = filteredApis.filter(api => !existingKeys.has(api.key));
+      
+      selectedApis.value = [...selectedApis.value, ...newApis];
+      selectedApiKeys.value = selectedApis.value.map(api => api.key);
+      
+      ElMessage.success(`成功应用"${template.name}"模板,添加了${newApis.length}个API`);
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('应用模板失败');
+    }
+  } finally {
+    loading.value = false;
+  }
+};
+
+// 批量添加选中的API
+const addSelectedApis = () => {
+  if (selectedRows.value.length === 0) {
+    ElMessage.warning('请先选择要添加的API');
+    return;
+  }
+  
+  // 过滤掉已经选择的API
+  const existingKeys = new Set(selectedApiKeys.value);
+  const newApis = selectedRows.value.filter(api => !existingKeys.has(api.key));
+  
+  if (newApis.length === 0) {
+    ElMessage.warning('所选API已经全部添加');
+    return;
+  }
+  
+  selectedApis.value = [...selectedApis.value, ...newApis];
+  selectedApiKeys.value = selectedApis.value.map(api => api.key);
+  ElMessage.success(`成功添加${newApis.length}个API`);
+  
+  // 清除表格选择
+  apiTable.value?.clearSelection();
+};
+
+// 添加单个API
+const addApi = (api: ApiInfo) => {
+  if (isApiSelected(api.key)) {
+    ElMessage.warning('该API已添加');
+    return;
+  }
+  
+  selectedApis.value.push(api);
+  selectedApiKeys.value = selectedApis.value.map(item => item.key);
+  ElMessage.success('添加成功');
+};
+
+// 移除单个API
+const removeApi = (api: ApiInfo) => {
+  const index = selectedApis.value.findIndex(item => item.key === api.key);
+  if (index !== -1) {
+    selectedApis.value.splice(index, 1);
+    selectedApiKeys.value = selectedApis.value.map(item => item.key);
+    ElMessage.success('移除成功');
+  }
+};
+
+// 移除所有已选API
+const removeAllSelected = async () => {
+  if (selectedApis.value.length === 0) {
+    ElMessage.warning('暂无已授权的API');
+    return;
+  }
+  
+  try {
+    await ElMessageBox.confirm('确定要移除所有已授权的API吗?', '警告', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    });
+    
+    selectedApis.value = [];
+    selectedApiKeys.value = [];
+    ElMessage.success('已移除所有API授权');
+  } catch (error) {
+    // 用户取消操作
+  }
+};
+
+// 检查API是否已选中
+const isApiSelected = (apiKey: string) => {
+  return selectedApiKeys.value.includes(apiKey);
+};
+
+// 搜索API
+const handleSearch = () => {
+  pageNum.value = 1;
+  getApiList();
+};
+
+// 处理分页大小变化
+const handleSizeChange = (size: number) => {
+  pageSize.value = size;
+  getApiList();
+};
+
+// 处理页码变化
+const handleCurrentChange = (page: number) => {
+  pageNum.value = page;
+  getApiList();
+};
+
+// 获取请求方法对应的标签类型
+const getMethodTagType = (method: string) => {
+  const map: Record<string, string> = {
+    'GET': 'success',
+    'POST': 'primary',
+    'PUT': 'warning',
+    'DELETE': 'danger',
+    'PATCH': 'info'
+  };
+  
+  return map[method] || '';
+};
+
+// 获取状态对应的标签类型
+const getStatusTagType = (status: string) => {
+  const map: Record<string, string> = {
+    'Published': 'success',
+    'Deprecated': 'warning',
+    'Draft': 'info'
+  };
+  
+  return map[status] || '';
+};
+
+// 获取状态文本
+const getStatusText = (status: string) => {
+  const map: Record<string, string> = {
+    'Published': '已发布',
+    'Deprecated': '已弃用',
+    'Draft': '未发布'
+  };
+  
+  return map[status] || status;
+};
+
+// 初始化
+onMounted(() => {
+  getApiGroups();
+  getApiList();
+  getClientApis();
+});
+</script>
+
+<style scoped>
+.client-api-relation {
+  padding: 15px 0;
+}
+
+.filter-container {
+  margin-bottom: 20px;
+}
+
+.relation-container {
+  margin-top: 20px;
+}
+
+.api-list-header,
+.selected-api-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+  padding: 0 10px;
+}
+
+.api-list-header h4,
+.selected-api-header h4 {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.api-list-actions {
+  display: flex;
+  gap: 8px;
+}
+
+.api-list-container,
+.selected-api-container {
+  padding: 15px;
+  background-color: #f5f7fa;
+  border-radius: 4px;
+  height: 600px;
+  display: flex;
+  flex-direction: column;
+}
+
+.pagination-container {
+  margin-top: 15px;
+  display: flex;
+  justify-content: center;
+}
+</style>