diff --git a/backend/src/main/java/com/imeeting/controller/SysOrgController.java b/backend/src/main/java/com/imeeting/controller/SysOrgController.java new file mode 100644 index 0000000..3ad8d7a --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/SysOrgController.java @@ -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(@RequestParam(required = false) Long tenantId) { + return ApiResponse.ok(sysOrgService.listTree(tenantId)); + } + + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermi('sys_org:query')") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(sysOrgService.getById(id)); + } + + @PostMapping + @PreAuthorize("@ss.hasPermi('sys_org:create')") + @Log(value = "新增组织", type = "组织管理") + public ApiResponse create(@RequestBody SysOrg org) { + return ApiResponse.ok(sysOrgService.save(org)); + } + + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermi('sys_org:update')") + @Log(value = "修改组织", type = "组织管理") + public ApiResponse 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 delete(@PathVariable Long id) { + // Check if has children + long count = sysOrgService.count(new LambdaQueryWrapper().eq(SysOrg::getParentId, id)); + if (count > 0) { + return ApiResponse.error("存在下级组织,无法删除"); + } + return ApiResponse.ok(sysOrgService.removeById(id)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/SysTenantController.java b/backend/src/main/java/com/imeeting/controller/SysTenantController.java new file mode 100644 index 0000000..9977b3a --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/SysTenantController.java @@ -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> list( + @RequestParam(defaultValue = "1") Integer current, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String name, + @RequestParam(required = false) String code + ) { + LambdaQueryWrapper 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 get(@PathVariable Long id) { + return ApiResponse.ok(sysTenantService.getById(id)); + } + + @PostMapping + @PreAuthorize("@ss.hasPermi('sys_tenant:create')") + @Log(value = "新增租户", type = "租户管理") + public ApiResponse create(@RequestBody SysTenant tenant) { + return ApiResponse.ok(sysTenantService.save(tenant)); + } + + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermi('sys_tenant:update')") + @Log(value = "修改租户", type = "租户管理") + public ApiResponse 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 delete(@PathVariable Long id) { + return ApiResponse.ok(sysTenantService.removeById(id)); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index f5c7b4e..c6c8b1b 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -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() { - return ApiResponse.ok(sysUserService.list()); + public ApiResponse> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) { + LambdaQueryWrapper 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); } diff --git a/backend/src/main/java/com/imeeting/dto/UserProfile.java b/backend/src/main/java/com/imeeting/dto/UserProfile.java index 55f3b4d..cd927cd 100644 --- a/backend/src/main/java/com/imeeting/dto/UserProfile.java +++ b/backend/src/main/java/com/imeeting/dto/UserProfile.java @@ -13,4 +13,5 @@ public class UserProfile { private Integer status; @JsonProperty("isAdmin") private boolean isAdmin; + private Boolean isPlatformAdmin; } diff --git a/backend/src/main/java/com/imeeting/entity/SysOrg.java b/backend/src/main/java/com/imeeting/entity/SysOrg.java new file mode 100644 index 0000000..d75c356 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysOrg.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysTenant.java b/backend/src/main/java/com/imeeting/entity/SysTenant.java new file mode 100644 index 0000000..a8e56a4 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/SysTenant.java @@ -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; +} diff --git a/backend/src/main/java/com/imeeting/entity/SysUser.java b/backend/src/main/java/com/imeeting/entity/SysUser.java index 8df4850..fa3be0f 100644 --- a/backend/src/main/java/com/imeeting/entity/SysUser.java +++ b/backend/src/main/java/com/imeeting/entity/SysUser.java @@ -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; } diff --git a/backend/src/main/java/com/imeeting/mapper/SysOrgMapper.java b/backend/src/main/java/com/imeeting/mapper/SysOrgMapper.java new file mode 100644 index 0000000..ffb6951 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysOrgMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysTenantMapper.java b/backend/src/main/java/com/imeeting/mapper/SysTenantMapper.java new file mode 100644 index 0000000..31cd9a2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/SysTenantMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/SysOrgService.java b/backend/src/main/java/com/imeeting/service/SysOrgService.java new file mode 100644 index 0000000..f79b9e8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysOrgService.java @@ -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 { + List listTree(Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/service/SysTenantService.java b/backend/src/main/java/com/imeeting/service/SysTenantService.java new file mode 100644 index 0000000..ceae7a0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/SysTenantService.java @@ -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 { +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java new file mode 100644 index 0000000..28d66d0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysOrgServiceImpl.java @@ -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 implements SysOrgService { + @Override + public List listTree(Long tenantId) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + if (tenantId != null) { + query.eq(SysOrg::getTenantId, tenantId); + } + query.orderByAsc(SysOrg::getSortOrder); + return list(query); + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java new file mode 100644 index 0000000..46686fe --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java @@ -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 implements SysTenantService { +} diff --git a/backend/src/test/java/com/imeeting/auth/PasswordHashTest.java b/backend/src/test/java/com/imeeting/auth/PasswordHashTest.java new file mode 100644 index 0000000..849113c --- /dev/null +++ b/backend/src/test/java/com/imeeting/auth/PasswordHashTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java b/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java new file mode 100644 index 0000000..ecb478b --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/DictItemServiceTest.java @@ -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 result = dictItemService.getItemsByTypeCode(typeCode); + assertEquals(1, result.size()); + assertEquals("Male", result.get(0).getItemLabel()); + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index bbb5f7a..8bc35cb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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"; diff --git a/frontend/src/api/org.ts b/frontend/src/api/org.ts new file mode 100644 index 0000000..a1d9e62 --- /dev/null +++ b/frontend/src/api/org.ts @@ -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) { + const resp = await http.post("/api/orgs", data); + return resp.data.data as boolean; +} + +export async function updateOrg(id: number, data: Partial) { + 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; +} diff --git a/frontend/src/api/tenant.ts b/frontend/src/api/tenant.ts new file mode 100644 index 0000000..41af85b --- /dev/null +++ b/frontend/src/api/tenant.ts @@ -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) { + const resp = await http.post("/api/tenants", data); + return resp.data.data as boolean; +} + +export async function updateTenant(id: number, data: Partial) { + 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; +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index d077c8f..da0ee91 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -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 = { 'home': , + 'tenant': , + 'org': , 'user': , 'role': , 'permission': , 'dict': , 'device': , - 'setting': + 'setting': , + 'logs': }; export default function AppLayout() { diff --git a/frontend/src/pages/Devices.css b/frontend/src/pages/Devices.css new file mode 100644 index 0000000..619dd7d --- /dev/null +++ b/frontend/src/pages/Devices.css @@ -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; +} diff --git a/frontend/src/pages/Dictionaries.css b/frontend/src/pages/Dictionaries.css new file mode 100644 index 0000000..e2e2590 --- /dev/null +++ b/frontend/src/pages/Dictionaries.css @@ -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%; +} diff --git a/frontend/src/pages/Orgs.tsx b/frontend/src/pages/Orgs.tsx new file mode 100644 index 0000000..4b18dfd --- /dev/null +++ b/frontend/src/pages/Orgs.tsx @@ -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(); + 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([]); + const [tenants, setTenants] = useState([]); + const [selectedTenantId, setSelectedTenantId] = useState(undefined); + + const [drawerOpen, setDrawerOpen] = useState(false); + const [editing, setEditing] = useState(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} + }, + { + title: "组织编码", + dataIndex: "orgCode", + key: "orgCode", + width: 150, + render: (text: string) => {text || "-"} + }, + { + title: "排序", + dataIndex: "sortOrder", + width: 100, + className: "tabular-nums" + }, + { + title: "状态", + dataIndex: "status", + width: 100, + render: (s: number) => {s === 1 ? "启用" : "禁用"} + }, + { + title: "操作", + key: "action", + width: 180, + render: (_: any, record: SysOrg) => ( + + {can("sys_org:create") && ( + + )} + {can("sys_org:update") && ( + + )} + + + + + 所属租户: + ({ label: t.tenantName, value: t.id }))} /> + + + + + + + + + + + + + + + + + + + } + style={{ width: 200 }} + value={params.name} + onChange={e => setParams({ ...params, name: e.target.value })} + allowClear + /> + setParams({ ...params, code: e.target.value })} + allowClear + /> + + + + + + + setParams({ ...params, current: page, size }), + showTotal: (total) => `共 ${total} 条数据` + }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } - className="users-search-input" - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - /> + + } + className="users-search-input" + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + aria-label="搜索用户" + /> + +
`共 ${total} 条数据`, pageSize: 10, @@ -267,16 +354,16 @@ export default function Users() { - - {editing ? "编辑用户信息" : "创建新用户"} + + + + + + + + + + + - + - + @@ -317,33 +424,33 @@ export default function Users() { - + - + - + + + +