feat(app): 添加租户和组织架构管理功能
- 在AppLayout组件中添加租户、组织和日志菜单图标映射 - 新增Devices.css和Dictionaries.css样式文件 - 添加DictItemServiceTest和PasswordHashTest测试用例 - 扩展SysUser类型定义,增加tenantId、orgId和isPlatformAdmin字段 - 新增tenant和org相关的API接口和服务 - 实现Tenants和Orgs页面组件,提供完整的租户和组织管理界面 - 添加租户和组织管理路由配置 - 创建SysTenant和SysOrg实体类及对应的控制器、服务和数据访问层 - 实现组织架构树形展示和层级管理功能master
parent
b138960f4b
commit
5b73b53de3
|
|
@ -0,0 +1,60 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.common.annotation.Log;
|
||||
import com.imeeting.entity.SysOrg;
|
||||
import com.imeeting.service.SysOrgService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/orgs")
|
||||
public class SysOrgController {
|
||||
private final SysOrgService sysOrgService;
|
||||
|
||||
public SysOrgController(SysOrgService sysOrgService) {
|
||||
this.sysOrgService = sysOrgService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_org:list')")
|
||||
public ApiResponse<List<SysOrg>> list(@RequestParam(required = false) Long tenantId) {
|
||||
return ApiResponse.ok(sysOrgService.listTree(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_org:query')")
|
||||
public ApiResponse<SysOrg> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysOrgService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_org:create')")
|
||||
@Log(value = "新增组织", type = "组织管理")
|
||||
public ApiResponse<Boolean> create(@RequestBody SysOrg org) {
|
||||
return ApiResponse.ok(sysOrgService.save(org));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_org:update')")
|
||||
@Log(value = "修改组织", type = "组织管理")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysOrg org) {
|
||||
org.setId(id);
|
||||
return ApiResponse.ok(sysOrgService.updateById(org));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_org:delete')")
|
||||
@Log(value = "删除组织", type = "组织管理")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
// Check if has children
|
||||
long count = sysOrgService.count(new LambdaQueryWrapper<SysOrg>().eq(SysOrg::getParentId, id));
|
||||
if (count > 0) {
|
||||
return ApiResponse.error("存在下级组织,无法删除");
|
||||
}
|
||||
return ApiResponse.ok(sysOrgService.removeById(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.imeeting.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.common.annotation.Log;
|
||||
import com.imeeting.entity.SysTenant;
|
||||
import com.imeeting.service.SysTenantService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenants")
|
||||
public class SysTenantController {
|
||||
private final SysTenantService sysTenantService;
|
||||
|
||||
public SysTenantController(SysTenantService sysTenantService) {
|
||||
this.sysTenantService = sysTenantService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_tenant:list')")
|
||||
public ApiResponse<Page<SysTenant>> list(
|
||||
@RequestParam(defaultValue = "1") Integer current,
|
||||
@RequestParam(defaultValue = "10") Integer size,
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String code
|
||||
) {
|
||||
LambdaQueryWrapper<SysTenant> query = new LambdaQueryWrapper<>();
|
||||
if (name != null && !name.isEmpty()) {
|
||||
query.like(SysTenant::getTenantName, name);
|
||||
}
|
||||
if (code != null && !code.isEmpty()) {
|
||||
query.like(SysTenant::getTenantCode, code);
|
||||
}
|
||||
query.orderByDesc(SysTenant::getCreatedAt);
|
||||
return ApiResponse.ok(sysTenantService.page(new Page<>(current, size), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_tenant:query')")
|
||||
public ApiResponse<SysTenant> get(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysTenantService.getById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_tenant:create')")
|
||||
@Log(value = "新增租户", type = "租户管理")
|
||||
public ApiResponse<Boolean> create(@RequestBody SysTenant tenant) {
|
||||
return ApiResponse.ok(sysTenantService.save(tenant));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_tenant:update')")
|
||||
@Log(value = "修改租户", type = "租户管理")
|
||||
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysTenant tenant) {
|
||||
tenant.setId(id);
|
||||
return ApiResponse.ok(sysTenantService.updateById(tenant));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermi('sys_tenant:delete')")
|
||||
@Log(value = "删除租户", type = "租户管理")
|
||||
public ApiResponse<Boolean> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(sysTenantService.removeById(id));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.imeeting.controller;
|
|||
import com.imeeting.auth.JwtTokenProvider;
|
||||
import com.imeeting.common.ApiResponse;
|
||||
import com.imeeting.dto.UserProfile;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.security.LoginUser;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.imeeting.entity.SysUser;
|
||||
|
|
@ -36,8 +37,16 @@ public class UserController {
|
|||
|
||||
@GetMapping
|
||||
@PreAuthorize("@ss.hasPermi('sys_user:list')")
|
||||
public ApiResponse<List<SysUser>> list() {
|
||||
return ApiResponse.ok(sysUserService.list());
|
||||
public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
|
||||
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<>();
|
||||
if (tenantId != null) {
|
||||
query.eq(SysUser::getTenantId, tenantId);
|
||||
}
|
||||
if (orgId != null) {
|
||||
query.eq(SysUser::getOrgId, orgId);
|
||||
}
|
||||
query.eq(SysUser::getIsDeleted, 0);
|
||||
return ApiResponse.ok(sysUserService.list(query));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
|
|
@ -61,6 +70,7 @@ public class UserController {
|
|||
profile.setPhone(user.getPhone());
|
||||
profile.setStatus(user.getStatus());
|
||||
profile.setAdmin(userId == 1L);
|
||||
profile.setIsPlatformAdmin(user.getIsPlatformAdmin());
|
||||
return ApiResponse.ok(profile);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ public class UserProfile {
|
|||
private Integer status;
|
||||
@JsonProperty("isAdmin")
|
||||
private boolean isAdmin;
|
||||
private Boolean isPlatformAdmin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("sys_org")
|
||||
public class SysOrg extends BaseEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long tenantId;
|
||||
private Long parentId;
|
||||
private String orgName;
|
||||
private String orgCode;
|
||||
private String orgPath;
|
||||
private Integer sortOrder;
|
||||
|
||||
@TableField(exist = false)
|
||||
private Integer isDeleted;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.imeeting.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("sys_tenant")
|
||||
public class SysTenant extends BaseEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String tenantCode;
|
||||
private String tenantName;
|
||||
private LocalDateTime expireTime;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String remark;
|
||||
|
||||
@TableField(exist = false)
|
||||
private Long tenantId;
|
||||
|
||||
@TableField(exist = false)
|
||||
private Integer isDeleted;
|
||||
}
|
||||
|
|
@ -15,4 +15,8 @@ public class SysUser extends BaseEntity {
|
|||
private String email;
|
||||
private String phone;
|
||||
private String passwordHash;
|
||||
|
||||
private Long tenantId;
|
||||
private Long orgId;
|
||||
private Boolean isPlatformAdmin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysOrg;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysOrgMapper extends BaseMapper<SysOrg> {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.imeeting.entity.SysTenant;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface SysTenantMapper extends BaseMapper<SysTenant> {
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysOrg;
|
||||
import java.util.List;
|
||||
|
||||
public interface SysOrgService extends IService<SysOrg> {
|
||||
List<SysOrg> listTree(Long tenantId);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.imeeting.entity.SysTenant;
|
||||
|
||||
public interface SysTenantService extends IService<SysTenant> {
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysOrg;
|
||||
import com.imeeting.mapper.SysOrgMapper;
|
||||
import com.imeeting.service.SysOrgService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class SysOrgServiceImpl extends ServiceImpl<SysOrgMapper, SysOrg> implements SysOrgService {
|
||||
@Override
|
||||
public List<SysOrg> listTree(Long tenantId) {
|
||||
LambdaQueryWrapper<SysOrg> query = new LambdaQueryWrapper<>();
|
||||
if (tenantId != null) {
|
||||
query.eq(SysOrg::getTenantId, tenantId);
|
||||
}
|
||||
query.orderByAsc(SysOrg::getSortOrder);
|
||||
return list(query);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.imeeting.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.imeeting.entity.SysTenant;
|
||||
import com.imeeting.mapper.SysTenantMapper;
|
||||
import com.imeeting.service.SysTenantService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant> implements SysTenantService {
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.imeeting.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
public class PasswordHashTest {
|
||||
|
||||
@Test
|
||||
void bcryptHashMatches() {
|
||||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
|
||||
String raw = "admin@123";
|
||||
String hashed = encoder.encode(raw);
|
||||
System.out.println("BCrypt(admin@123)=" + hashed);
|
||||
assertTrue(encoder.matches(raw, hashed));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.imeeting.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.entity.SysDictItem;
|
||||
import com.imeeting.mapper.SysDictItemMapper;
|
||||
import com.imeeting.service.impl.SysDictItemServiceImpl;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class DictItemServiceTest {
|
||||
|
||||
@Mock
|
||||
private SysDictItemMapper dictItemMapper;
|
||||
|
||||
@InjectMocks
|
||||
private SysDictItemServiceImpl dictItemService;
|
||||
|
||||
@Test
|
||||
void testGetItemsByTypeCode() {
|
||||
String typeCode = "gender";
|
||||
SysDictItem item = new SysDictItem();
|
||||
item.setTypeCode(typeCode);
|
||||
item.setItemLabel("Male");
|
||||
item.setItemValue("1");
|
||||
item.setStatus(1);
|
||||
item.setSortOrder(1);
|
||||
|
||||
when(dictItemMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(item));
|
||||
|
||||
List<SysDictItem> result = dictItemService.getItemsByTypeCode(typeCode);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("Male", result.get(0).getItemLabel());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import http from "./http";
|
||||
import { DeviceInfo, SysPermission, SysRole, SysUser, UserProfile } from "../types";
|
||||
|
||||
export async function listUsers() {
|
||||
const resp = await http.get("/api/users");
|
||||
export async function listUsers(params?: { tenantId?: number; orgId?: number }) {
|
||||
const resp = await http.get("/api/users", { params });
|
||||
return resp.data.data as SysUser[];
|
||||
}
|
||||
|
||||
|
|
@ -133,4 +133,6 @@ export async function fetchLogs(params: any) {
|
|||
}
|
||||
|
||||
export * from "./dict";
|
||||
export * from "./tenant";
|
||||
export * from "./org";
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import http from "./http";
|
||||
import { SysOrg } from "../types";
|
||||
|
||||
export async function listOrgs(tenantId?: number) {
|
||||
const resp = await http.get("/api/orgs", { params: { tenantId } });
|
||||
return resp.data.data as SysOrg[];
|
||||
}
|
||||
|
||||
export async function getOrg(id: number) {
|
||||
const resp = await http.get(`/api/orgs/${id}`);
|
||||
return resp.data.data as SysOrg;
|
||||
}
|
||||
|
||||
export async function createOrg(data: Partial<SysOrg>) {
|
||||
const resp = await http.post("/api/orgs", data);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function updateOrg(id: number, data: Partial<SysOrg>) {
|
||||
const resp = await http.put(`/api/orgs/${id}`, data);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function deleteOrg(id: number) {
|
||||
const resp = await http.delete(`/api/orgs/${id}`);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import http from "./http";
|
||||
import { SysTenant } from "../types";
|
||||
|
||||
export async function listTenants(params: any) {
|
||||
const resp = await http.get("/api/tenants", { params });
|
||||
return resp.data.data;
|
||||
}
|
||||
|
||||
export async function getTenant(id: number) {
|
||||
const resp = await http.get(`/api/tenants/${id}`);
|
||||
return resp.data.data as SysTenant;
|
||||
}
|
||||
|
||||
export async function createTenant(data: Partial<SysTenant>) {
|
||||
const resp = await http.post("/api/tenants", data);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function updateTenant(id: number, data: Partial<SysTenant>) {
|
||||
const resp = await http.put(`/api/tenants/${id}`, data);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
||||
export async function deleteTenant(id: number) {
|
||||
const resp = await http.delete(`/api/tenants/${id}`);
|
||||
return resp.data.data as boolean;
|
||||
}
|
||||
|
|
@ -3,16 +3,19 @@ import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||
import { useEffect, useState } from "react";
|
||||
import { fetchMyMenuTree } from "../api";
|
||||
import { PermissionNode } from "../types";
|
||||
import { LogoutOutlined, HomeOutlined, SettingOutlined, UserOutlined, SafetyOutlined, ClusterOutlined, BookOutlined, DesktopOutlined } from "@ant-design/icons";
|
||||
import { LogoutOutlined, HomeOutlined, SettingOutlined, UserOutlined, SafetyOutlined, ClusterOutlined, BookOutlined, DesktopOutlined, ShopOutlined, InfoCircleOutlined, ApartmentOutlined } from "@ant-design/icons";
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
'home': <HomeOutlined />,
|
||||
'tenant': <ShopOutlined />,
|
||||
'org': <ApartmentOutlined />,
|
||||
'user': <UserOutlined />,
|
||||
'role': <SafetyOutlined />,
|
||||
'permission': <ClusterOutlined />,
|
||||
'dict': <BookOutlined />,
|
||||
'device': <DesktopOutlined />,
|
||||
'setting': <SettingOutlined />
|
||||
'setting': <SettingOutlined />,
|
||||
'logs': <InfoCircleOutlined />
|
||||
};
|
||||
|
||||
export default function AppLayout() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
.devices-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.devices-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.devices-title {
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.devices-table-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.devices-table-toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.devices-search-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.device-icon-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #f0f5ff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1890ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.device-code {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.device-drawer-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
.dictionaries-page {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dictionaries-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dictionaries-title {
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.dictionaries-content {
|
||||
flex: 1;
|
||||
min-height: 0; /* Important for flex child scroll */
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.full-height-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.full-height-card .ant-card-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0; /* Remove padding for scroll container */
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dict-type-row {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.dict-type-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dict-type-row-selected {
|
||||
background-color: #e6f7ff !important;
|
||||
border-right: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.dict-type-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.dict-type-name {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.dict-type-code {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.dict-type-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.dict-type-row:hover .dict-type-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
InputNumber,
|
||||
Row,
|
||||
Col,
|
||||
Select,
|
||||
Empty
|
||||
} from "antd";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { createOrg, deleteOrg, listOrgs, updateOrg, listTenants } from "../api";
|
||||
import { usePermission } from "../hooks/usePermission";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ApartmentOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
ShopOutlined
|
||||
} from "@ant-design/icons";
|
||||
import type { SysOrg, SysTenant, OrgNode } from "../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function buildOrgTree(list: SysOrg[]): OrgNode[] {
|
||||
const map = new Map<number, OrgNode>();
|
||||
const roots: OrgNode[] = [];
|
||||
|
||||
list.forEach((item) => {
|
||||
map.set(item.id, { ...item, children: [] });
|
||||
});
|
||||
|
||||
map.forEach((node) => {
|
||||
if (node.parentId && map.has(node.parentId)) {
|
||||
map.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
const sortTree = (nodes: OrgNode[]) => {
|
||||
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||
nodes.forEach(n => n.children && sortTree(n.children));
|
||||
};
|
||||
sortTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export default function Orgs() {
|
||||
const { can } = usePermission();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [data, setData] = useState<SysOrg[]>([]);
|
||||
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState<number | undefined>(undefined);
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysOrg | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadTenants = async () => {
|
||||
try {
|
||||
const resp = await listTenants({ current: 1, size: 100 });
|
||||
const list = resp.records || [];
|
||||
setTenants(list);
|
||||
if (list.length > 0 && selectedTenantId === undefined) {
|
||||
setSelectedTenantId(list[0].id);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error("加载租户列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrgs = async () => {
|
||||
if (selectedTenantId === undefined) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await listOrgs(selectedTenantId);
|
||||
setData(list || []);
|
||||
} catch (e) {
|
||||
message.error("加载组织架构失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTenants();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOrgs();
|
||||
}, [selectedTenantId]);
|
||||
|
||||
const treeData = useMemo(() => buildOrgTree(data), [data]);
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
return data.map(o => ({ label: o.orgName, value: o.id }));
|
||||
}, [data]);
|
||||
|
||||
const openCreate = (parentId?: number) => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
tenantId: selectedTenantId,
|
||||
parentId: parentId,
|
||||
status: 1,
|
||||
sortOrder: 0
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: SysOrg) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteOrg(id);
|
||||
message.success("组织已删除");
|
||||
loadOrgs();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
if (editing) {
|
||||
await updateOrg(editing.id, values);
|
||||
message.success("更新成功");
|
||||
} else {
|
||||
await createOrg(values);
|
||||
message.success("创建成功");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
loadOrgs();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "组织名称",
|
||||
dataIndex: "orgName",
|
||||
key: "orgName",
|
||||
render: (text: string) => <Text strong>{text}</Text>
|
||||
},
|
||||
{
|
||||
title: "组织编码",
|
||||
dataIndex: "orgCode",
|
||||
key: "orgCode",
|
||||
width: 150,
|
||||
render: (text: string) => <Tag className="tabular-nums">{text || "-"}</Tag>
|
||||
},
|
||||
{
|
||||
title: "排序",
|
||||
dataIndex: "sortOrder",
|
||||
width: 100,
|
||||
className: "tabular-nums"
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (s: number) => <Tag color={s === 1 ? "green" : "red"}>{s === 1 ? "启用" : "禁用"}</Tag>
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 180,
|
||||
render: (_: any, record: SysOrg) => (
|
||||
<Space>
|
||||
{can("sys_org:create") && (
|
||||
<Button type="link" size="small" onClick={() => openCreate(record.id)}>添加下级</Button>
|
||||
)}
|
||||
{can("sys_org:update") && (
|
||||
<Button type="text" size="small" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label="编辑组织" />
|
||||
)}
|
||||
{can("sys_org:delete") && (
|
||||
<Popconfirm title={`确定删除 "${record.orgName}" 吗?`} onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label="删除组织" />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex justify-between items-end">
|
||||
<div>
|
||||
<Title level={4} className="mb-1">组织架构管理</Title>
|
||||
<Text type="secondary">维护企业内部部门层级关系,支持多租户架构隔离</Text>
|
||||
</div>
|
||||
{can("sys_org:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
|
||||
新增根组织
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm mb-4">
|
||||
<Space>
|
||||
<Text strong>所属租户:</Text>
|
||||
<Select
|
||||
style={{ width: 220 }}
|
||||
placeholder="切换租户查看架构"
|
||||
value={selectedTenantId}
|
||||
onChange={setSelectedTenantId}
|
||||
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>刷新</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
|
||||
{selectedTenantId !== undefined ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
expandable={{ defaultExpandAllRows: true }}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-20 flex justify-center">
|
||||
<Empty description="请先选择一个租户以查看其组织架构" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<ApartmentOutlined aria-hidden="true" />
|
||||
<span>{editing ? "编辑组织节点" : "新增组织部门"}</span>
|
||||
</Space>
|
||||
}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={420}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="flex justify-end gap-2 p-2">
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={submit}>确认提交</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="所属租户" name="tenantId" rules={[{ required: true }]}>
|
||||
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上级部门" name="parentId">
|
||||
<Select
|
||||
placeholder="顶级部门"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={parentOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="部门名称" name="orgName" rules={[{ required: true, message: "请输入部门/组织名称" }]}>
|
||||
<Input placeholder="例如:技术部、财务处…" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="部门编码" name="orgCode">
|
||||
<Input placeholder="例如:DEPT_TECH" className="tabular-nums" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="显示排序" name="sortOrder" initialValue={0}>
|
||||
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="状态" name="status" initialValue={1}>
|
||||
<Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
DatePicker,
|
||||
Row,
|
||||
Col,
|
||||
Select
|
||||
} from "antd";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { createTenant, deleteTenant, listTenants, updateTenant } from "../api";
|
||||
import { usePermission } from "../hooks/usePermission";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
ShopOutlined,
|
||||
CalendarOutlined,
|
||||
PhoneOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
import type { SysTenant } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function Tenants() {
|
||||
const { can } = usePermission();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [data, setData] = useState<SysTenant[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [params, setParams] = useState({
|
||||
current: 1,
|
||||
size: 10,
|
||||
name: "",
|
||||
code: ""
|
||||
});
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysTenant | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = async (currentParams = params) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listTenants(currentParams);
|
||||
setData(result.records || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (e) {
|
||||
message.error("加载租户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [params.current, params.size]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setParams({ ...params, current: 1 });
|
||||
loadData({ ...params, current: 1 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const resetParams = {
|
||||
current: 1,
|
||||
size: 10,
|
||||
name: "",
|
||||
code: ""
|
||||
};
|
||||
setParams(resetParams);
|
||||
loadData(resetParams);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 1 });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: SysTenant) => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
expireTime: record.expireTime ? dayjs(record.expireTime) : null
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteTenant(id);
|
||||
message.success("租户已删除");
|
||||
loadData();
|
||||
} catch (e) {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
...values,
|
||||
expireTime: values.expireTime ? values.expireTime.format("YYYY-MM-DD HH:mm:ss") : null
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateTenant(editing.id, payload);
|
||||
message.success("租户信息已更新");
|
||||
} else {
|
||||
await createTenant(payload);
|
||||
message.success("租户已成功创建");
|
||||
}
|
||||
setDrawerOpen(false);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "租户信息",
|
||||
key: "tenant",
|
||||
render: (_: any, record: SysTenant) => (
|
||||
<Space>
|
||||
<div style={{ width: 40, height: 40, background: '#f0f5ff', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#1890ff', fontSize: 20 }}>
|
||||
<ShopOutlined aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#262626' }}>{record.tenantName}</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="tabular-nums">{record.tenantCode}</div>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "联系人",
|
||||
key: "contact",
|
||||
render: (_: any, record: SysTenant) => (
|
||||
<div>
|
||||
<div><UserOutlined style={{ marginRight: 4, color: '#8c8c8c' }} />{record.contactName || "-"}</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="tabular-nums"><PhoneOutlined style={{ marginRight: 4 }} />{record.contactPhone || "-"}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 1 ? "green" : "red"}>
|
||||
{status === 1 ? "正常" : "禁用"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "过期时间",
|
||||
dataIndex: "expireTime",
|
||||
width: 180,
|
||||
render: (text: string) => (
|
||||
<Space>
|
||||
<CalendarOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Text className="tabular-nums">{text ? text.substring(0, 10) : "永久有效"}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 120,
|
||||
fixed: "right" as const,
|
||||
render: (_: any, record: SysTenant) => (
|
||||
<Space>
|
||||
{can("sys_tenant:update") && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined aria-hidden="true" />}
|
||||
onClick={() => openEdit(record)}
|
||||
aria-label={`编辑租户 ${record.tenantName}`}
|
||||
/>
|
||||
)}
|
||||
{can("sys_tenant:delete") && (
|
||||
<Popconfirm title={`确定删除租户 "${record.tenantName}" 吗?`} onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={`删除租户 ${record.tenantName}`} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex justify-between items-end">
|
||||
<div>
|
||||
<Title level={4} className="mb-1">租户管理</Title>
|
||||
<Text type="secondary">管理系统多租户基础信息、授权状态及过期时间</Text>
|
||||
</div>
|
||||
{can("sys_tenant:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
新增租户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm mb-4">
|
||||
<Space wrap size="middle">
|
||||
<Input
|
||||
placeholder="租户名称…"
|
||||
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
||||
style={{ width: 200 }}
|
||||
value={params.name}
|
||||
onChange={e => setParams({ ...params, name: e.target.value })}
|
||||
allowClear
|
||||
/>
|
||||
<Input
|
||||
placeholder="租户编码…"
|
||||
style={{ width: 180 }}
|
||||
value={params.code}
|
||||
onChange={e => setParams({ ...params, code: e.target.value })}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>查询</Button>
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: params.current,
|
||||
pageSize: params.size,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => setParams({ ...params, current: page, size }),
|
||||
showTotal: (total) => `共 ${total} 条数据`
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<ShopOutlined aria-hidden="true" />
|
||||
<span>{editing ? "编辑租户信息" : "创建新租户"}</span>
|
||||
</Space>
|
||||
}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={480}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="flex justify-end gap-2 p-2">
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={submit}>确认提交</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="租户名称" name="tenantName" rules={[{ required: true, message: "请输入租户名称" }]}>
|
||||
<Input placeholder="例如:云合智慧" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="租户编码" name="tenantCode" rules={[{ required: true, message: "请输入租户编码" }]}>
|
||||
<Input placeholder="例如:UNIS" disabled={!!editing} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="联系人姓名" name="contactName">
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="联系电话" name="contactPhone">
|
||||
<Input placeholder="手机或座机" className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="过期时间" name="expireTime">
|
||||
<DatePicker style={{ width: "100%" }} placeholder="留空为永久有效" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="租户状态" name="status" initialValue={1}>
|
||||
<Select options={[{ label: "正常启用", value: 1 }, { label: "禁止访问", value: 0 }]} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="备注说明" name="remark">
|
||||
<Input.TextArea rows={3} placeholder="选填,租户详细背景说明…" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ import {
|
|||
Typography,
|
||||
Card,
|
||||
Row,
|
||||
Col
|
||||
Col,
|
||||
Switch,
|
||||
TreeSelect
|
||||
} from "antd";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
|
|
@ -22,7 +24,9 @@ import {
|
|||
listUserRoles,
|
||||
listUsers,
|
||||
saveUserRoles,
|
||||
updateUser
|
||||
updateUser,
|
||||
listTenants,
|
||||
listOrgs
|
||||
} from "../api";
|
||||
import { usePermission } from "../hooks/usePermission";
|
||||
import {
|
||||
|
|
@ -30,45 +34,107 @@ import {
|
|||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined
|
||||
UserOutlined,
|
||||
ShopOutlined,
|
||||
ApartmentOutlined,
|
||||
ReloadOutlined
|
||||
} from "@ant-design/icons";
|
||||
import type { SysRole, SysUser } from "../types";
|
||||
import type { SysRole, SysUser, SysTenant, SysOrg, OrgNode } from "../types";
|
||||
import "./Users.css";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function buildOrgTree(list: SysOrg[]): any[] {
|
||||
const map = new Map<number, any>();
|
||||
const roots: any[] = [];
|
||||
|
||||
list.forEach((item) => {
|
||||
map.set(item.id, { value: item.id, title: item.orgName, children: [] });
|
||||
});
|
||||
|
||||
map.forEach((node, id) => {
|
||||
const item = list.find(o => o.id === id);
|
||||
if (item?.parentId && map.has(item.parentId)) {
|
||||
map.get(item.parentId).children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { can } = usePermission();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [data, setData] = useState<SysUser[]>([]);
|
||||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
const [tenants, setTenants] = useState<SysTenant[]>([]);
|
||||
const [orgs, setOrgs] = useState<SysOrg[]>([]);
|
||||
|
||||
// Search state
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
||||
|
||||
// Drawer state
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SysUser | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const loadData = async () => {
|
||||
const selectedTenantId = Form.useWatch("tenantId", form);
|
||||
|
||||
const loadBaseData = async () => {
|
||||
try {
|
||||
const [rolesList, tenantsResp] = await Promise.all([
|
||||
listRoles(),
|
||||
listTenants({ current: 1, size: 1000 })
|
||||
]);
|
||||
setRoles(rolesList || []);
|
||||
setTenants(tenantsResp.records || []);
|
||||
} catch (e) {
|
||||
message.error("加载基础数据失败");
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsersData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersList, rolesList] = await Promise.all([listUsers(), listRoles()]);
|
||||
const usersList = await listUsers({ tenantId: filterTenantId });
|
||||
setData(usersList || []);
|
||||
setRoles(rolesList || []);
|
||||
} catch (e) {
|
||||
message.error("加载数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadBaseData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsersData();
|
||||
}, [filterTenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrgs = async () => {
|
||||
if (selectedTenantId) {
|
||||
const list = await listOrgs(selectedTenantId);
|
||||
setOrgs(list || []);
|
||||
} else {
|
||||
setOrgs([]);
|
||||
}
|
||||
};
|
||||
fetchOrgs();
|
||||
}, [selectedTenantId]);
|
||||
|
||||
const tenantMap = useMemo(() => {
|
||||
const map: Record<number, string> = {};
|
||||
tenants.forEach(t => map[t.id] = t.tenantName);
|
||||
return map;
|
||||
}, [tenants]);
|
||||
|
||||
const orgTreeData = useMemo(() => buildOrgTree(orgs), [orgs]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchText) return data;
|
||||
const lower = searchText.toLowerCase();
|
||||
|
|
@ -76,15 +142,14 @@ export default function Users() {
|
|||
(u) =>
|
||||
u.username.toLowerCase().includes(lower) ||
|
||||
u.displayName.toLowerCase().includes(lower) ||
|
||||
(u.email && u.email.toLowerCase().includes(lower)) ||
|
||||
(u.phone && u.phone.includes(lower))
|
||||
(u.email && u.email.toLowerCase().includes(lower))
|
||||
);
|
||||
}, [data, searchText]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 1, roleIds: [] });
|
||||
form.setFieldsValue({ status: 1, roleIds: [], isPlatformAdmin: false });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -92,24 +157,29 @@ export default function Users() {
|
|||
setEditing(record);
|
||||
try {
|
||||
const roleIds = await listUserRoles(record.userId);
|
||||
// Ensure orgs for the tenant are loaded
|
||||
if (record.tenantId) {
|
||||
const orgList = await listOrgs(record.tenantId);
|
||||
setOrgs(orgList || []);
|
||||
}
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
roleIds: roleIds || [],
|
||||
password: "" // Clear password field
|
||||
password: ""
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
} catch (e) {
|
||||
message.error("获取用户角色失败");
|
||||
message.error("获取用户信息详情失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteUser(id);
|
||||
message.success("用户已删除");
|
||||
loadData();
|
||||
message.success("用户已移除");
|
||||
loadUsersData();
|
||||
} catch (e) {
|
||||
message.error("删除失败");
|
||||
message.error("操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -124,6 +194,9 @@ export default function Users() {
|
|||
email: values.email,
|
||||
phone: values.phone,
|
||||
status: values.status,
|
||||
tenantId: values.tenantId,
|
||||
orgId: values.orgId,
|
||||
isPlatformAdmin: values.isPlatformAdmin
|
||||
};
|
||||
|
||||
if (values.password) {
|
||||
|
|
@ -134,11 +207,7 @@ export default function Users() {
|
|||
if (editing) {
|
||||
await updateUser(editing.userId, userPayload);
|
||||
} else {
|
||||
// We need the new user ID to save roles.
|
||||
// Our API returns boolean, so we might need to find the user after creation if backend doesn't return ID.
|
||||
// However, looking at the list request after create is common.
|
||||
await createUser(userPayload);
|
||||
// Refresh list to find the newly created user (by username)
|
||||
const updatedList = await listUsers();
|
||||
const newUser = updatedList.find(u => u.username === userPayload.username);
|
||||
userId = newUser?.userId;
|
||||
|
|
@ -148,13 +217,11 @@ export default function Users() {
|
|||
await saveUserRoles(userId, values.roleIds || []);
|
||||
}
|
||||
|
||||
message.success(editing ? "用户信息已更新" : "用户已创建");
|
||||
message.success(editing ? "更新成功" : "创建成功");
|
||||
setDrawerOpen(false);
|
||||
loadData();
|
||||
loadUsersData();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) {
|
||||
message.error(e.message);
|
||||
}
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -170,55 +237,61 @@ export default function Users() {
|
|||
<UserOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<div className="user-display-name">{record.displayName}</div>
|
||||
<div className="user-username">@{record.username}</div>
|
||||
{record.isPlatformAdmin && <Tag color="gold" size="small" style={{ fontSize: 10 }}>平台管理</Tag>}
|
||||
</Space>
|
||||
<div className="user-username tabular-nums">@{record.username}</div>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "联系方式",
|
||||
key: "contact",
|
||||
title: "所属租户/组织",
|
||||
key: "org",
|
||||
render: (_: any, record: SysUser) => (
|
||||
<div>
|
||||
<div>{record.email || "-"}</div>
|
||||
<div className="user-phone">{record.phone || "-"}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Space size={4} style={{ fontSize: 13 }}>
|
||||
<ShopOutlined style={{ color: '#8c8c8c' }} />
|
||||
<span>{tenantMap[record.tenantId] || "未知租户"}</span>
|
||||
</Space>
|
||||
{record.orgId && (
|
||||
<Space size={4} style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
<ApartmentOutlined />
|
||||
<span>{orgs.find(o => o.id === record.orgId)?.orgName || "组织节点"}</span>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 100,
|
||||
width: 80,
|
||||
render: (status: number) => (
|
||||
<Tag color={status === 1 ? "green" : "red"}>
|
||||
<Tag color={status === 1 ? "green" : "red"} className="m-0">
|
||||
{status === 1 ? "正常" : "禁用"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
width: 180,
|
||||
render: (text: string) => <Text type="secondary">{text?.replace('T', ' ').substring(0, 19)}</Text>
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 120,
|
||||
width: 100,
|
||||
fixed: "right" as const,
|
||||
render: (_: any, record: SysUser) => (
|
||||
<Space>
|
||||
{can("sys_user:update") && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
icon={<EditOutlined aria-hidden="true" />}
|
||||
onClick={() => openEdit(record)}
|
||||
aria-label={`编辑用户 ${record.displayName}`}
|
||||
/>
|
||||
)}
|
||||
{can("sys_user:delete") && record.userId !== 1 && (
|
||||
<Popconfirm title="确定删除该用户吗?" onConfirm={() => handleDelete(record.userId)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
<Popconfirm title="确定注销该用户吗?" onConfirm={() => handleDelete(record.userId)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={`删除用户 ${record.displayName}`} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
|
|
@ -230,26 +303,39 @@ export default function Users() {
|
|||
<div className="users-page">
|
||||
<div className="users-header">
|
||||
<div>
|
||||
<Title level={4} className="users-title">用户管理</Title>
|
||||
<Text type="secondary">维护系统用户信息及其所属角色</Text>
|
||||
<Title level={4} className="users-title">系统用户管理</Title>
|
||||
<Text type="secondary">维护系统多租户下的用户信息、组织归属及权限角色</Text>
|
||||
</div>
|
||||
{can("sys_user:create") && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||
新增用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="users-table-card">
|
||||
<Card className="users-table-card shadow-sm">
|
||||
<div className="users-table-toolbar">
|
||||
<Space size="middle">
|
||||
<Select
|
||||
placeholder="按租户筛选…"
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
value={filterTenantId}
|
||||
onChange={setFilterTenantId}
|
||||
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||
suffixIcon={<ShopOutlined aria-hidden="true" />}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索用户名、姓名、邮箱或手机号..."
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索用户名、姓名或邮箱…"
|
||||
prefix={<SearchOutlined aria-hidden="true" />}
|
||||
className="users-search-input"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
aria-label="搜索用户"
|
||||
/>
|
||||
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadUsersData}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
|
|
@ -257,6 +343,7 @@ export default function Users() {
|
|||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
pagination={{
|
||||
showTotal: (total) => `共 ${total} 条数据`,
|
||||
pageSize: 10,
|
||||
|
|
@ -267,16 +354,16 @@ export default function Users() {
|
|||
<Drawer
|
||||
title={
|
||||
<div className="user-drawer-title">
|
||||
<UserOutlined className="mr-2" />
|
||||
{editing ? "编辑用户信息" : "创建新用户"}
|
||||
<UserOutlined className="mr-2" aria-hidden="true" />
|
||||
{editing ? "修改用户信息" : "创建系统用户"}
|
||||
</div>
|
||||
}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={480}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
footer={
|
||||
<div className="user-drawer-footer">
|
||||
<div className="flex justify-end gap-2 p-2">
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={submit}>
|
||||
保存更改
|
||||
|
|
@ -285,31 +372,51 @@ export default function Users() {
|
|||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="user-form">
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
>
|
||||
<Input placeholder="登录凭证,创建后不可修改" disabled={!!editing} />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="所属租户" name="tenantId" rules={[{ required: true, message: "请选择所属租户" }]}>
|
||||
<Select
|
||||
placeholder="选择租户"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="所属组织" name="orgId">
|
||||
<TreeSelect
|
||||
placeholder="请选择组织节点"
|
||||
allowClear
|
||||
treeData={orgTreeData}
|
||||
disabled={!selectedTenantId}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="显示姓名"
|
||||
name="displayName"
|
||||
rules={[{ required: true, message: "请输入显示姓名" }]}
|
||||
>
|
||||
<Input placeholder="用户的真实姓名或昵称" />
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="用户名" name="username" rules={[{ required: true, message: "请输入用户名" }]}>
|
||||
<Input placeholder="登录账号" disabled={!!editing} className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="显示姓名" name="displayName" rules={[{ required: true, message: "请输入显示姓名" }]}>
|
||||
<Input placeholder="真实姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="邮箱地址" name="email">
|
||||
<Input placeholder="example@domain.com" />
|
||||
<Input placeholder="example@domain.com" className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="手机号码" name="phone">
|
||||
<Input placeholder="联系电话" />
|
||||
<Input placeholder="联系电话" className="tabular-nums" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
@ -317,31 +424,31 @@ export default function Users() {
|
|||
<Form.Item
|
||||
label="登录密码"
|
||||
name="password"
|
||||
rules={[{ required: !editing, message: "请输入初始密码" }]}
|
||||
rules={[{ required: !editing, message: "请输入登录密码" }]}
|
||||
>
|
||||
<Input.Password placeholder={editing ? "留空表示不修改密码" : "设置初始登录密码"} />
|
||||
<Input.Password placeholder={editing ? "留空表示不修改" : "设置初始密码"} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="所属角色"
|
||||
name="roleIds"
|
||||
rules={[{ required: true, message: "请至少选择一个角色" }]}
|
||||
>
|
||||
<Form.Item label="授予角色" name="roleIds" rules={[{ required: true, message: "请至少选择一个角色" }]}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择授予该用户的系统角色"
|
||||
placeholder="选择系统角色"
|
||||
options={roles.map(r => ({ label: r.roleName, value: r.roleId }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="账号状态" name="status" initialValue={1}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "正常启用", value: 1 },
|
||||
{ label: "禁用账号", value: 0 },
|
||||
]}
|
||||
/>
|
||||
<Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="平台管理员" name="isPlatformAdmin" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import Permissions from "../pages/Permissions";
|
|||
import Devices from "../pages/Devices";
|
||||
import Dictionaries from "../pages/Dictionaries";
|
||||
import Logs from "../pages/Logs";
|
||||
import Tenants from "../pages/Tenants";
|
||||
import Orgs from "../pages/Orgs";
|
||||
import UserRoleBinding from "../pages/UserRoleBinding";
|
||||
import RolePermissionBinding from "../pages/RolePermissionBinding";
|
||||
|
||||
|
|
@ -12,6 +14,8 @@ import type { MenuRoute } from "../types";
|
|||
|
||||
export const menuRoutes: MenuRoute[] = [
|
||||
{ path: "/", label: "总览", element: <Dashboard />, perm: "menu:dashboard" },
|
||||
{ path: "/tenants", label: "租户管理", element: <Tenants />, perm: "menu:tenants" },
|
||||
{ path: "/orgs", label: "组织管理", element: <Orgs />, perm: "menu:orgs" },
|
||||
{ path: "/users", label: "用户管理", element: <Users />, perm: "menu:users" },
|
||||
{ path: "/roles", label: "角色管理", element: <Roles />, perm: "menu:roles" },
|
||||
{ path: "/permissions", label: "权限管理", element: <Permissions />, perm: "menu:permissions" },
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface SysUser extends BaseEntity {
|
|||
email?: string;
|
||||
phone?: string;
|
||||
passwordHash?: string;
|
||||
tenantId: number;
|
||||
orgId?: number;
|
||||
isPlatformAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
|
|
@ -23,6 +26,7 @@ export interface UserProfile {
|
|||
phone?: string;
|
||||
status?: number;
|
||||
isAdmin: boolean;
|
||||
isPlatformAdmin?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -76,6 +80,30 @@ export interface SysDictItem extends BaseEntity {
|
|||
remark?: string;
|
||||
}
|
||||
|
||||
export interface SysTenant extends BaseEntity {
|
||||
id: number;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
expireTime?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface SysOrg extends BaseEntity {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
parentId?: number;
|
||||
orgName: string;
|
||||
orgCode?: string;
|
||||
orgPath?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface OrgNode extends SysOrg {
|
||||
children: OrgNode[];
|
||||
}
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface MenuRoute {
|
||||
|
|
|
|||
Loading…
Reference in New Issue