Compare commits
5 Commits
ea65b55332
...
d19d7d9a2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d19d7d9a2c | ||
|
|
dcaa7e1779 | ||
|
|
3c4100c231 | ||
|
|
8154628d3d | ||
|
|
c99738fc46 |
@ -33,8 +33,8 @@ public class SysTenantController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission("tenant:create")
|
@RequirePermission("tenant:create")
|
||||||
@Operation(summary = "创建租户")
|
@Operation(summary = "创建租户(自动创建管理员账号、角色、权限)")
|
||||||
public Result<SysTenant> create(@Valid @RequestBody CreateTenantDto dto) {
|
public Result<Map<String, Object>> create(@Valid @RequestBody CreateTenantDto dto) {
|
||||||
Long currentTenantId = SecurityUtil.getCurrentTenantId();
|
Long currentTenantId = SecurityUtil.getCurrentTenantId();
|
||||||
return Result.success(tenantService.createTenant(dto, currentTenantId));
|
return Result.success(tenantService.createTenant(dto, currentTenantId));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,4 +29,10 @@ public class CreateTenantDto {
|
|||||||
|
|
||||||
@Schema(description = "分配菜单 ID 列表")
|
@Schema(description = "分配菜单 ID 列表")
|
||||||
private List<Long> menuIds;
|
private List<Long> menuIds;
|
||||||
|
|
||||||
|
@Schema(description = "管理员用户名(默认 admin)")
|
||||||
|
private String adminUsername;
|
||||||
|
|
||||||
|
@Schema(description = "管理员密码(默认 admin@{tenantCode})")
|
||||||
|
private String adminPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
public interface ISysTenantService extends IService<SysTenant> {
|
public interface ISysTenantService extends IService<SysTenant> {
|
||||||
|
|
||||||
SysTenant createTenant(CreateTenantDto dto, Long currentTenantId);
|
Map<String, Object> createTenant(CreateTenantDto dto, Long currentTenantId);
|
||||||
|
|
||||||
PageResult<Map<String, Object>> findAll(Long page, Long pageSize, String keyword, String tenantType);
|
PageResult<Map<String, Object>> findAll(Long page, Long pageSize, String keyword, String tenantType);
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,14 @@ import com.competition.common.exception.BusinessException;
|
|||||||
import com.competition.common.result.PageResult;
|
import com.competition.common.result.PageResult;
|
||||||
import com.competition.modules.sys.dto.CreateTenantDto;
|
import com.competition.modules.sys.dto.CreateTenantDto;
|
||||||
import com.competition.modules.sys.dto.UpdateTenantDto;
|
import com.competition.modules.sys.dto.UpdateTenantDto;
|
||||||
import com.competition.modules.sys.entity.SysTenant;
|
import com.competition.modules.sys.entity.*;
|
||||||
import com.competition.modules.sys.entity.SysUser;
|
import com.competition.modules.sys.mapper.*;
|
||||||
import com.competition.modules.sys.mapper.SysTenantMapper;
|
|
||||||
import com.competition.modules.sys.mapper.SysUserMapper;
|
|
||||||
import com.competition.modules.sys.service.ISysTenantService;
|
import com.competition.modules.sys.service.ISysTenantService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@ -30,10 +30,32 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
|
|||||||
|
|
||||||
private static final Set<String> INTERNAL_TENANT_CODES = Set.of("super", "public", "school", "teacher", "student", "judge");
|
private static final Set<String> INTERNAL_TENANT_CODES = Set.of("super", "public", "school", "teacher", "student", "judge");
|
||||||
|
|
||||||
|
/** 广东省图租户编码,作为新租户权限模板 */
|
||||||
|
private static final String TEMPLATE_TENANT_CODE = "gdlib";
|
||||||
|
|
||||||
|
/** 基础管理权限(模板租户可能缺少,需补充) */
|
||||||
|
private static final List<String[]> BASE_ADMIN_PERMISSIONS = List.of(
|
||||||
|
// {name, code, resource, action, description}
|
||||||
|
new String[]{"更新用户", "user:update", "user", "update", "允许更新用户信息"},
|
||||||
|
new String[]{"删除用户", "user:delete", "user", "delete", "允许删除用户"},
|
||||||
|
new String[]{"创建角色", "role:create", "role", "create", "允许创建新角色"},
|
||||||
|
new String[]{"更新角色", "role:update", "role", "update", "允许更新角色信息"},
|
||||||
|
new String[]{"删除角色", "role:delete", "role", "delete", "允许删除角色"},
|
||||||
|
new String[]{"分配角色", "role:assign", "role", "assign", "允许给用户分配角色"},
|
||||||
|
new String[]{"查看权限", "permission:read", "permission", "read", "允许查看权限列表"}
|
||||||
|
);
|
||||||
|
|
||||||
private final SysUserMapper userMapper;
|
private final SysUserMapper userMapper;
|
||||||
|
private final SysRoleMapper roleMapper;
|
||||||
|
private final SysPermissionMapper permissionMapper;
|
||||||
|
private final SysRolePermissionMapper rolePermissionMapper;
|
||||||
|
private final SysUserRoleMapper userRoleMapper;
|
||||||
|
private final SysTenantMenuMapper tenantMenuMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SysTenant createTenant(CreateTenantDto dto, Long currentTenantId) {
|
@Transactional
|
||||||
|
public Map<String, Object> createTenant(CreateTenantDto dto, Long currentTenantId) {
|
||||||
log.info("开始创建租户,编码:{}", dto.getCode());
|
log.info("开始创建租户,编码:{}", dto.getCode());
|
||||||
checkSuperTenant(currentTenantId);
|
checkSuperTenant(currentTenantId);
|
||||||
|
|
||||||
@ -42,6 +64,7 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
|
|||||||
throw BusinessException.of(ErrorCode.CONFLICT, "租户编码已存在");
|
throw BusinessException.of(ErrorCode.CONFLICT, "租户编码已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 创建租户
|
||||||
SysTenant tenant = new SysTenant();
|
SysTenant tenant = new SysTenant();
|
||||||
tenant.setName(dto.getName());
|
tenant.setName(dto.getName());
|
||||||
tenant.setCode(dto.getCode());
|
tenant.setCode(dto.getCode());
|
||||||
@ -50,11 +73,126 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
|
|||||||
tenant.setTenantType(dto.getTenantType() != null ? dto.getTenantType() : "other");
|
tenant.setTenantType(dto.getTenantType() != null ? dto.getTenantType() : "other");
|
||||||
tenant.setIsSuper(0);
|
tenant.setIsSuper(0);
|
||||||
save(tenant);
|
save(tenant);
|
||||||
|
Long tenantId = tenant.getId();
|
||||||
|
|
||||||
// TODO: Phase 2 处理 menuIds 关联
|
// 2. 复制模板租户权限 + 补充基础管理权限
|
||||||
|
List<Long> permissionIds = copyTemplatePermissions(tenantId);
|
||||||
|
|
||||||
log.info("租户创建成功,ID:{}", tenant.getId());
|
// 3. 创建管理员角色
|
||||||
return tenant;
|
SysRole adminRole = new SysRole();
|
||||||
|
adminRole.setTenantId(tenantId);
|
||||||
|
adminRole.setName("机构管理员");
|
||||||
|
adminRole.setCode("tenant_admin");
|
||||||
|
adminRole.setDescription(tenant.getName() + "管理员角色,拥有该租户所有权限");
|
||||||
|
roleMapper.insert(adminRole);
|
||||||
|
|
||||||
|
// 4. 绑定角色-权限
|
||||||
|
for (Long permId : permissionIds) {
|
||||||
|
SysRolePermission rp = new SysRolePermission();
|
||||||
|
rp.setRoleId(adminRole.getId());
|
||||||
|
rp.setPermissionId(permId);
|
||||||
|
rolePermissionMapper.insert(rp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 创建管理员用户
|
||||||
|
String adminUsername = dto.getAdminUsername() != null && !dto.getAdminUsername().isBlank()
|
||||||
|
? dto.getAdminUsername() : "admin";
|
||||||
|
String adminPassword = dto.getAdminPassword() != null && !dto.getAdminPassword().isBlank()
|
||||||
|
? dto.getAdminPassword() : "admin@" + dto.getCode();
|
||||||
|
|
||||||
|
SysUser adminUser = new SysUser();
|
||||||
|
adminUser.setTenantId(tenantId);
|
||||||
|
adminUser.setUsername(adminUsername);
|
||||||
|
adminUser.setPassword(passwordEncoder.encode(adminPassword));
|
||||||
|
adminUser.setNickname(tenant.getName() + "管理员");
|
||||||
|
adminUser.setStatus("enabled");
|
||||||
|
adminUser.setUserSource("admin_created");
|
||||||
|
adminUser.setUserType("adult");
|
||||||
|
userMapper.insert(adminUser);
|
||||||
|
|
||||||
|
// 6. 绑定用户-角色
|
||||||
|
SysUserRole ur = new SysUserRole();
|
||||||
|
ur.setUserId(adminUser.getId());
|
||||||
|
ur.setRoleId(adminRole.getId());
|
||||||
|
userRoleMapper.insert(ur);
|
||||||
|
|
||||||
|
// 7. 分配菜单
|
||||||
|
if (dto.getMenuIds() != null && !dto.getMenuIds().isEmpty()) {
|
||||||
|
for (Long menuId : dto.getMenuIds()) {
|
||||||
|
SysTenantMenu tm = new SysTenantMenu();
|
||||||
|
tm.setTenantId(tenantId);
|
||||||
|
tm.setMenuId(menuId);
|
||||||
|
tenantMenuMapper.insert(tm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("租户创建成功,ID:{},管理员:{}", tenantId, adminUsername);
|
||||||
|
|
||||||
|
// 返回租户信息 + 管理员凭据
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("tenant", tenant);
|
||||||
|
result.put("admin", Map.of(
|
||||||
|
"username", adminUsername,
|
||||||
|
"password", adminPassword,
|
||||||
|
"userId", adminUser.getId(),
|
||||||
|
"nickname", adminUser.getNickname()
|
||||||
|
));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从模板租户复制权限,并补充基础管理权限
|
||||||
|
*/
|
||||||
|
private List<Long> copyTemplatePermissions(Long newTenantId) {
|
||||||
|
List<Long> newPermissionIds = new ArrayList<>();
|
||||||
|
|
||||||
|
// 查找模板租户
|
||||||
|
SysTenant templateTenant = getOne(
|
||||||
|
new LambdaQueryWrapper<SysTenant>().eq(SysTenant::getCode, TEMPLATE_TENANT_CODE), false);
|
||||||
|
if (templateTenant == null) {
|
||||||
|
log.warn("模板租户 {} 不存在,仅创建基础管理权限", TEMPLATE_TENANT_CODE);
|
||||||
|
} else {
|
||||||
|
// 复制模板租户的所有权限
|
||||||
|
List<SysPermission> templatePerms = permissionMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<SysPermission>()
|
||||||
|
.eq(SysPermission::getTenantId, templateTenant.getId())
|
||||||
|
.eq(SysPermission::getValidState, 1));
|
||||||
|
for (SysPermission tp : templatePerms) {
|
||||||
|
SysPermission np = new SysPermission();
|
||||||
|
np.setTenantId(newTenantId);
|
||||||
|
np.setName(tp.getName());
|
||||||
|
np.setCode(tp.getCode());
|
||||||
|
np.setResource(tp.getResource());
|
||||||
|
np.setAction(tp.getAction());
|
||||||
|
np.setDescription(tp.getDescription());
|
||||||
|
permissionMapper.insert(np);
|
||||||
|
newPermissionIds.add(np.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集已有的权限编码,避免重复
|
||||||
|
Set<String> existingCodes = new HashSet<>();
|
||||||
|
for (Long pid : newPermissionIds) {
|
||||||
|
SysPermission p = permissionMapper.selectById(pid);
|
||||||
|
if (p != null) existingCodes.add(p.getCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补充基础管理权限
|
||||||
|
for (String[] perm : BASE_ADMIN_PERMISSIONS) {
|
||||||
|
if (!existingCodes.contains(perm[1])) {
|
||||||
|
SysPermission np = new SysPermission();
|
||||||
|
np.setTenantId(newTenantId);
|
||||||
|
np.setName(perm[0]);
|
||||||
|
np.setCode(perm[1]);
|
||||||
|
np.setResource(perm[2]);
|
||||||
|
np.setAction(perm[3]);
|
||||||
|
np.setDescription(perm[4]);
|
||||||
|
permissionMapper.insert(np);
|
||||||
|
newPermissionIds.add(np.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPermissionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -141,7 +279,17 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
|
|||||||
if (dto.getTenantType() != null) tenant.setTenantType(dto.getTenantType());
|
if (dto.getTenantType() != null) tenant.setTenantType(dto.getTenantType());
|
||||||
updateById(tenant);
|
updateById(tenant);
|
||||||
|
|
||||||
// TODO: Phase 2 处理 menuIds 关联
|
// 更新菜单关联(先删后增)
|
||||||
|
if (dto.getMenuIds() != null) {
|
||||||
|
tenantMenuMapper.delete(
|
||||||
|
new LambdaQueryWrapper<SysTenantMenu>().eq(SysTenantMenu::getTenantId, id));
|
||||||
|
for (Long menuId : dto.getMenuIds()) {
|
||||||
|
SysTenantMenu tm = new SysTenantMenu();
|
||||||
|
tm.setTenantId(id);
|
||||||
|
tm.setMenuId(menuId);
|
||||||
|
tenantMenuMapper.insert(tm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return getById(id);
|
return getById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
| [成果发布优化](./super-admin/results-publish-optimization.md) | 活动监管 | 已实现(待验收) | 2026-03-27 |
|
||||||
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
|
| [内容管理模块](./super-admin/content-management.md) | 内容管理 | P0 已实现并优化 | 2026-03-31 |
|
||||||
| [机构管理优化](./super-admin/org-management.md) | 机构管理 | 已优化 | 2026-03-31 |
|
| [机构管理优化](./super-admin/org-management.md) | 机构管理 | 已优化 | 2026-03-31 |
|
||||||
|
| [租户创建自动生成管理员](./super-admin/tenant-auto-create-admin.md) | 机构管理 | 已完成 | 2026-04-02 |
|
||||||
|
|
||||||
## 租户端(机构管理端)
|
## 租户端(机构管理端)
|
||||||
|
|
||||||
|
|||||||
160
docs/design/super-admin/tenant-auto-create-admin.md
Normal file
160
docs/design/super-admin/tenant-auto-create-admin.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# 租户创建自动生成管理员账号 — 设计方案
|
||||||
|
|
||||||
|
> 所属端:超管端
|
||||||
|
> 状态:已完成
|
||||||
|
> 创建日期:2026-04-02
|
||||||
|
> 最后更新:2026-04-02
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
超管端创建新租户(机构)后,需要**手动**到用户管理页面另行创建该租户的管理员账号、角色、权限,流程割裂且容易遗漏,导致新建的租户无法登录使用。
|
||||||
|
|
||||||
|
核心问题:
|
||||||
|
- 创建租户与创建管理员是分离的两步操作,缺乏原子性
|
||||||
|
- 新租户没有默认角色和权限,管理员账号无法正常使用
|
||||||
|
- 菜单分配(menuIds)也未实现(后端标记为 TODO)
|
||||||
|
|
||||||
|
## 2. 现状分析
|
||||||
|
|
||||||
|
### 2.1 创建租户接口
|
||||||
|
|
||||||
|
- `POST /api/tenants` → `SysTenantServiceImpl.createTenant()`
|
||||||
|
- 仅创建 `t_sys_tenant` 记录,不创建用户、角色、权限、菜单关联
|
||||||
|
- menuIds 参数存在但未处理(TODO 注释)
|
||||||
|
|
||||||
|
### 2.2 初始化脚本的做法
|
||||||
|
|
||||||
|
`backend/scripts/init-dev-tenants.ts`(Node 版,已移除)中,创建租户时会一并完成:
|
||||||
|
1. 创建租户 → 2. 创建权限 → 3. 创建角色 → 4. 角色绑定权限 → 5. 创建 admin 用户 → 6. 用户绑定角色 → 7. 分配菜单
|
||||||
|
|
||||||
|
`init.sql` 中每个租户也都有对应的 admin 用户和角色。
|
||||||
|
|
||||||
|
### 2.3 涉及的数据表
|
||||||
|
|
||||||
|
| 表名 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `t_sys_tenant` | 租户 |
|
||||||
|
| `t_sys_user` | 用户 |
|
||||||
|
| `t_sys_role` | 角色 |
|
||||||
|
| `t_sys_permission` | 权限(按租户隔离) |
|
||||||
|
| `t_sys_user_role` | 用户-角色关联 |
|
||||||
|
| `t_sys_role_permission` | 角色-权限关联 |
|
||||||
|
| `t_sys_tenant_menu` | 租户-菜单关联 |
|
||||||
|
|
||||||
|
## 3. 设计方案
|
||||||
|
|
||||||
|
### 3.1 整体思路
|
||||||
|
|
||||||
|
创建租户时在同一事务中自动完成:创建租户 → 复制权限模板 → 创建管理员角色 → 创建管理员用户 → 绑定关联关系 → 分配菜单。接口返回租户信息 + 管理员凭据。
|
||||||
|
|
||||||
|
### 3.2 权限模板策略
|
||||||
|
|
||||||
|
- **模板来源**:运行时从广东省图租户(code=`gdlib`,tenant_id=9)复制权限,该租户拥有 26 项业务权限,覆盖活动、报名、评审、作品、公告等核心模块
|
||||||
|
- **补充基础管理权限**:模板中缺少的 7 项基础权限自动补充:
|
||||||
|
- `user:update`、`user:delete` — 用户管理
|
||||||
|
- `role:create`、`role:update`、`role:delete`、`role:assign` — 角色管理
|
||||||
|
- `permission:read` — 权限查看
|
||||||
|
- 新租户最终获得 **33 项权限**(26 模板 + 7 补充)
|
||||||
|
|
||||||
|
### 3.3 前端页面设计
|
||||||
|
|
||||||
|
**创建表单改动**:
|
||||||
|
- 基本信息 Tab 增加「管理员账号」区块(用分割线分隔)
|
||||||
|
- 新增两个输入框:管理员账号(默认 admin)、管理员密码(默认 admin@{编码})
|
||||||
|
- 仅新建时显示,编辑时隐藏
|
||||||
|
|
||||||
|
**成功弹窗改动**:
|
||||||
|
- 原先:提示"创建成功"+ 跳转用户管理页按钮
|
||||||
|
- 现在:展示管理员账号、初始密码、登录地址
|
||||||
|
- 新增「复制账号信息」按钮,一键复制全部信息到剪贴板
|
||||||
|
|
||||||
|
### 3.4 后端改动
|
||||||
|
|
||||||
|
**`CreateTenantDto`** — 新增字段:
|
||||||
|
- `adminUsername`(String,可选,默认 "admin")
|
||||||
|
- `adminPassword`(String,可选,默认 "admin@{tenantCode}")
|
||||||
|
|
||||||
|
**`ISysTenantService.createTenant()`** — 返回值改为 `Map<String, Object>`
|
||||||
|
|
||||||
|
**`SysTenantServiceImpl.createTenant()`** — 核心改动,事务内完成 7 步:
|
||||||
|
1. 创建租户记录
|
||||||
|
2. `copyTemplatePermissions()` — 复制 gdlib 权限 + 补充基础权限
|
||||||
|
3. 创建 `tenant_admin` 角色
|
||||||
|
4. 批量绑定角色-权限
|
||||||
|
5. 创建管理员用户(密码 BCrypt 加密)
|
||||||
|
6. 绑定用户-角色
|
||||||
|
7. 处理 menuIds 菜单分配
|
||||||
|
|
||||||
|
新增私有方法 `copyTemplatePermissions(Long newTenantId)`:
|
||||||
|
- 查询 gdlib 租户的全部权限,逐条复制(替换 tenantId)
|
||||||
|
- 对比已有权限编码,补充缺失的基础管理权限
|
||||||
|
|
||||||
|
**`SysTenantServiceImpl.updateTenant()`** — 同步实现 menuIds 更新(先删后增)
|
||||||
|
|
||||||
|
**`SysTenantController.create()`** — 返回类型从 `Result<SysTenant>` 改为 `Result<Map<String, Object>>`
|
||||||
|
|
||||||
|
### 3.5 接口响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"tenant": {
|
||||||
|
"id": 13,
|
||||||
|
"name": "测试图书馆",
|
||||||
|
"code": "test-lib",
|
||||||
|
"tenantType": "library",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"username": "libadmin",
|
||||||
|
"password": "Lib@2026",
|
||||||
|
"userId": 32,
|
||||||
|
"nickname": "测试图书馆管理员"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 前端改动
|
||||||
|
|
||||||
|
**`src/api/tenants.ts`**:
|
||||||
|
- `CreateTenantForm` 新增 `adminUsername`、`adminPassword`
|
||||||
|
- 新增 `CreateTenantResult` 接口
|
||||||
|
- `createTenant()` 返回类型改为 `CreateTenantResult`
|
||||||
|
|
||||||
|
**`src/views/system/tenants/Index.vue`**:
|
||||||
|
- 表单 reactive 新增 `adminUsername`、`adminPassword`
|
||||||
|
- 模板增加管理员输入区块
|
||||||
|
- 成功弹窗展示管理员信息卡片 + 复制按钮
|
||||||
|
- 移除 `useRouter`、`useAuthStore` 无用依赖
|
||||||
|
|
||||||
|
## 4. 改动范围
|
||||||
|
|
||||||
|
### 后端(Java)
|
||||||
|
|
||||||
|
| 操作 | 文件 |
|
||||||
|
|------|------|
|
||||||
|
| 修改 | `modules/sys/dto/CreateTenantDto.java` |
|
||||||
|
| 修改 | `modules/sys/service/ISysTenantService.java` |
|
||||||
|
| 修改 | `modules/sys/service/impl/SysTenantServiceImpl.java` |
|
||||||
|
| 修改 | `modules/sys/controller/SysTenantController.java` |
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
| 操作 | 文件 |
|
||||||
|
|------|------|
|
||||||
|
| 修改 | `src/api/tenants.ts` |
|
||||||
|
| 修改 | `src/views/system/tenants/Index.vue` |
|
||||||
|
|
||||||
|
## 5. 实施记录
|
||||||
|
|
||||||
|
### 2026-04-02 — 初版实现
|
||||||
|
|
||||||
|
- 完成后端事务化创建租户 + 管理员 + 角色 + 权限 + 菜单分配
|
||||||
|
- 权限模板使用 gdlib 租户(26项)+ 7项基础管理权限补充
|
||||||
|
- 前端表单增加管理员账号/密码输入,成功弹窗展示凭据信息
|
||||||
|
- 测试验证:创建租户 → 管理员登录成功 → 获得 97 项权限(含 gdlib 完整业务权限 + 补充权限)
|
||||||
|
- 同步实现了 `updateTenant` 的 menuIds 更新(消除 TODO)
|
||||||
117
docs/project/11-java-migration-log.md
Normal file
117
docs/project/11-java-migration-log.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Java 后端转写 — 开发日志
|
||||||
|
|
||||||
|
## 2026-04-02 Day1:完整转写 + 联调 + 菜单修复
|
||||||
|
|
||||||
|
### 一、模块剥离
|
||||||
|
|
||||||
|
将学校端、教师端、学生端、3D建模模块代码从项目中剥离,备份至 `competition-management-system-stripped-modules/`。
|
||||||
|
|
||||||
|
- 移除后端:school/(6个子模块)、ai-3d/
|
||||||
|
- 移除前端:views/school/、views/workbench/ai-3d/、views/model/
|
||||||
|
- 移除 Prisma Schema:School/Grade/Department/Class/Teacher/Student/StudentInterestClass/AI3DTask 共8个模型
|
||||||
|
- 清理跨模块引用:registrations.service(教师判断改为角色判断)、homework services(移除 class/grade 查询)
|
||||||
|
- 保留 API 存根文件:students.ts/teachers.ts/ai-3d.ts(赛事组件有引用)
|
||||||
|
|
||||||
|
### 二、Java 后端转写(239个文件,~256个API)
|
||||||
|
|
||||||
|
#### Phase 0:项目脚手架
|
||||||
|
- Spring Boot 3.2 + Java 17 + MyBatis-Plus 3.5
|
||||||
|
- JWT 认证 + Spring Security + AOP 权限注解
|
||||||
|
- 统一响应 Result<T> + PageResult<T> + 全局异常处理
|
||||||
|
- Flyway 迁移脚本(V1 表重命名 + V2 新审计字段)
|
||||||
|
|
||||||
|
#### Phase 1:认证/用户/角色/权限/租户(~35接口)
|
||||||
|
- 6个实体 + 6个Mapper + 8个DTO + 5个Service + 5个Controller
|
||||||
|
- JWT payload 与 NestJS 兼容:{sub: userId, username, tenantId}
|
||||||
|
|
||||||
|
#### Phase 2:菜单/字典/配置/日志(~25接口)
|
||||||
|
- 菜单树构建(4级嵌套)+ tenant_menus 过滤
|
||||||
|
- 日志统计(Top10操作 + 按日聚合)
|
||||||
|
|
||||||
|
#### Phase 3:赛事核心(~46接口)
|
||||||
|
- BizContest(35+字段,7个JSON列,JacksonTypeHandler)
|
||||||
|
- 报名 + 作品提交 + 团队 + 附件 + 公告
|
||||||
|
|
||||||
|
#### Phase 4:评审/计分/成果(~52接口)
|
||||||
|
- 4种计分策略:average / max / weighted / remove_max_min
|
||||||
|
- 自动分配(轮询,3评委/作品)
|
||||||
|
- Dense ranking 排名 + 自动奖项分配
|
||||||
|
- 评委库管理
|
||||||
|
|
||||||
|
#### Phase 5:作业模块(~20接口)
|
||||||
|
#### Phase 6:公众端API(~55接口,9个Controller)
|
||||||
|
#### Phase 7:UGC + 用户子女(~15接口,9个实体)
|
||||||
|
#### Phase 8:文件上传/OSS(腾讯云COS + 本地回退)
|
||||||
|
|
||||||
|
### 三、数据库适配
|
||||||
|
|
||||||
|
| 修复项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| configs 表添加 valid_state 列 | BaseEntity 映射需要 |
|
||||||
|
| t_contest_registration 表添加 valid_state 列 | 同上 |
|
||||||
|
| 所有表 modify_time 添加 DEFAULT CURRENT_TIMESTAMP(3) | 非BaseEntity表INSERT会报错 |
|
||||||
|
| SysLogMapper SQL 表名修正 | @Select 注解中用了新表名 |
|
||||||
|
| `rank` 加反引号 | MySQL 保留字 |
|
||||||
|
| gdlib 租户补全全套权限码(97个) | 缺少 work:submit 等权限 |
|
||||||
|
| JDBC characterEncoding=UTF-8 | 不支持 utf8mb4 |
|
||||||
|
|
||||||
|
### 四、写操作测试(22/22通过)
|
||||||
|
|
||||||
|
覆盖:用户CRUD、角色CRUD、赛事创建(含JSON)、报名、审核、作品提交、评委分配、评分、计算得分/排名/奖项、发布成果、公众注册、内容审核、文件上传。
|
||||||
|
|
||||||
|
修复:PATCH 方法去掉 @Valid(部分更新不应强制校验所有必填字段)、文件上传用绝对路径。
|
||||||
|
|
||||||
|
### 五、前端联调(20页面,69+ API调用,零错误)
|
||||||
|
|
||||||
|
Playwright 自动化测试覆盖超管端、租户端、公众端所有核心页面。
|
||||||
|
|
||||||
|
修复:registrations/stats 和 works/stats 的 contestId 改为可选参数。
|
||||||
|
|
||||||
|
### 六、端到端链路验证(16/16通过)
|
||||||
|
|
||||||
|
完整活动生命周期:租户创建活动 → 发布 → 公众报名 → 审核 → 提交作品 → 添加评委 → 分配 → 评分 → 计算得分 → 排名 → 奖项 → 发布成果 → 超管查看。
|
||||||
|
|
||||||
|
### 七、权限检查(67/67通过)
|
||||||
|
|
||||||
|
所有端所有模块的读写接口权限测试,零403错误。
|
||||||
|
|
||||||
|
### 八、菜单修复
|
||||||
|
|
||||||
|
| 问题 | 根因 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| 超管端显示全部52个菜单 | getUserMenus 对超管返回所有菜单 | 超管也按 tenant_menus 过滤 |
|
||||||
|
| 超管端包含数据统计 | 错误分配,数据统计是租户端专属 | 从超管 tenant_menus 移除52/53/54 |
|
||||||
|
| gdlib 菜单包含超管模块 | 远程数据库分配错误 | 重置为正确的18条 |
|
||||||
|
| gdlib 登录跳到 /super/ | Java 后端只支持 X-Tenant-Id header | 增加 tenantCode body 参数支持 |
|
||||||
|
| Knife4j 启动报错 | 4.5.0 与 Spring Boot 3.2.5 不兼容 | 降为 4.4.0 |
|
||||||
|
|
||||||
|
### 九、文档输出
|
||||||
|
|
||||||
|
- `docs/design/menu-config.md` — 各端菜单配置规范(完整ID表+登录信息+权限码)
|
||||||
|
- `docs/project/10-java-backend-test-plan.md` — 完整测试规划(190条用例)
|
||||||
|
- `backend-java/src/main/resources/db/init.sql` — 数据库初始化脚本(41张新表名)
|
||||||
|
|
||||||
|
### 十、提交记录
|
||||||
|
|
||||||
|
| Commit | 内容 |
|
||||||
|
|--------|------|
|
||||||
|
| bead1cf | 剥离学校/教师/学生/3D模块 |
|
||||||
|
| 096d06a | Java 后端完整转写(239文件) |
|
||||||
|
| a6b0565 | 数据库导出 + 权限补全 |
|
||||||
|
| 58c232d | 表名规范化 + Flyway启用 |
|
||||||
|
| 5c0d87d | 干净的 init.sql 初始化脚本 |
|
||||||
|
| 5b5af63 | 超管端菜单修复 + Knife4j降版本 |
|
||||||
|
| fd9c739 | 租户端登录修复 + gdlib菜单修正 |
|
||||||
|
| e9676ea | 菜单配置规范文档 |
|
||||||
|
| 0b989b0 | 文档完善(全量ID表+调试指南) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 明日待续
|
||||||
|
|
||||||
|
- [ ] P1测试:超管端各页面数据展示验证
|
||||||
|
- [ ] P1测试:租户端活动全流程操作
|
||||||
|
- [ ] P2测试:评委端评审流程
|
||||||
|
- [ ] P2测试:公众端注册/报名/作品提交
|
||||||
|
- [ ] 响应数据结构精调(如有前端解析不匹配)
|
||||||
|
- [ ] 生产环境部署配置
|
||||||
@ -31,6 +31,18 @@ export interface CreateTenantForm {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
menuIds?: number[];
|
menuIds?: number[];
|
||||||
|
adminUsername?: string;
|
||||||
|
adminPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantResult {
|
||||||
|
tenant: Tenant;
|
||||||
|
admin: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
userId: number;
|
||||||
|
nickname: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTenantForm {
|
export interface UpdateTenantForm {
|
||||||
@ -61,9 +73,9 @@ export async function getTenantDetail(id: number): Promise<Tenant> {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建租户
|
// 创建租户(自动创建管理员账号)
|
||||||
export async function createTenant(data: CreateTenantForm): Promise<Tenant> {
|
export async function createTenant(data: CreateTenantForm): Promise<CreateTenantResult> {
|
||||||
const response = await request.post<any, Tenant>("/tenants", data);
|
const response = await request.post<any, CreateTenantResult>("/tenants", data);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a-button type="text" @click="$router.back()">
|
<a-button type="text" @click="$router.back()">
|
||||||
<template #icon><ArrowLeftOutlined /></template>
|
<template #icon>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
</template>
|
||||||
返回
|
返回
|
||||||
</a-button>
|
</a-button>
|
||||||
<h1 class="title">{{ contest.contestName }}</h1>
|
<h1 class="title">{{ contest.contestName }}</h1>
|
||||||
@ -77,11 +79,8 @@
|
|||||||
|
|
||||||
<!-- 时间轴 -->
|
<!-- 时间轴 -->
|
||||||
<div class="timeline-bar">
|
<div class="timeline-bar">
|
||||||
<div
|
<div v-for="(phase, idx) in phases" :key="phase.key"
|
||||||
v-for="(phase, idx) in phases"
|
:class="['phase', { completed: phase.status === 'completed', current: phase.status === 'current', future: phase.status === 'future' }]">
|
||||||
:key="phase.key"
|
|
||||||
:class="['phase', { completed: phase.status === 'completed', current: phase.status === 'current', future: phase.status === 'future' }]"
|
|
||||||
>
|
|
||||||
<div class="phase-dot">
|
<div class="phase-dot">
|
||||||
<CheckOutlined v-if="phase.status === 'completed'" />
|
<CheckOutlined v-if="phase.status === 'completed'" />
|
||||||
<span v-else>{{ idx + 1 }}</span>
|
<span v-else>{{ idx + 1 }}</span>
|
||||||
@ -90,7 +89,8 @@
|
|||||||
<span class="phase-name">{{ phase.name }}</span>
|
<span class="phase-name">{{ phase.name }}</span>
|
||||||
<span class="phase-time">{{ phase.timeRange }}</span>
|
<span class="phase-time">{{ phase.timeRange }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="idx < phases.length - 1" class="phase-line" :class="{ done: phase.status === 'completed' }"></div>
|
<div v-if="idx < phases.length - 1" class="phase-line" :class="{ done: phase.status === 'completed' }">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -100,9 +100,11 @@
|
|||||||
<a-tab-pane key="config" tab="活动配置">
|
<a-tab-pane key="config" tab="活动配置">
|
||||||
<a-descriptions bordered :column="2" size="small" class="config-desc">
|
<a-descriptions bordered :column="2" size="small" class="config-desc">
|
||||||
<a-descriptions-item label="活动名称" :span="2">{{ contest.contestName }}</a-descriptions-item>
|
<a-descriptions-item label="活动名称" :span="2">{{ contest.contestName }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="活动类型">{{ contest.contestType === 'individual' ? '个人参与' : '团队参与' }}</a-descriptions-item>
|
<a-descriptions-item label="活动类型">{{ contest.contestType === 'individual' ? '个人参与' : '团队参与'
|
||||||
|
}}</a-descriptions-item>
|
||||||
<a-descriptions-item label="可见范围">{{ visibilityMap[contest.visibility] || '-' }}</a-descriptions-item>
|
<a-descriptions-item label="可见范围">{{ visibilityMap[contest.visibility] || '-' }}</a-descriptions-item>
|
||||||
<a-descriptions-item v-if="contest.visibility === 'targeted' && contest.targetCities?.length" label="定向城市" :span="2">
|
<a-descriptions-item v-if="contest.visibility === 'targeted' && contest.targetCities?.length"
|
||||||
|
label="定向城市" :span="2">
|
||||||
<a-tag v-for="c in contest.targetCities" :key="c">{{ c }}</a-tag>
|
<a-tag v-for="c in contest.targetCities" :key="c">{{ c }}</a-tag>
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item v-if="contest.visibility === 'targeted'" label="年龄限制">
|
<a-descriptions-item v-if="contest.visibility === 'targeted'" label="年龄限制">
|
||||||
@ -112,7 +114,8 @@
|
|||||||
|
|
||||||
<a-divider orientation="left">报名配置</a-divider>
|
<a-divider orientation="left">报名配置</a-divider>
|
||||||
<a-descriptions bordered :column="2" size="small">
|
<a-descriptions bordered :column="2" size="small">
|
||||||
<a-descriptions-item label="报名时间" :span="2">{{ formatDate(contest.registerStartTime) }} ~ {{ formatDate(contest.registerEndTime) }}</a-descriptions-item>
|
<a-descriptions-item label="报名时间" :span="2">{{ formatDate(contest.registerStartTime) }} ~ {{
|
||||||
|
formatDate(contest.registerEndTime) }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="是否需要审核">{{ contest.requireAudit ? '是' : '否' }}</a-descriptions-item>
|
<a-descriptions-item label="是否需要审核">{{ contest.requireAudit ? '是' : '否' }}</a-descriptions-item>
|
||||||
<a-descriptions-item v-if="contest.contestType === 'team'" label="团队人数">
|
<a-descriptions-item v-if="contest.contestType === 'team'" label="团队人数">
|
||||||
{{ contest.teamMinMembers || '-' }} ~ {{ contest.teamMaxMembers || '-' }} 人
|
{{ contest.teamMinMembers || '-' }} ~ {{ contest.teamMaxMembers || '-' }} 人
|
||||||
@ -121,15 +124,19 @@
|
|||||||
|
|
||||||
<a-divider orientation="left">作品配置</a-divider>
|
<a-divider orientation="left">作品配置</a-divider>
|
||||||
<a-descriptions bordered :column="2" size="small">
|
<a-descriptions bordered :column="2" size="small">
|
||||||
<a-descriptions-item label="提交时间" :span="2">{{ formatDate(contest.submitStartTime) }} ~ {{ formatDate(contest.submitEndTime) }}</a-descriptions-item>
|
<a-descriptions-item label="提交时间" :span="2">{{ formatDate(contest.submitStartTime) }} ~ {{
|
||||||
<a-descriptions-item label="提交规则">{{ contest.submitRule === 'resubmit' ? '允许重新提交' : '单次提交' }}</a-descriptions-item>
|
formatDate(contest.submitEndTime) }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="提交规则">{{ contest.submitRule === 'resubmit' ? '允许重新提交' : '单次提交'
|
||||||
|
}}</a-descriptions-item>
|
||||||
<a-descriptions-item label="作品类型">{{ workTypeMap[contest.workType] || '-' }}</a-descriptions-item>
|
<a-descriptions-item label="作品类型">{{ workTypeMap[contest.workType] || '-' }}</a-descriptions-item>
|
||||||
<a-descriptions-item v-if="contest.workRequirement" label="作品要求" :span="2">{{ contest.workRequirement }}</a-descriptions-item>
|
<a-descriptions-item v-if="contest.workRequirement" label="作品要求" :span="2">{{ contest.workRequirement
|
||||||
|
}}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
|
||||||
<a-divider orientation="left">评审配置</a-divider>
|
<a-divider orientation="left">评审配置</a-divider>
|
||||||
<a-descriptions bordered :column="2" size="small">
|
<a-descriptions bordered :column="2" size="small">
|
||||||
<a-descriptions-item label="评审时间" :span="2">{{ formatDate(contest.reviewStartTime) }} ~ {{ formatDate(contest.reviewEndTime) }}</a-descriptions-item>
|
<a-descriptions-item label="评审时间" :span="2">{{ formatDate(contest.reviewStartTime) }} ~ {{
|
||||||
|
formatDate(contest.reviewEndTime) }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="评审规则">{{ contest.reviewRule?.ruleName || '-' }}</a-descriptions-item>
|
<a-descriptions-item label="评审规则">{{ contest.reviewRule?.ruleName || '-' }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="成果状态">
|
<a-descriptions-item label="成果状态">
|
||||||
<a-tag :color="contest.resultState === 'published' ? 'green' : 'default'">
|
<a-tag :color="contest.resultState === 'published' ? 'green' : 'default'">
|
||||||
@ -143,7 +150,8 @@
|
|||||||
<a-descriptions-item label="主办单位">{{ formatOrgList(contest.organizers) }}</a-descriptions-item>
|
<a-descriptions-item label="主办单位">{{ formatOrgList(contest.organizers) }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="协办单位">{{ formatOrgList(contest.coOrganizers) }}</a-descriptions-item>
|
<a-descriptions-item label="协办单位">{{ formatOrgList(contest.coOrganizers) }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="赞助单位">{{ formatOrgList(contest.sponsors) }}</a-descriptions-item>
|
<a-descriptions-item label="赞助单位">{{ formatOrgList(contest.sponsors) }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="联系人">{{ contest.contactName || '-' }} {{ contest.contactPhone ? `/ ${contest.contactPhone}` : '' }}</a-descriptions-item>
|
<a-descriptions-item label="联系人">{{ contest.contactName || '-' }} {{ contest.contactPhone ? `/
|
||||||
|
${contest.contactPhone}` : '' }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
@ -308,6 +316,8 @@ $primary: #6366f1;
|
|||||||
|
|
||||||
.super-detail-page {
|
.super-detail-page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶部信息栏
|
// 顶部信息栏
|
||||||
@ -392,11 +402,24 @@ $primary: #6366f1;
|
|||||||
color: #1e1b4b;
|
color: #1e1b4b;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.ov-sub { font-size: 14px; color: #9ca3af; font-weight: 400; }
|
|
||||||
.ov-label { font-size: 12px; color: #9ca3af; margin-top: 2px; }
|
.ov-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ov-arrow { color: #d1d5db; font-size: 12px; }
|
.ov-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ov-arrow {
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 时间轴
|
// 时间轴
|
||||||
@ -435,8 +458,18 @@ $primary: #6366f1;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
.phase-name { font-size: 13px; font-weight: 600; color: #374151; }
|
|
||||||
.phase-time { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
.phase-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase-line {
|
.phase-line {
|
||||||
@ -446,12 +479,21 @@ $primary: #6366f1;
|
|||||||
margin: 0 16px;
|
margin: 0 16px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
|
|
||||||
&.done { background: $primary; }
|
&.done {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.completed {
|
&.completed {
|
||||||
.phase-dot { background: $primary; color: #fff; border-color: $primary; }
|
.phase-dot {
|
||||||
.phase-info .phase-name { color: $primary; }
|
background: $primary;
|
||||||
|
color: #fff;
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-info .phase-name {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
@ -461,7 +503,11 @@ $primary: #6366f1;
|
|||||||
border-color: $primary;
|
border-color: $primary;
|
||||||
box-shadow: 0 0 0 4px rgba($primary, 0.15);
|
box-shadow: 0 0 0 4px rgba($primary, 0.15);
|
||||||
}
|
}
|
||||||
.phase-info .phase-name { color: $primary; font-weight: 700; }
|
|
||||||
|
.phase-info .phase-name {
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,11 +517,16 @@ $primary: #6366f1;
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
.config-desc { margin-bottom: 0; }
|
.config-desc {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content {
|
.rich-content {
|
||||||
:deep(img) { max-width: 100%; border-radius: 8px; }
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
@ -492,9 +543,24 @@ $primary: #6366f1;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.notice-title { font-weight: 600; color: #1e1b4b; font-size: 14px; }
|
|
||||||
.notice-body { font-size: 13px; color: #4b5563; line-height: 1.6; }
|
.notice-title {
|
||||||
.notice-time { font-size: 11px; color: #9ca3af; margin-top: 8px; }
|
font-weight: 600;
|
||||||
|
color: #1e1b4b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-body {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -131,6 +131,21 @@
|
|||||||
<a-textarea v-model:value="form.description" placeholder="机构描述(可选)" :rows="3"
|
<a-textarea v-model:value="form.description" placeholder="机构描述(可选)" :rows="3"
|
||||||
:maxlength="500" />
|
:maxlength="500" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<template v-if="!editingId">
|
||||||
|
<a-divider style="margin: 16px 0 8px;">管理员账号</a-divider>
|
||||||
|
<a-form-item label="管理员账号" name="adminUsername">
|
||||||
|
<a-input v-model:value="form.adminUsername" placeholder="默认 admin" :maxlength="50" />
|
||||||
|
<template #extra>
|
||||||
|
<span class="form-hint">留空则默认为 admin</span>
|
||||||
|
</template>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="管理员密码" name="adminPassword">
|
||||||
|
<a-input-password v-model:value="form.adminPassword" placeholder="默认 admin@{机构编码}" :maxlength="50" />
|
||||||
|
<template #extra>
|
||||||
|
<span class="form-hint">留空则默认为 admin@{{ form.code || '{机构编码}' }}</span>
|
||||||
|
</template>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
<a-form-item v-if="editingId" label="状态" name="validState">
|
<a-form-item v-if="editingId" label="状态" name="validState">
|
||||||
<a-radio-group v-model:value="form.validState">
|
<a-radio-group v-model:value="form.validState">
|
||||||
<a-radio :value="1">正常</a-radio>
|
<a-radio :value="1">正常</a-radio>
|
||||||
@ -174,12 +189,26 @@
|
|||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 新建成功引导弹窗 -->
|
<!-- 新建成功引导弹窗 -->
|
||||||
<a-modal v-model:open="guideVisible" title="机构创建成功" :footer="null" width="460px">
|
<a-modal v-model:open="guideVisible" title="机构创建成功" :footer="null" width="500px">
|
||||||
<a-result status="success" :title="`「${lastCreatedName}」创建成功`" sub-title="接下来你可以:">
|
<a-result status="success" :title="`「${lastCreatedName}」创建成功`" sub-title="管理员账号已自动创建,请妥善保管以下信息:">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<div style="display: flex; flex-direction: column; gap: 12px; align-items: center">
|
<div class="admin-info-card" v-if="createdAdmin">
|
||||||
<a-button type="primary" @click="goCreateAdmin">为该机构创建管理员账号</a-button>
|
<div class="admin-info-row">
|
||||||
<a-button @click="guideVisible = false">稍后再说</a-button>
|
<span class="admin-info-label">管理员账号</span>
|
||||||
|
<span class="admin-info-value">{{ createdAdmin.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-info-row">
|
||||||
|
<span class="admin-info-label">初始密码</span>
|
||||||
|
<span class="admin-info-value admin-info-password">{{ createdAdmin.password }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-info-row">
|
||||||
|
<span class="admin-info-label">登录地址</span>
|
||||||
|
<span class="admin-info-value" style="font-family: monospace; font-size: 12px;">{{ loginUrlForCreated }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 12px; justify-content: center; margin-top: 16px;">
|
||||||
|
<a-button type="primary" @click="copyAdminInfo">复制账号信息</a-button>
|
||||||
|
<a-button @click="guideVisible = false">我已记录</a-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-result>
|
</a-result>
|
||||||
@ -189,7 +218,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
|
import { ref, reactive, nextTick, onMounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
|
import type { TableColumnsType, FormInstance } from 'ant-design-vue'
|
||||||
import { PlusOutlined, UserOutlined, SafetyOutlined, CopyOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined, UserOutlined, SafetyOutlined, CopyOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||||
@ -198,12 +226,9 @@ import {
|
|||||||
type Tenant,
|
type Tenant,
|
||||||
type CreateTenantForm,
|
type CreateTenantForm,
|
||||||
type UpdateTenantForm,
|
type UpdateTenantForm,
|
||||||
|
type CreateTenantResult,
|
||||||
} from '@/api/tenants'
|
} from '@/api/tenants'
|
||||||
import { menusApi, type Menu } from '@/api/menus'
|
import { menusApi, type Menu } from '@/api/menus'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dataSource = ref<Tenant[]>([])
|
const dataSource = ref<Tenant[]>([])
|
||||||
@ -222,10 +247,10 @@ const activeTab = ref('basic')
|
|||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchType = ref('')
|
const searchType = ref('')
|
||||||
|
|
||||||
// 新建成功引导(#6)
|
// 新建成功引导
|
||||||
const guideVisible = ref(false)
|
const guideVisible = ref(false)
|
||||||
const lastCreatedName = ref('')
|
const lastCreatedName = ref('')
|
||||||
const lastCreatedId = ref<number | null>(null)
|
const lastCreatedCode = ref('')
|
||||||
|
|
||||||
// 菜单相关
|
// 菜单相关
|
||||||
const allMenus = ref<Menu[]>([])
|
const allMenus = ref<Menu[]>([])
|
||||||
@ -239,8 +264,13 @@ const form = reactive<CreateTenantForm & { validState?: number; menuIds?: number
|
|||||||
tenantType: 'other',
|
tenantType: 'other',
|
||||||
validState: 1,
|
validState: 1,
|
||||||
menuIds: [],
|
menuIds: [],
|
||||||
|
adminUsername: '',
|
||||||
|
adminPassword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 创建成功后的管理员信息
|
||||||
|
const createdAdmin = ref<CreateTenantResult['admin'] | null>(null)
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
name: [
|
name: [
|
||||||
{ required: true, message: '请输入机构名称', trigger: 'blur' },
|
{ required: true, message: '请输入机构名称', trigger: 'blur' },
|
||||||
@ -493,7 +523,7 @@ const handleAdd = () => {
|
|||||||
preservedExcludedMenuIds.value = []
|
preservedExcludedMenuIds.value = []
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
formRef.value?.resetFields()
|
formRef.value?.resetFields()
|
||||||
Object.assign(form, { name: '', code: '', domain: '', description: '', tenantType: 'other', validState: 1, menuIds: [] })
|
Object.assign(form, { name: '', code: '', domain: '', description: '', tenantType: 'other', validState: 1, menuIds: [], adminUsername: '', adminPassword: '' })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -563,17 +593,20 @@ const handleSubmit = async () => {
|
|||||||
message.success('保存成功')
|
message.success('保存成功')
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
} else {
|
} else {
|
||||||
const created = await tenantsApi.create({
|
const result = await tenantsApi.create({
|
||||||
name: form.name, code: form.code,
|
name: form.name, code: form.code,
|
||||||
domain: form.domain || undefined,
|
domain: form.domain || undefined,
|
||||||
description: form.description || undefined,
|
description: form.description || undefined,
|
||||||
tenantType: form.tenantType,
|
tenantType: form.tenantType,
|
||||||
menuIds,
|
menuIds,
|
||||||
|
adminUsername: form.adminUsername || undefined,
|
||||||
|
adminPassword: form.adminPassword || undefined,
|
||||||
} as CreateTenantForm)
|
} as CreateTenantForm)
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
// #6 新建成功引导
|
// 显示创建成功 + 管理员信息
|
||||||
lastCreatedName.value = form.name
|
lastCreatedName.value = form.name
|
||||||
lastCreatedId.value = created.id
|
lastCreatedCode.value = form.code
|
||||||
|
createdAdmin.value = result.admin
|
||||||
guideVisible.value = true
|
guideVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,12 +619,20 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #6 引导跳转创建管理员
|
// 创建成功后的登录地址
|
||||||
const goCreateAdmin = () => {
|
const loginUrlForCreated = computed(() => {
|
||||||
guideVisible.value = false
|
return `${window.location.origin}/${lastCreatedCode.value}/login`
|
||||||
// 跳转到用户管理页面(带上租户信息)
|
})
|
||||||
const tenantCode = authStore.tenantCode || 'super'
|
|
||||||
router.push(`/${tenantCode}/system/users`)
|
// 复制管理员账号信息
|
||||||
|
const copyAdminInfo = () => {
|
||||||
|
if (!createdAdmin.value) return
|
||||||
|
const info = `机构:${lastCreatedName.value}\n账号:${createdAdmin.value.username}\n密码:${createdAdmin.value.password}\n登录地址:${loginUrlForCreated.value}`
|
||||||
|
navigator.clipboard.writeText(info).then(() => {
|
||||||
|
message.success('账号信息已复制到剪贴板')
|
||||||
|
}).catch(() => {
|
||||||
|
message.info(info)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@ -641,6 +682,17 @@ $primary: #6366f1;
|
|||||||
|
|
||||||
.form-hint { font-size: 12px; color: #9ca3af; }
|
.form-hint { font-size: 12px; color: #9ca3af; }
|
||||||
|
|
||||||
|
.admin-info-card {
|
||||||
|
background: #f6f8fc; border-radius: 10px; padding: 16px 24px; text-align: left; margin: 0 auto; max-width: 380px;
|
||||||
|
.admin-info-row {
|
||||||
|
display: flex; justify-content: space-between; align-items: center; padding: 8px 0;
|
||||||
|
&:not(:last-child) { border-bottom: 1px solid #eef0f5; }
|
||||||
|
}
|
||||||
|
.admin-info-label { font-size: 13px; color: #6b7280; }
|
||||||
|
.admin-info-value { font-size: 14px; font-weight: 600; color: #1e1b4b; }
|
||||||
|
.admin-info-password { color: #dc2626; font-family: monospace; letter-spacing: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
.menu-config {
|
.menu-config {
|
||||||
.menu-config-hint { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
|
.menu-config-hint { font-size: 13px; color: #6b7280; margin-bottom: 16px; }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user