Просмотр исходного кода

feat: 外部客户端管理界面

wuzh 3 дней назад
Родитель
Сommit
d0d2b289b4

+ 75 - 0
src/api/system/externalClient/index.js

@@ -0,0 +1,75 @@
+import request from '@/utils/request';
+
+function unwrapResponse(res) {
+  if (String(res.data.code) === '0') {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message || '请求失败'));
+}
+
+/**
+ * 分页查询外部客户端
+ * @param params 查询条件
+ */
+export async function pageExternalClients(params) {
+  const res = await request.get('/main/externalClient/page', { params });
+  return unwrapResponse(res);
+}
+
+/**
+ * 查询外部客户端详情
+ * @param id 客户端主键
+ */
+export async function getExternalClient(id) {
+  const res = await request.get(`/main/externalClient/getById/${id}`);
+  return unwrapResponse(res);
+}
+
+/**
+ * 创建外部客户端
+ * @param data 创建参数
+ */
+export async function addExternalClient(data) {
+  const res = await request.post('/main/externalClient/save', data);
+  return unwrapResponse(res);
+}
+
+/**
+ * 修改外部客户端
+ * @param id 客户端主键
+ * @param data 修改参数
+ */
+export async function updateExternalClient(data) {
+  const res = await request.put('/main/externalClient/update', data);
+  return unwrapResponse(res);
+}
+
+/**
+ * 启用外部客户端
+ * @param id 客户端主键
+ */
+export async function setExternalClientStatus(id, status) {
+  const res = await request.post('/main/externalClient/enableOrDisable', {
+    id,
+    status
+  });
+  return unwrapResponse(res);
+}
+
+/**
+ * 轮换外部客户端 Secret
+ * @param id 客户端主键
+ */
+export async function rotateExternalClientSecret(id) {
+  const res = await request.post(`/main/externalClient/rotateSecret/${id}`);
+  return unwrapResponse(res);
+}
+
+/**
+ * 吊销外部客户端 Token
+ * @param id 客户端主键
+ */
+export async function revokeExternalClientToken(id) {
+  const res = await request.post(`/main/externalClient/revokeToken/${id}`);
+  return unwrapResponse(res);
+}

+ 230 - 0
src/views/system/externalClient/components/external-client-edit.vue

@@ -0,0 +1,230 @@
+<!-- 外部客户端新建/编辑弹窗 -->
+<template>
+  <ele-modal
+    width="760px"
+    :visible="visible"
+    :append-to-body="true"
+    :close-on-click-modal="false"
+    custom-class="ele-dialog-form"
+    :title="isUpdate ? '修改外部客户端' : '新建外部客户端'"
+    @update:visible="updateVisible"
+  >
+    <el-form ref="form" :model="form" :rules="rules" label-width="110px">
+      <el-row :gutter="20">
+        <el-col v-if="isUpdate" :span="12">
+          <el-form-item label="客户端 ID:">
+            <el-input v-model="form.clientId" disabled />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="系统名称:" prop="systemName">
+            <el-input
+              v-model="form.systemName"
+              clearable
+              :maxlength="100"
+              placeholder="请输入外部系统名称"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="系统编码:" prop="systemCode">
+            <el-input
+              v-model="form.systemCode"
+              clearable
+              :maxlength="100"
+              placeholder="请输入外部系统编码"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="来源 IP 白名单:">
+            <el-input
+              v-model="form.ipWhitelist"
+              clearable
+              placeholder="多个 IP 使用英文逗号分隔,留空表示不限制"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="生效时间:">
+            <el-date-picker
+              v-model="form.effectiveTime"
+              type="datetime"
+              value-format="yyyy-MM-dd HH:mm:ss"
+              format="yyyy-MM-dd HH:mm:ss"
+              clearable
+              placeholder="请选择生效时间"
+              class="ele-block"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="失效时间:">
+            <el-date-picker
+              v-model="form.expireTime"
+              type="datetime"
+              value-format="yyyy-MM-dd HH:mm:ss"
+              format="yyyy-MM-dd HH:mm:ss"
+              clearable
+              placeholder="请选择失效时间"
+              class="ele-block"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-alert
+        title="来源 IP 白名单请使用英文半角逗号分隔。"
+        type="info"
+        :closable="false"
+        show-icon
+      />
+    </el-form>
+    <template v-slot:footer>
+      <el-button @click="updateVisible(false)">取消</el-button>
+      <el-button type="primary" :loading="loading" @click="save">
+        保存
+      </el-button>
+    </template>
+  </ele-modal>
+</template>
+
+<script>
+  import {
+    addExternalClient,
+    getExternalClient,
+    updateExternalClient
+  } from '@/api/system/externalClient';
+
+  const defaultForm = {
+    id: null,
+    clientId: '',
+    systemCode: '',
+    systemName: '',
+    effectiveTime: '',
+    expireTime: '',
+    ipWhitelist: ''
+  };
+
+  export default {
+    props: {
+      visible: Boolean,
+      data: {
+        type: Object,
+        default: null
+      }
+    },
+    data() {
+      return {
+        form: { ...defaultForm },
+        rules: {
+          systemName: [
+            { required: true, message: '请输入外部系统名称', trigger: 'blur' }
+          ],
+          systemCode: [
+            { required: true, message: '请输入外部系统编码', trigger: 'blur' }
+          ],
+          expireTime: [
+            {
+              validator: (rule, value, callback) => {
+                if (!value || !this.form.effectiveTime) {
+                  callback();
+                  return;
+                }
+                callback(
+                  new Date(value).getTime() >
+                    new Date(this.form.effectiveTime).getTime()
+                    ? undefined
+                    : new Error('失效时间必须晚于生效时间')
+                );
+              },
+              trigger: 'change'
+            }
+          ]
+        },
+        isUpdate: false,
+        loading: false,
+        detailLoading: false
+      };
+    },
+    watch: {
+      visible(value) {
+        if (value) {
+          this.initForm();
+        } else {
+          this.resetForm();
+        }
+      }
+    },
+    methods: {
+      async initForm() {
+        this.isUpdate = Boolean(this.data && this.data.id);
+        this.form = {
+          ...defaultForm,
+          ...(this.data || {})
+        };
+        if (!this.isUpdate) {
+          return;
+        }
+        this.detailLoading = true;
+        try {
+          const detail = await getExternalClient(this.data.id);
+          this.form = {
+            ...defaultForm,
+            ...detail
+          };
+        } catch (error) {
+          this.$message.error(error.message || '获取客户端详情失败');
+          this.updateVisible(false);
+        } finally {
+          this.detailLoading = false;
+        }
+      },
+      resetForm() {
+        this.form = { ...defaultForm };
+        this.isUpdate = false;
+        this.loading = false;
+        this.detailLoading = false;
+        if (this.$refs.form) {
+          this.$refs.form.clearValidate();
+        }
+      },
+      save() {
+        if (this.loading || this.detailLoading) {
+          return;
+        }
+        this.$refs.form.validate((valid) => {
+          if (!valid) {
+            return;
+          }
+          this.loading = true;
+          const payload = {
+            ...(this.isUpdate ? { id: this.form.id } : {}),
+            systemName: this.form.systemName,
+            systemCode: this.form.systemCode,
+            effectiveTime: this.form.effectiveTime || undefined,
+            expireTime: this.form.expireTime || undefined,
+            ipWhitelist: this.form.ipWhitelist || undefined
+          };
+          const request = this.isUpdate
+            ? updateExternalClient(payload)
+            : addExternalClient(payload);
+          request
+            .then((result) => {
+              this.$message.success(this.isUpdate ? '修改成功' : '创建成功');
+              this.updateVisible(false);
+              this.$emit('done', result);
+            })
+            .catch((error) => {
+              this.$message.error(error.message || '保存失败');
+            })
+            .finally(() => {
+              this.loading = false;
+            });
+        });
+      },
+      updateVisible(value) {
+        this.$emit('update:visible', value);
+      }
+    }
+  };
+</script>

+ 48 - 0
src/views/system/externalClient/components/external-client-search.vue

@@ -0,0 +1,48 @@
+<!-- 外部客户端查询条件 -->
+<template>
+  <seekPage :seekList="seekList" :formLength="4" @search="search" />
+</template>
+
+<script>
+  export default {
+    computed: {
+      seekList() {
+        return [
+          {
+            label: '客户端 ID:',
+            value: 'clientId',
+            type: 'input',
+            placeholder: '请输入客户端 ID'
+          },
+          {
+            label: '系统编码:',
+            value: 'systemCode',
+            type: 'input',
+            placeholder: '请输入系统编码'
+          },
+          {
+            label: '系统名称:',
+            value: 'systemName',
+            type: 'input',
+            placeholder: '请输入系统名称'
+          },
+          {
+            label: '状态:',
+            value: 'status',
+            type: 'select',
+            planList: [
+              { label: '启用', value: 1 },
+              { label: '禁用', value: 0 }
+            ],
+            placeholder: '请选择状态'
+          }
+        ];
+      }
+    },
+    methods: {
+      search(where) {
+        this.$emit('search', where);
+      }
+    }
+  };
+</script>

+ 320 - 0
src/views/system/externalClient/index.vue

@@ -0,0 +1,320 @@
+<!-- 外部客户端管理 -->
+<template>
+  <div class="ele-body">
+    <el-card shadow="never">
+      <external-client-search @search="reload" />
+      <ele-pro-table
+        ref="table"
+        :columns="columns"
+        :datasource="datasource"
+        height="calc(100vh - 385px)"
+        row-key="id"
+        :page-size="pageSize"
+        @columns-change="handleColumnChange"
+        :cache-key="cacheKeyUrl"
+      >
+        <template v-slot:toolbar>
+          <el-button
+            v-if="$hasPermission('main:externalClient:save')"
+            size="small"
+            type="primary"
+            icon="el-icon-plus"
+            class="ele-btn-icon"
+            @click="openEdit()"
+          >
+            新建
+          </el-button>
+        </template>
+        <template v-slot:status="{ row }">
+          <el-tag :type="Number(row.status) === 1 ? 'success' : 'info'">
+            {{ Number(row.status) === 1 ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+        <template v-slot:action="{ row }">
+          <el-link
+            v-if="row.clientId"
+            v-clipboard:copy="row.clientId"
+            v-clipboard:error="onCopyClientIdError"
+            v-clipboard:success="onCopyClientId"
+            type="primary"
+            :underline="false"
+            icon="el-icon-document-copy"
+            class="ele-action"
+          >
+            复制clientId
+          </el-link>
+          <el-link
+            v-if="$hasPermission('main:externalClient:update')"
+            type="primary"
+            :underline="false"
+            icon="el-icon-edit"
+            class="ele-action"
+            @click="openEdit(row)"
+          >
+            修改
+          </el-link>
+          <el-popconfirm
+            v-if="$hasPermission('main:externalClient:enableOrDisable')"
+            class="ele-action"
+            :title="
+              Number(row.status) === 1
+                ? '禁用后该客户端已有 Token 将被吊销,确定继续吗?'
+                : '确定启用此客户端吗?'
+            "
+            @confirm="toggleStatus(row)"
+          >
+            <template v-slot:reference>
+              <el-link type="primary" :underline="false">
+                {{ Number(row.status) === 1 ? '禁用' : '启用' }}
+              </el-link>
+            </template>
+          </el-popconfirm>
+          <el-popconfirm
+            v-if="$hasPermission('main:externalClient:rotateSecret')"
+            class="ele-action"
+            title="轮换后旧 Secret 和已有 Token 会立即失效,确定继续吗?"
+            @confirm="rotateSecret(row)"
+          >
+            <template v-slot:reference>
+              <el-link type="warning" :underline="false">重置签名</el-link>
+            </template>
+          </el-popconfirm>
+          <el-popconfirm
+            v-if="$hasPermission('main:externalClient:revokeToken')"
+            class="ele-action"
+            title="确定吊销该客户端当前已有的全部 Token 吗?"
+            @confirm="revokeToken(row)"
+          >
+            <template v-slot:reference>
+              <el-link type="danger" :underline="false">吊销 Token</el-link>
+            </template>
+          </el-popconfirm>
+        </template>
+      </ele-pro-table>
+    </el-card>
+
+    <external-client-edit
+      :visible.sync="showEdit"
+      :data="current"
+      @done="handleEditDone"
+    />
+
+    <el-dialog
+      title="客户端 Secret"
+      :visible.sync="secretVisible"
+      width="560px"
+      :close-on-click-modal="false"
+      :show-close="false"
+    >
+      <el-alert
+        title="Secret 只展示这一次,请立即安全保存并交付给外部系统。关闭后无法再次查询明文。"
+        type="warning"
+        :closable="false"
+        show-icon
+        style="margin-bottom: 16px"
+      />
+      <el-input v-model="clientSecret" type="textarea" :rows="3" readonly />
+      <template v-slot:footer>
+        <el-button
+          v-clipboard:copy="clientSecret"
+          v-clipboard:error="onCopyError"
+          v-clipboard:success="onCopy"
+        >
+          复制 Secret
+        </el-button>
+        <el-button type="primary" @click="closeSecret">
+          我已安全保存
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+  import tabMixins from '@/mixins/tableColumnsMixin';
+  import ExternalClientSearch from './components/external-client-search.vue';
+  import ExternalClientEdit from './components/external-client-edit.vue';
+  import {
+    pageExternalClients,
+    setExternalClientStatus,
+    rotateExternalClientSecret,
+    revokeExternalClientToken
+  } from '@/api/system/externalClient';
+
+  export default {
+    name: 'ExternalClient',
+    mixins: [tabMixins],
+    components: {
+      ExternalClientSearch,
+      ExternalClientEdit
+    },
+    data() {
+      return {
+        columns: [
+          {
+            columnKey: 'index',
+            type: 'index',
+            width: 55,
+            align: 'center',
+            fixed: 'left',
+            label: '序号'
+          },
+          {
+            prop: 'clientId',
+            label: '客户端 ID',
+            minWidth: 230,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'systemCode',
+            label: '系统编码',
+            minWidth: 120,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'systemName',
+            label: '系统名称',
+            minWidth: 140,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'status',
+            label: '状态',
+            width: 80,
+            align: 'center',
+            slot: 'status'
+          },
+          {
+            prop: 'effectiveTime',
+            label: '生效时间',
+            minWidth: 155,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'expireTime',
+            label: '失效时间',
+            minWidth: 155,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'ipWhitelist',
+            label: '来源 IP 白名单',
+            minWidth: 180,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'lastAccessTime',
+            label: '最近访问时间',
+            minWidth: 155,
+            showOverflowTooltip: true
+          },
+          {
+            prop: 'createTime',
+            label: '创建时间',
+            minWidth: 155,
+            showOverflowTooltip: true
+          },
+          {
+            columnKey: 'action',
+            label: '操作',
+            width: 340,
+            align: 'center',
+            resizable: false,
+            slot: 'action'
+          }
+        ],
+        current: null,
+        showEdit: false,
+        pageSize: this.$store.state.tablePageSize,
+        cacheKeyUrl: 'external-client-management',
+        secretVisible: false,
+        clientSecret: ''
+      };
+    },
+    methods: {
+      datasource({ page, limit, where, order }) {
+        return pageExternalClients({
+          ...where,
+          ...order,
+          pageNum: page,
+          size: limit
+        });
+      },
+      reload(where) {
+        this.$refs.table.reload({ page: 1, where });
+      },
+      openEdit(row) {
+        this.current = row || null;
+        this.showEdit = true;
+      },
+      handleEditDone(result) {
+        this.reload();
+        if (result && result.clientSecret) {
+          this.clientSecret = result.clientSecret;
+          this.secretVisible = true;
+        }
+      },
+      async toggleStatus(row) {
+        try {
+          const status = Number(row.status) === 1 ? 0 : 1;
+          await setExternalClientStatus(row.id, status);
+          this.$message.success(
+            status === 0
+              ? '客户端已禁用,已有 Token 已同步吊销'
+              : '客户端已启用'
+          );
+          this.reload();
+        } catch (error) {
+          this.$message.error(error.message || '状态更新失败');
+        }
+      },
+      async rotateSecret(row) {
+        try {
+          const result = await rotateExternalClientSecret(row.id);
+          this.$message.success('Secret 轮换成功,旧凭证已失效');
+          this.clientSecret = result && result.clientSecret;
+          if (this.clientSecret) {
+            this.secretVisible = true;
+          }
+          this.reload();
+        } catch (error) {
+          this.$message.error(error.message || 'Secret 轮换失败');
+        }
+      },
+      async revokeToken(row) {
+        try {
+          await revokeExternalClientToken(row.id);
+          this.$message.success('客户端 Token 已全部吊销');
+        } catch (error) {
+          this.$message.error(error.message || 'Token 吊销失败');
+        }
+      },
+      closeSecret() {
+        this.secretVisible = false;
+        this.clientSecret = '';
+      },
+      onCopy() {
+        this.$message.success('Secret 已复制');
+      },
+      onCopyError() {
+        this.$message.error('Secret 复制失败,请手动复制');
+      },
+      onCopyClientId() {
+        this.$message.success('API Key 已复制');
+      },
+      onCopyClientIdError() {
+        this.$message.error('API Key 复制失败,请手动复制');
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  :deep(.ele-action) {
+    margin-right: 10px;
+  }
+
+  :deep(.ele-action:last-child) {
+    margin-right: 0;
+  }
+</style>