Kaynağa Gözat

完善多租户版本

ahjung 3 yıl önce
ebeveyn
işleme
c46299a1d1

+ 4 - 5
.env.development

@@ -1,5 +1,5 @@
 # 只在开发模式中被载入
-VITE_PORT = 8083
+VITE_PORT = 8084
 
 # 网站根目录
 VITE_PUBLIC_PATH = /
@@ -18,14 +18,13 @@ VITE_DROP_CONSOLE = true
 # VITE_PROXY=[["/api","https://naive-ui-admin"]]
 
 # API 接口地址
-VITE_GLOB_API_URL = https://vapi.naiveadmin.com
-# VITE_GLOB_API_URL = http://192.168.3.199:8086
+VITE_GLOB_API_URL = https://api-tenant.naiveadmin.com
 
 # 图片上传地址
-VITE_GLOB_UPLOAD_URL= https://vapi.naiveadmin.com
+VITE_GLOB_UPLOAD_URL= https://api-tenant.naiveadmin.com
 
 # 图片前缀地址
-VITE_GLOB_IMG_URL= https://vapi.naiveadmin.com
+VITE_GLOB_IMG_URL= https://api-tenant.naiveadmin.com
 
 # 接口前缀
 VITE_GLOB_API_URL_PREFIX = /api

+ 3 - 3
.env.production

@@ -11,13 +11,13 @@ VITE_BASE_URL = /
 VITE_DROP_CONSOLE = true
 
 # API
-VITE_GLOB_API_URL = https://vapi.naiveadmin.com
+VITE_GLOB_API_URL = https://api-tenant.naiveadmin.com
 
 # 图片上传地址
-VITE_GLOB_UPLOAD_URL= https://vapi.naiveadmin.com
+VITE_GLOB_UPLOAD_URL= https://api-tenant.naiveadmin.com
 
 # 图片前缀地址
-VITE_GLOB_IMG_URL= https://vapi.naiveadmin.com
+VITE_GLOB_IMG_URL= https://api-tenant.naiveadmin.com
 
 # 接口前缀
 VITE_GLOB_API_URL_PREFIX = /api

+ 11 - 0
src/api/common/index.ts

@@ -39,3 +39,14 @@ export function postList(params?) {
     params,
   });
 }
+
+/**
+ * @description: 租户列表(不分页)
+ */
+export function tentantList(params?) {
+  return http.request({
+    url: '/common/queryTentantList',
+    method: 'get',
+    params,
+  });
+}

+ 13 - 2
src/api/system/logs.ts

@@ -1,12 +1,23 @@
 import { http } from '@/utils/http/axios';
 
 /**
- * @description: 日志列表
+ * @description: 操作日志
  */
-export function logsList(params) {
+export function operlogList(params) {
   return http.request({
     url: '/log/list',
     method: 'get',
     params,
   });
 }
+
+/**
+ * @description: 登录日志
+ */
+export function loginLogList(params) {
+  return http.request({
+    url: '/log/loginList',
+    method: 'get',
+    params,
+  });
+}

+ 22 - 0
src/api/system/user.ts

@@ -126,3 +126,25 @@ export function getUserList(params) {
     params,
   });
 }
+
+/**
+ * @description: 获取在线用户列表
+ */
+export function onlineList(params) {
+  return http.request({
+    url: '/online/userList',
+    method: 'GET',
+    params,
+  });
+}
+
+/**
+ * @description: 在线用户强制退出
+ */
+export function onlineLogOut(params?) {
+  return http.request({
+    url: '/online/logOut',
+    method: 'GET',
+    params,
+  });
+}

+ 56 - 0
src/api/tenant/index.ts

@@ -0,0 +1,56 @@
+import { http } from '@/utils/http/axios';
+
+/**
+ * @description: 租户列表
+ */
+export function tenantList(params) {
+  return http.request({
+    url: '/tenant/list',
+    method: 'get',
+    params,
+  });
+}
+
+/**
+ * @description: 添加租户
+ */
+export function addTenant(data) {
+  return http.request({
+    url: '/tenant/add',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * @description: 删除租户
+ */
+export function deleteTenant(data) {
+  return http.request({
+    url: '/tenant/delete',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * @description: 编辑租户
+ */
+export function editTenant(data) {
+  return http.request({
+    url: '/tenant/update',
+    method: 'post',
+    data,
+  });
+}
+
+/**
+ * @description: 租户详情
+ */
+export function tenantInfo(params) {
+  return http.request({
+    url: '/tenant/view',
+    method: 'get',
+    params,
+  });
+}

+ 30 - 0
src/router/modules/article.ts

@@ -0,0 +1,30 @@
+import { RouteRecordRaw } from 'vue-router';
+import { Layout } from '@/router/constant';
+import { BookOutlined } from '@vicons/antd';
+import { renderIcon } from '@/utils/index';
+
+const routes: Array<RouteRecordRaw> = [
+  {
+    path: '/article',
+    name: 'Article',
+    redirect: '/article/list',
+    component: Layout,
+    meta: {
+      title: '文章管理',
+      icon: renderIcon(BookOutlined),
+      sort: 7,
+    },
+    children: [
+      {
+        path: 'list',
+        name: 'article_list',
+        meta: {
+          title: '文章列表',
+        },
+        component: () => import('@/views/article/list.vue'),
+      },
+    ],
+  },
+];
+
+export default routes;

+ 38 - 0
src/router/modules/instation.ts

@@ -0,0 +1,38 @@
+import { RouteRecordRaw } from 'vue-router';
+import { Layout } from '@/router/constant';
+import { BellOutlined } from '@vicons/antd';
+import { renderIcon } from '@/utils/index';
+
+const routes: Array<RouteRecordRaw> = [
+  {
+    path: '/instation',
+    name: 'Instation',
+    redirect: '/instation/myalerts',
+    component: Layout,
+    meta: {
+      title: '站内通知',
+      icon: renderIcon(BellOutlined),
+      sort: 7,
+    },
+    children: [
+      {
+        path: 'myalerts',
+        name: 'myalerts',
+        meta: {
+          title: '我的通知',
+        },
+        component: () => import('@/views/instation/myalerts/myalerts.vue'),
+      },
+      {
+        path: 'notice',
+        name: 'instation_notice',
+        meta: {
+          title: '通知管理',
+        },
+        component: () => import('@/views/instation/notice/notice.vue'),
+      },
+    ],
+  },
+];
+
+export default routes;

+ 25 - 1
src/router/modules/system.ts

@@ -26,6 +26,22 @@ const routes: Array<RouteRecordRaw> = [
       sort: 1,
     },
     children: [
+      {
+        path: 'online',
+        name: 'online',
+        meta: {
+          title: '在线用户',
+        },
+        component: () => import('@/views/system/online/online.vue'),
+      },
+      {
+        path: 'tenant',
+        name: 'system_tenant',
+        meta: {
+          title: '租户管理',
+        },
+        component: () => import('@/views/system/tenant/tenant.vue'),
+      },
       {
         path: 'menu',
         name: 'system_menu',
@@ -34,6 +50,14 @@ const routes: Array<RouteRecordRaw> = [
         },
         component: () => import('@/views/system/menu/menu.vue'),
       },
+      {
+        path: 'tenant',
+        name: 'system_tenant',
+        meta: {
+          title: '租户管理',
+        },
+        component: () => import('@/views/system/tenant/tenant.vue'),
+      },
       {
         path: 'dictionary',
         name: 'system_dictionary',
@@ -48,7 +72,7 @@ const routes: Array<RouteRecordRaw> = [
         meta: {
           title: '日志管理',
         },
-        component: () => import('@/views/system/logs/logs.vue'),
+        component: () => import('@/views/system/logs/operlog.vue'),
       },
       {
         path: 'region',

+ 15 - 3
src/store/modules/user.ts

@@ -1,7 +1,7 @@
 import { defineStore } from 'pinia';
 import { createStorage } from '@/utils/Storage';
 import { store } from '@/store';
-import { ACCESS_TOKEN, CURRENT_USER, IS_LOCKSCREEN } from '@/store/mutation-types';
+import { ACCESS_TOKEN, CURRENT_USER, IS_LOCKSCREEN, TENANT_TOKEN } from '@/store/mutation-types';
 import { ResultEnum } from '@/enums/httpEnum';
 
 const Storage = createStorage({ storage: localStorage });
@@ -15,12 +15,14 @@ export interface IUserState {
   avatar: string;
   permissions: any[];
   info: any;
+  tenantId: number;
 }
 
 export const useUserStore = defineStore({
   id: 'app-user',
   state: (): IUserState => ({
     token: Storage.get(ACCESS_TOKEN, ''),
+    tenantId: Storage.get(TENANT_TOKEN, ''),
     username: '',
     welcome: '',
     avatar: '',
@@ -31,6 +33,9 @@ export const useUserStore = defineStore({
     getToken(): string {
       return this.token;
     },
+    getTenantId(): number {
+      return this.tenantId;
+    },
     getAvatar(): string {
       return this.avatar;
     },
@@ -45,6 +50,9 @@ export const useUserStore = defineStore({
     },
   },
   actions: {
+    setTenantId(tenantId: number) {
+      this.tenantId = tenantId;
+    },
     setToken(token: string) {
       this.token = token;
     },
@@ -62,14 +70,18 @@ export const useUserStore = defineStore({
       try {
         const response = await login(userInfo);
         const { data: result, code } = response;
-        if (code === ResultEnum.SUCCESS) {
-          const ex = 7 * 24 * 60 * 60;
+        if (parseInt(code) === ResultEnum.SUCCESS) {
+          const ex = 7 * 24 * 60 * 60 * 1000;
           const token = result.satoken;
+          const tenantId = result.tenantId;
           storage.set(ACCESS_TOKEN, token, ex);
+          storage.set(TENANT_TOKEN, tenantId, ex);
           storage.set(CURRENT_USER, result, ex);
           storage.set(IS_LOCKSCREEN, false);
           storage.setCookie('satoken', token);
+          storage.setCookie('tenantId', tenantId);
           this.setToken(token);
+          this.setTenantId(tenantId);
           this.setUserInfo(result);
         }
         return Promise.resolve(response);

+ 1 - 0
src/store/mutation-types.ts

@@ -1,4 +1,5 @@
 export const ACCESS_TOKEN = 'ACCESS-TOKEN'; // 用户token
+export const TENANT_TOKEN = 'TENANTID'; // 租户id
 export const CURRENT_USER = 'CURRENT-USER'; // 当前用户信息
 export const IS_LOCKSCREEN = 'IS-LOCKSCREEN'; // 是否锁屏
 export const TABS_ROUTES = 'TABS-ROUTES'; // 标签页

+ 5 - 0
src/utils/http/axios/index.ts

@@ -188,12 +188,17 @@ const transform: AxiosTransform = {
     // 请求之前处理config
     const userStore = useUserStoreWidthOut();
     const token = userStore.getToken;
+    const tenantId = userStore.getTenantId;
     if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
       // jwt token
       (config as Recordable).headers.satoken = options.authenticationScheme
         ? `${options.authenticationScheme} ${token}`
         : token;
     }
+    // 租户id
+    if (tenantId) {
+      (config as Recordable).headers.tenantId = tenantId;
+    }
     return config;
   },
 

+ 1 - 1
src/views/article/ImportModal.vue

@@ -108,7 +108,7 @@
           ElNotification({
             title: '提示',
             message: `导入总数:${data.totalCount}条,成功 ${data.sucCount}条,失败 ${data.failCount}条`,
-            type: data.isSuc ? 'success' : 'error',
+            type: data.isSuc ? 'success' : 'danger',
           });
           emit('change');
           closeModal();

+ 1 - 1
src/views/auth/post/columns.ts

@@ -22,7 +22,7 @@ export const columns: BasicColumn[] = [
       return h(
         ElTag,
         {
-          type: record.row.isEnable ? 'success' : 'error',
+          type: record.row.isEnable ? 'success' : 'danger',
         },
         {
           default: () => (record.row.isEnable ? '启用' : '禁用'),

+ 1 - 1
src/views/list/basicList/index.vue

@@ -333,4 +333,4 @@
   }
 </script>
 
-<style lang="less" scoped></style>
+<style lang="scss" scoped></style>

+ 63 - 4
src/views/login/LoginForm2.vue

@@ -8,6 +8,28 @@
     :rules="rules"
     class="login-form"
   >
+    <el-form-item prop="tenantId">
+      <el-select
+        v-model="formInline.tenantId"
+        placeholder="请选择租户"
+        @chnage="tenantIdChange"
+        class="w-full"
+      >
+        <el-option
+          v-for="item in tenantOptions"
+          :key="item.tenantId"
+          :label="item.tenantName"
+          :value="item.tenantId"
+        />
+
+        <template #prefix>
+          <el-icon size="18" color="#808695">
+            <PersonOutline />
+          </el-icon>
+        </template>
+      </el-select>
+    </el-form-item>
+
     <el-form-item prop="username">
       <el-input v-model="formInline.username" placeholder="请输入用户名">
         <template #prefix>
@@ -112,9 +134,9 @@
   import { reactive, ref, onMounted } from 'vue';
   import { useRoute, useRouter } from 'vue-router';
   import { useUserStore } from '@/store/modules/user';
-  import { ElMessage } from 'element-plus';
+  import { ElMessage, FormRules } from 'element-plus';
   import { ResultEnum } from '@/enums/httpEnum';
-  import { initData, captchaBase64 } from '@/api/common/index';
+  import { initData, captchaBase64, tentantList } from '@/api/common/index';
   import { CodeOutlined } from '@vicons/antd';
   import { PersonOutline, LockClosedOutline, LogoGithub, LogoFacebook } from '@vicons/ionicons5';
   import { PageEnum } from '@/enums/pageEnum';
@@ -124,6 +146,7 @@
     password: string;
     verCode: string;
     vercodeType: number;
+    tenantId: number | undefined;
   }
 
   const formRef = ref();
@@ -135,16 +158,26 @@
   const captchaImgUrl = ref();
   const LOGIN_NAME = PageEnum.BASE_LOGIN_NAME;
 
+  const tenantOptions = ref<{ tenantCode: string; tenantId: number; tenantName: string }[]>([]);
+  const tenantAccounts = [
+    { username: 'bj', password: '123456' },
+    { username: 'sz', password: '123456' },
+    { username: 'gz', password: '123456' },
+    { username: 'sh', password: '123456' },
+  ];
+
   const formInline = reactive({
     username: 'test',
     password: '123456',
     verCode: '',
     vercodeType: 5,
+    tenantId: undefined,
   });
 
-  const rules = {
+  const rules: FormRules = {
     username: { required: true, message: '请输入用户名', trigger: 'blur' },
     password: { required: true, message: '请输入密码', trigger: 'blur' },
+    tenantId: { required: true, message: '请选择租户', type: 'number', trigger: 'change' },
   };
   const emit = defineEmits(['goRegister']);
   const userStore = useUserStore();
@@ -152,6 +185,25 @@
   const router = useRouter();
   const route = useRoute();
 
+  function tenantIdChange() {
+    const tenantId = formInline.tenantId;
+    const index = tenantOptions.value.findIndex((item) => item.tenantId === tenantId);
+    if (index >= 0) {
+      const info = tenantAccounts[index];
+      formInline.username = info.username;
+      formInline.password = info.password;
+    }
+  }
+
+  function getTentantList() {
+    tentantList().then((res) => {
+      tenantOptions.value = res;
+      if (res.length) {
+        formInline.tenantId = res[0].tenantId;
+      }
+    });
+  }
+
   //获取验证码
   function getCaptcha() {
     const vercodeType = formInline.vercodeType;
@@ -168,7 +220,7 @@
     if (!formRef.value) return;
     formRef.value.validate(async (valid) => {
       if (valid) {
-        const { username, password, verCode, vercodeType } = formInline;
+        const { username, password, verCode, vercodeType, tenantId } = formInline;
         loading.value = true;
 
         const params: FormState = {
@@ -176,6 +228,7 @@
           password,
           verCode,
           vercodeType,
+          tenantId,
         };
 
         try {
@@ -218,6 +271,7 @@
 
   onMounted(() => {
     getInitData();
+    getTentantList();
   });
 </script>
 
@@ -230,5 +284,10 @@
     .el-input {
       --el-input-border-radius: 20px !important;
     }
+    .el-select {
+      .el-input {
+        --el-input-border-radius: 20px !important;
+      }
+    }
   }
 </style>

+ 1 - 1
src/views/system/dictionary/dictionary.vue

@@ -199,7 +199,7 @@
         return h(
           ElTag,
           {
-            type: record.row.status === '0' ? 'success' : 'error',
+            type: record.row.status === '0' ? 'success' : 'danger',
           },
           {
             default: () => (record.row.status === '0' ? '正常' : '停用'),

+ 20 - 1
src/views/system/logs/columns.ts

@@ -1,4 +1,6 @@
 import { BasicColumn } from '@/components/Table';
+import { ElPopover, ElButton } from 'element-plus';
+import { h } from 'vue';
 
 export const columns: BasicColumn[] = [
   {
@@ -20,7 +22,24 @@ export const columns: BasicColumn[] = [
   {
     label: '参数',
     prop: 'parameter',
-    width: 650,
+    render(record) {
+      return h(
+        ElPopover,
+        {
+          placement: 'bottom',
+          trigger: 'hover',
+          style: { 'max-width': '550px' },
+          'content-style': {
+            'word-break': 'break-all',
+          },
+          scrollable: true,
+        },
+        {
+          trigger: () => h(ElButton, {}, { default: () => '查看参数' }),
+          default: () => `${record.row.parameter}`,
+        },
+      );
+    },
   },
   {
     label: '创建时间',

+ 53 - 0
src/views/system/logs/loginColumns.ts

@@ -0,0 +1,53 @@
+import { BasicColumn } from '@/components/Table';
+import { ElTag } from 'element-plus';
+import { h } from 'vue';
+
+export const columns: BasicColumn[] = [
+  {
+    label: '访问编号',
+    prop: 'id',
+  },
+  {
+    label: '用户名称',
+    prop: 'userName',
+  },
+  {
+    label: '登录ip',
+    prop: 'loginIp',
+  },
+  {
+    label: '登录地点',
+    prop: 'loginAddress',
+  },
+  {
+    label: '浏览器',
+    prop: 'browser',
+  },
+  {
+    label: '操作系统',
+    prop: 'system',
+  },
+  {
+    label: '登录状态',
+    prop: 'loginStatus',
+    render(record) {
+      return h(
+        ElTag,
+        {
+          type: record.row.loginStatus === 1 ? 'success' : 'danger',
+        },
+        {
+          default: () => `${record.row.loginStatus === 1 ? '登录成功' : '登录失败'}`,
+        },
+      );
+    },
+  },
+  {
+    label: '登录信息',
+    prop: 'content',
+  },
+  {
+    label: '登录时间',
+    prop: 'createTime',
+  },
+];

+ 8 - 6
src/views/system/logs/logs.vue

@@ -4,9 +4,9 @@
       <el-space align="center">
         <el-input
           :style="{ width: '320px' }"
-          v-model="params.operator"
+          v-model:value="params.userName"
           clearable
-          placeholder="请输入操作人"
+          placeholder="请输入用户名称"
           @keyup.enter="reloadTable"
         />
         <el-button type="primary" @click="reloadTable">
@@ -33,19 +33,19 @@
 <script lang="ts" setup>
   import { reactive, ref } from 'vue';
   import { BasicTable } from '@/components/Table';
-  import { logsList } from '@/api/system/logs';
+  import { loginLogList } from '@/api/system/logs';
   import { SearchOutlined } from '@vicons/antd';
-  import { columns } from './columns';
+  import { columns } from './loginColumns';
 
   const basicTableRef = ref();
   const tableData = ref();
 
   const params = reactive({
-    operator: '',
+    userName: '',
   });
 
   const loadDataTable = async (res) => {
-    const result = await logsList({ ...params, ...res });
+    const result = await loginLogList({ ...params, ...res });
     tableData.value = result.list;
     return result;
   };
@@ -54,3 +54,5 @@
     basicTableRef.value.reload();
   }
 </script>
+
+<style lang="scss" scoped></style>

+ 60 - 0
src/views/system/logs/operlog.vue

@@ -0,0 +1,60 @@
+<template>
+  <PageWrapper>
+    <el-card :bordered="false" class="mb-3 proCard">
+      <el-space align="center">
+        <el-input-group style="width: 380px">
+          <el-input
+            :style="{ width: '100%' }"
+            v-model:value="params.operator"
+            clearable
+            placeholder="请输入操作人"
+            @keyup.enter="reloadTable"
+          />
+          <el-button type="primary" @click="reloadTable">
+            <template #icon>
+              <el-icon>
+                <SearchOutlined />
+              </el-icon>
+            </template>
+            查询
+          </el-button>
+        </el-input-group>
+      </el-space>
+    </el-card>
+    <el-card :bordered="false" class="proCard">
+      <BasicTable
+        :columns="columns"
+        :request="loadDataTable"
+        :row-key="(row) => row.id"
+        ref="basicTableRef"
+      />
+    </el-card>
+  </PageWrapper>
+</template>
+
+<script lang="ts" setup>
+  import { reactive, ref } from 'vue';
+  import { BasicTable } from '@/components/Table';
+  import { operlogList } from '@/api/system/logs';
+  import { SearchOutlined } from '@vicons/antd';
+  import { columns } from './columns';
+
+  const basicTableRef = ref();
+  const tableData = ref();
+
+  const params = reactive({
+    operator: '',
+  });
+
+  const loadDataTable = async (res) => {
+    const result = await operlogList({ ...params, ...res });
+    tableData.value = result.list;
+    return result;
+  };
+
+  function reloadTable() {
+    basicTableRef.value.reload();
+  }
+</script>
+
+<style lang="scss" scoped></style>

+ 49 - 0
src/views/system/online/columns.ts

@@ -0,0 +1,49 @@
+import { h } from 'vue';
+import { BasicColumn } from '@/components/Table';
+
+export const columns: BasicColumn[] = [
+  {
+    label: '编号',
+    render(record) {
+      return h('span', {}, { default: () => `${record.index + 1}` });
+    },
+  },
+  {
+    label: '会话编号',
+    prop: 'token',
+    width: 300,
+  },
+  {
+    label: '用户名',
+    prop: 'username',
+  },
+  {
+    label: '部门名称',
+    prop: 'deptName',
+  },
+  {
+    label: '登录地址',
+    prop: 'loginIp',
+  },
+  {
+    label: '登录地点',
+    prop: 'loginAddr',
+  },
+  {
+    label: '浏览器',
+    prop: 'browser',
+  },
+  {
+    label: '操作系统',
+    prop: 'operatingSystem',
+  },
+  {
+    label: '登录设备',
+    prop: 'device',
+  },
+  {
+    label: '登录时间',
+    prop: 'loginTime',
+    width: 180,
+  },
+];

+ 94 - 0
src/views/system/online/online.vue

@@ -0,0 +1,94 @@
+<template>
+  <PageWrapper>
+    <el-card :bordered="false" class="mb-3 proCard">
+      <el-space align="center">
+        <el-input
+          :style="{ width: '320px' }"
+          v-model:value="params.keyword"
+          clearable
+          placeholder="请输入用户名称"
+          @keyup.enter="reloadTable"
+        />
+        <el-button type="primary" @click="reloadTable">
+          <template #icon>
+            <el-icon>
+              <SearchOutlined />
+            </el-icon>
+          </template>
+          查询
+        </el-button>
+      </el-space>
+    </el-card>
+    <el-card :bordered="false" class="proCard">
+      <BasicTable
+        :columns="columns"
+        :request="loadDataTable"
+        :row-key="(row) => row.userId"
+        ref="basicTableRef"
+        :actionColumn="actionColumn"
+        virtual-scroll
+      />
+    </el-card>
+  </PageWrapper>
+</template>
+
+<script lang="ts" setup>
+  import { h, reactive, ref } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import { BasicTable, TableAction, BasicColumn } from '@/components/Table';
+  import { onlineList, onlineLogOut } from '@/api/system/user';
+  import { SearchOutlined } from '@vicons/antd';
+  import { columns } from './columns';
+
+  const message = ElMessage;
+  const basicTableRef = ref();
+  const tableData = ref();
+
+  const params = reactive({
+    keyword: '',
+  });
+
+  const actionColumn: BasicColumn = reactive({
+    width: 100,
+    title: '操作',
+    prop: 'action',
+    fixed: 'right',
+    align: 'center',
+    render(record) {
+      return h(TableAction as any, {
+        actions: [
+          {
+            label: '强退',
+            type: 'error',
+            isConfirm: true,
+            popConfirm: {
+              onConfirm: handleLogOut.bind(null, record.row),
+              title: '您确定要强退吗?',
+              confirmButtonText: '确定',
+              cancelButtonText: '取消',
+            },
+          },
+        ],
+      });
+    },
+  });
+
+  const loadDataTable = async (res) => {
+    const result = await onlineList({ ...params, ...res });
+    tableData.value = result.list;
+    return result;
+  };
+
+  function reloadTable() {
+    basicTableRef.value.reload();
+  }
+
+  function handleLogOut(row) {
+    onlineLogOut({ userId: row.userId }).then(() => {
+      message.success('强退成功');
+      reloadTable();
+    });
+  }
+</script>
+
+<style lang="scss" scoped></style>

+ 170 - 0
src/views/system/tenant/CreateDrawer.vue

@@ -0,0 +1,170 @@
+<template>
+  <el-drawer v-model="isDrawer" :size="width" :title="title" @close="handleReset">
+    <el-form
+      :model="formParams"
+      :rules="rules"
+      ref="formRef"
+      label-placement="left"
+      :label-width="80"
+    >
+      <el-form-item label="租户编码" path="tenantCode">
+        <el-input placeholder="请输入租户编码" v-model:value="formParams.tenantCode" />
+      </el-form-item>
+      <el-form-item label="租户名称" path="tenantName">
+        <el-input placeholder="请输入租户名称" v-model:value="formParams.tenantName" />
+      </el-form-item>
+      <el-form-item label="开始时间" path="beginDate">
+        <el-date-picker v-model:value="formParams.beginDate" type="datetime" clearable />
+      </el-form-item>
+      <el-form-item label="结束时间" path="endDate">
+        <el-date-picker v-model:value="formParams.endDate" type="datetime" clearable />
+      </el-form-item>
+      <el-form-item label="状态" path="tenantStatus">
+        <el-radio-group v-model:value="formParams.tenantStatus" name="tenantStatusGroup">
+          <el-radio-button :key="0" :value="0">启用</el-radio-button>
+          <el-radio-button :key="1" :value="1">禁用</el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-space>
+        <el-button @click="handleReset">重置</el-button>
+        <el-button type="primary" :loading="subLoading" @click="formSubmit">提交</el-button>
+      </el-space>
+    </template>
+  </el-drawer>
+</template>
+
+<script lang="ts" setup>
+  import { ref, onMounted } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import type { FormRules } from 'element-plus';
+  import type { formParamsType } from './types';
+  import { cloneDeep } from 'lodash-es';
+  import { addTenant, editTenant, tenantInfo } from '@/api/tenant/index';
+  import { formatToDateTime } from '@/utils/dateUtil';
+
+  const rules: FormRules = {
+    tenantCode: {
+      required: true,
+      message: '请填写租户编码',
+      trigger: 'blur',
+    },
+    tenantName: {
+      required: true,
+      message: '请填写租户名称',
+      trigger: 'blur',
+    },
+    beginDate: {
+      required: true,
+      message: '请选择开始时间',
+      trigger: 'change',
+      type: 'number',
+    },
+    endDate: {
+      required: true,
+      message: '请选择结束时间',
+      trigger: 'change',
+      type: 'number',
+    },
+  };
+
+  const emit = defineEmits(['change']);
+
+  defineProps({
+    title: {
+      type: String,
+      default: '添加租户',
+    },
+    width: {
+      type: Number,
+      default: 450,
+    },
+    permissionList: {
+      type: Array,
+    },
+  });
+
+  const defaultValueRef = () => ({
+    tenantId: null,
+    beginDate: null,
+    endDate: null,
+    tenantCode: '',
+    tenantStatus: 0,
+    tenantName: '',
+  });
+
+  const message = ElMessage;
+  const formRef: any = ref(null);
+  const isDrawer = ref(false);
+  const subLoading = ref(false);
+
+  const formParams = ref<formParamsType>(defaultValueRef());
+
+  function openDrawer(tenantId?) {
+    if (tenantId) {
+      formParams.value.tenantId = tenantId;
+      getInfo();
+      return;
+    }
+    isDrawer.value = true;
+  }
+
+  function closeDrawer() {
+    isDrawer.value = false;
+  }
+
+  function formSubmit() {
+    formRef.value.validate((errors) => {
+      if (!errors) {
+        const params = cloneDeep(formParams.value);
+        params.beginDate = formatToDateTime(params.beginDate);
+        params.endDate = formatToDateTime(params.endDate);
+        if (formParams.value.tenantId) {
+          editTenant(params).then((_) => {
+            message.success('编辑成功');
+            emit('change');
+            handleReset();
+            closeDrawer();
+          });
+        } else {
+          addTenant(params).then((_) => {
+            message.success('添加成功');
+            emit('change');
+            handleReset();
+            closeDrawer();
+          });
+        }
+      } else {
+        message.error('请填写完整信息');
+      }
+    });
+  }
+
+  function handleReset() {
+    formRef.value.restoreValidation();
+    formParams.value = Object.assign(formParams.value, defaultValueRef());
+  }
+
+  function getInfo() {
+    tenantInfo({ id: formParams.value.tenantId }).then((res) => {
+      const info = {
+        tenantId: res.tenantId,
+        tenantName: res.tenantName,
+        tenantCode: res.tenantCode,
+        beginDate: new Date(res.beginDate).getTime(),
+        endDate: new Date(res.endDate).getTime(),
+        tenantStatus: res.tenantStatus,
+      };
+      formParams.value = info;
+      isDrawer.value = true;
+    });
+  }
+
+  onMounted(() => {});
+
+  defineExpose({
+    openDrawer,
+    closeDrawer,
+  });
+</script>

+ 41 - 0
src/views/system/tenant/columns.ts

@@ -0,0 +1,41 @@
+import { BasicColumn } from '@/components/Table';
+import { ElTag } from 'element-plus';
+import { h } from 'vue';
+
+export const columns: BasicColumn[] = [
+  {
+    label: '租户id',
+    prop: 'tenantId',
+  },
+  {
+    label: '租户编码',
+    prop: 'tenantCode',
+  },
+  {
+    label: '租户名称',
+    prop: 'tenantName',
+  },
+  {
+    label: '状态',
+    prop: 'tenantStatus',
+    render(record) {
+      return h(
+        ElTag,
+        {
+          type: record.row.tenantStatus === 0 ? 'success' : 'danger',
+        },
+        {
+          default: () => (record.row.tenantStatus === 0 ? '启用' : '停用'),
+        },
+      );
+    },
+  },
+  {
+    label: '开始时间',
+    prop: 'beginDate',
+  },
+  {
+    label: '结束时间',
+    prop: 'endDate',
+  },
+];

+ 141 - 0
src/views/system/tenant/tenant.vue

@@ -0,0 +1,141 @@
+<template>
+  <page-wrapper>
+    <el-card :bordered="false" class="mb-3 proCard">
+      <el-space align="center">
+        <el-input-group style="width: 380px">
+          <el-input
+            :style="{ width: '100%' }"
+            v-model:value="params.tenantName"
+            clearable
+            placeholder="请输入租户名称"
+            @keyup.enter="reloadTable"
+          />
+          <el-button type="primary" @click="reloadTable">
+            <template #icon>
+              <el-icon>
+                <SearchOutlined />
+              </el-icon>
+            </template>
+            查询
+          </el-button>
+        </el-input-group>
+      </el-space>
+    </el-card>
+    <el-card :bordered="false" class="proCard">
+      <BasicTable
+        :columns="columns"
+        :request="loadDataTable"
+        :row-key="(row) => row.id"
+        ref="tableRef"
+        :actionColumn="actionColumn"
+        @update:checked-row-keys="onCheckedRow"
+      >
+        <template #tableTitle>
+          <el-button type="primary" @click="openCreateDrawer">
+            <template #icon>
+              <el-icon>
+                <FileAddOutlined />
+              </el-icon>
+            </template>
+            添加租户
+          </el-button>
+        </template>
+
+        <template #action>
+          <TableAction />
+        </template>
+      </BasicTable>
+    </el-card>
+
+    <CreateDrawer
+      ref="createDrawerRef"
+      :title="drawerTitle"
+      :permissionList="treeData"
+      @change="reloadTable"
+    />
+  </page-wrapper>
+</template>
+
+<script lang="ts" setup>
+  import { reactive, ref, unref, h, onMounted } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import { BasicTable, TableAction, BasicColumn } from '@/components/Table';
+  import { tenantList, deleteTenant } from '@/api/tenant/index';
+  import { columns } from './columns';
+  import { FileAddOutlined, SearchOutlined } from '@vicons/antd';
+  import CreateDrawer from './CreateDrawer.vue';
+
+  const message = ElMessage;
+  const tableRef = ref();
+  const createDrawerRef = ref();
+  const drawerTitle = ref('添加租户');
+  const treeData = ref([]);
+
+  const params = reactive({
+    tenantName: '',
+    tenantCode: '',
+  });
+
+  const actionColumn: BasicColumn = reactive({
+    width: 150,
+    title: '操作',
+    key: 'action',
+    fixed: 'right',
+    render(record) {
+      return h(TableAction as any, {
+        style: 'button',
+        actions: [
+          {
+            label: '删除',
+            onPositiveClick: handleDelete.bind(null, record),
+            isConfirm: true,
+            confirmContent: '您确定要删除吗?',
+          },
+          {
+            label: '编辑',
+            onClick: handleEdit.bind(null, record),
+          },
+        ],
+      });
+    },
+  });
+
+  const loadDataTable = async (res: any) => {
+    let _params = {
+      ...unref(params),
+      ...res,
+    };
+    return await tenantList(_params);
+  };
+
+  function openCreateDrawer() {
+    const { openDrawer } = createDrawerRef.value;
+    openDrawer();
+  }
+
+  function onCheckedRow(rowKeys: any[]) {
+    console.log(rowKeys);
+  }
+
+  function reloadTable() {
+    tableRef.value.reload();
+  }
+
+  function handleEdit(record: Recordable) {
+    console.log('点击了编辑', record);
+    drawerTitle.value = '编辑租户';
+    const { openDrawer } = createDrawerRef.value;
+    openDrawer(record.tenantId);
+  }
+
+  function handleDelete(record: Recordable) {
+    deleteTenant({ id: record.tenantId }).then(() => {
+      message.success('删除成功');
+      reloadTable();
+    });
+  }
+
+  onMounted(async () => {});
+</script>
+
+<style lang="scss" scoped></style>

+ 8 - 0
src/views/system/tenant/types/index.ts

@@ -0,0 +1,8 @@
+export interface formParamsType {
+  tenantId?: number | null;
+  tenantName: string;
+  tenantCode: string;
+  beginDate: number | null;
+  endDate: number | null;
+  tenantStatus: number | null;
+}