feat(app): 添加租户和组织架构管理功能

- 在AppLayout组件中添加租户、组织和日志菜单图标映射
- 新增Devices.css和Dictionaries.css样式文件
- 添加DictItemServiceTest和PasswordHashTest测试用例
- 扩展SysUser类型定义,增加tenantId、orgId和isPlatformAdmin字段
- 新增tenant和org相关的API接口和服务
- 实现Tenants和Orgs页面组件,提供完整的租户和组织管理界面
- 添加租户和组织管理路由配置
- 创建SysTenant和SysOrg实体类及对应的控制器、服务和数据访问层
- 实现组织架构树形展示和层级管理功能
master
chenhao 2026-02-12 14:20:54 +08:00
parent b138960f4b
commit 5b73b53de3
26 changed files with 1420 additions and 103 deletions

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -13,4 +13,5 @@ public class UserProfile {
private Integer status;
@JsonProperty("isAdmin")
private boolean isAdmin;
private Boolean isPlatformAdmin;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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);
}
}

View File

@ -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 {
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -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";

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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() {

View File

@ -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;
}

View File

@ -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%;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 selectedTenantId = Form.useWatch("tenantId", form);
const loadData = async () => {
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>
<div className="user-display-name">{record.displayName}</div>
<div className="user-username">@{record.username}</div>
<Space size={4}>
<div className="user-display-name">{record.displayName}</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">
<Input
placeholder="搜索用户名、姓名、邮箱或手机号..."
prefix={<SearchOutlined />}
className="users-search-input"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<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 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} />
</Form.Item>
<Form.Item
label="显示姓名"
name="displayName"
rules={[{ required: true, message: "请输入显示姓名" }]}
>
<Input placeholder="用户的真实姓名或昵称" />
</Form.Item>
<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>
<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,33 +424,33 @@ 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>
<Form.Item label="账号状态" name="status" initialValue={1}>
<Select
options={[
{ label: "正常启用", value: 1 },
{ label: "禁用账号", value: 0 },
]}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="账号状态" name="status" initialValue={1}>
<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>
);
}
}

View File

@ -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" },

View File

@ -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 {