fix: 补齐作品租户隔离与租户菜单树
- 作品列表/统计补齐 validState 与租户条件,关键字支持报名/队伍信息匹配 - 新增租户菜单树接口与服务实现,结构对齐用户菜单树 - t_biz_contest_work 增加 deleted 字段,补充 flyway 迁移与启动时轻量修复 Made-with: Cursor
This commit is contained in:
parent
ab5bd36cec
commit
ea65b55332
@ -0,0 +1,49 @@
|
||||
package com.competition.common.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 数据库启动时轻量修复:
|
||||
* 由于本项目部分环境可能没有跑过最新的 init.sql,
|
||||
* 导致 `t_biz_contest_work` 缺少 `deleted` 字段,从而 MyBatis-Plus 逻辑删除查询报 500。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ContestWorkSchemaRepair implements ApplicationRunner {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try {
|
||||
Integer columnCnt = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) " +
|
||||
"FROM information_schema.columns " +
|
||||
"WHERE table_schema = DATABASE() " +
|
||||
"AND table_name = 't_biz_contest_work' " +
|
||||
"AND column_name = 'deleted'",
|
||||
Integer.class
|
||||
);
|
||||
|
||||
if (columnCnt == null || columnCnt == 0) {
|
||||
log.warn("检测到表 `t_biz_contest_work` 缺少字段 `deleted`,尝试补齐...");
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE t_biz_contest_work " +
|
||||
"ADD COLUMN deleted tinyint NOT NULL DEFAULT '0' " +
|
||||
"COMMENT '逻辑删除:0-未删除,1-已删除'"
|
||||
);
|
||||
log.info("补齐字段 `t_biz_contest_work.deleted` 完成");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 不阻断启动,给调用方避免 500 需要依赖数据库权限/执行成功
|
||||
log.warn("补齐 `t_biz_contest_work.deleted` 失败:{}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,9 @@ public class ContestWorkController {
|
||||
@RequirePermission("work:read")
|
||||
@Operation(summary = "获取作品统计")
|
||||
public Result<Map<String, Object>> getStats(@RequestParam(required = false) Long contestId) {
|
||||
return Result.success(workService.getStats(contestId, SecurityUtil.getCurrentTenantId()));
|
||||
return Result.success(
|
||||
workService.getStats(contestId, SecurityUtil.getCurrentTenantId(), SecurityUtil.isSuperAdmin())
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
|
||||
@ -15,7 +15,7 @@ public interface IContestWorkService extends IService<BizContestWork> {
|
||||
|
||||
PageResult<Map<String, Object>> findAll(QueryWorkDto dto, Long tenantId, boolean isSuperTenant);
|
||||
|
||||
Map<String, Object> getStats(Long contestId, Long tenantId);
|
||||
Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperTenant);
|
||||
|
||||
Map<String, Object> findDetail(Long id);
|
||||
|
||||
|
||||
@ -160,9 +160,44 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
} else if (!isSuperTenant && tenantId != null) {
|
||||
wrapper.eq(BizContestWork::getTenantId, tenantId);
|
||||
}
|
||||
Set<Long> keywordRegistrationIds = Collections.emptySet();
|
||||
if (StringUtils.hasText(dto.getKeyword())) {
|
||||
wrapper.and(w -> w.like(BizContestWork::getTitle, dto.getKeyword())
|
||||
.or().like(BizContestWork::getWorkNo, dto.getKeyword()));
|
||||
String keyword = dto.getKeyword();
|
||||
|
||||
// keyword 命中口径:作品编号(work_no)/作者姓名(account_name)/报名账号(account_no)/队伍名称(team_name)
|
||||
LambdaQueryWrapper<BizContestRegistration> regWrapper = new LambdaQueryWrapper<>();
|
||||
if (dto.getContestId() != null) {
|
||||
regWrapper.eq(BizContestRegistration::getContestId, dto.getContestId());
|
||||
}
|
||||
regWrapper.eq(BizContestRegistration::getValidState, 1);
|
||||
|
||||
if (dto.getTenantId() != null) {
|
||||
regWrapper.eq(BizContestRegistration::getTenantId, dto.getTenantId());
|
||||
} else if (!isSuperTenant && tenantId != null) {
|
||||
regWrapper.eq(BizContestRegistration::getTenantId, tenantId);
|
||||
}
|
||||
|
||||
regWrapper.and(w -> w.like(BizContestRegistration::getAccountNo, keyword)
|
||||
.or().like(BizContestRegistration::getAccountName, keyword)
|
||||
.or().like(BizContestRegistration::getTeamName, keyword));
|
||||
|
||||
List<BizContestRegistration> regs = contestRegistrationMapper.selectList(regWrapper);
|
||||
keywordRegistrationIds = regs.stream()
|
||||
.map(BizContestRegistration::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (!keywordRegistrationIds.isEmpty()) {
|
||||
// 使用 apply 拼接 IN 条件,避免当前 MyBatis-Plus LambdaQueryWrapper 的
|
||||
// or().in() 链式泛型推断导致的编译失败。
|
||||
String registrationIdIn = keywordRegistrationIds.stream()
|
||||
.map(String::valueOf)
|
||||
.collect(Collectors.joining(","));
|
||||
wrapper.and(w -> w.like(BizContestWork::getWorkNo, keyword)
|
||||
.or().apply("registration_id IN (" + registrationIdIn + ")"));
|
||||
} else {
|
||||
wrapper.and(w -> w.like(BizContestWork::getWorkNo, keyword));
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(dto.getSubmitStartTime())) {
|
||||
wrapper.ge(BizContestWork::getSubmitTime,
|
||||
@ -175,17 +210,23 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
|
||||
// 默认只查最新版本
|
||||
wrapper.eq(BizContestWork::getIsLatest, true);
|
||||
wrapper.eq(BizContestWork::getValidState, 1);
|
||||
wrapper.orderByDesc(BizContestWork::getSubmitTime);
|
||||
|
||||
Page<BizContestWork> page = new Page<>(dto.getPage(), dto.getPageSize());
|
||||
Page<BizContestWork> result = contestWorkMapper.selectPage(page, wrapper);
|
||||
|
||||
// 批量查询报名信息
|
||||
// 批量查询报名/赛事信息
|
||||
Set<Long> registrationIds = result.getRecords().stream()
|
||||
.map(BizContestWork::getRegistrationId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<Long> contestIds = result.getRecords().stream()
|
||||
.map(BizContestWork::getContestId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<Long, BizContestRegistration> registrationMap = new HashMap<>();
|
||||
if (!registrationIds.isEmpty()) {
|
||||
List<BizContestRegistration> registrations = contestRegistrationMapper.selectBatchIds(registrationIds);
|
||||
@ -193,12 +234,47 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
.collect(Collectors.toMap(BizContestRegistration::getId, r -> r));
|
||||
}
|
||||
|
||||
Map<Long, BizContest> contestMap = new HashMap<>();
|
||||
if (!contestIds.isEmpty()) {
|
||||
List<BizContest> contests = contestMapper.selectBatchIds(contestIds);
|
||||
contestMap = contests.stream()
|
||||
.collect(Collectors.toMap(BizContest::getId, c -> c));
|
||||
}
|
||||
|
||||
Map<Long, BizContestRegistration> finalRegistrationMap = registrationMap;
|
||||
Map<Long, BizContest> finalContestMap = contestMap;
|
||||
List<Map<String, Object>> voList = result.getRecords().stream()
|
||||
.map(work -> {
|
||||
Map<String, Object> map = workToMap(work);
|
||||
|
||||
BizContest contest = finalContestMap.get(work.getContestId());
|
||||
if (contest != null) {
|
||||
Map<String, Object> contestVo = new LinkedHashMap<>();
|
||||
contestVo.put("id", contest.getId());
|
||||
contestVo.put("contestName", contest.getContestName());
|
||||
map.put("contest", contestVo);
|
||||
}
|
||||
|
||||
BizContestRegistration reg = finalRegistrationMap.get(work.getRegistrationId());
|
||||
if (reg != null) {
|
||||
Map<String, Object> userVo = new LinkedHashMap<>();
|
||||
userVo.put("id", reg.getUserId());
|
||||
userVo.put("username", reg.getAccountNo());
|
||||
userVo.put("nickname", reg.getAccountName());
|
||||
|
||||
Map<String, Object> regVo = new LinkedHashMap<>();
|
||||
regVo.put("id", reg.getId());
|
||||
regVo.put("user", userVo);
|
||||
|
||||
if (StringUtils.hasText(reg.getTeamName()) || reg.getTeamId() != null) {
|
||||
Map<String, Object> teamVo = new LinkedHashMap<>();
|
||||
teamVo.put("teamName", reg.getTeamName());
|
||||
regVo.put("team", teamVo);
|
||||
}
|
||||
|
||||
map.put("registration", regVo);
|
||||
|
||||
// 兼容旧字段:保留扁平账号信息
|
||||
map.put("accountNo", reg.getAccountNo());
|
||||
map.put("accountName", reg.getAccountName());
|
||||
map.put("userId", reg.getUserId());
|
||||
@ -211,7 +287,7 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getStats(Long contestId, Long tenantId) {
|
||||
public Map<String, Object> getStats(Long contestId, Long tenantId, boolean isSuperTenant) {
|
||||
log.info("获取作品统计,赛事ID:{}", contestId);
|
||||
|
||||
LambdaQueryWrapper<BizContestWork> baseWrapper = new LambdaQueryWrapper<>();
|
||||
@ -219,6 +295,10 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
baseWrapper.eq(BizContestWork::getContestId, contestId);
|
||||
}
|
||||
baseWrapper.eq(BizContestWork::getIsLatest, true);
|
||||
baseWrapper.eq(BizContestWork::getValidState, 1);
|
||||
if (!isSuperTenant && tenantId != null) {
|
||||
baseWrapper.eq(BizContestWork::getTenantId, tenantId);
|
||||
}
|
||||
long total = count(baseWrapper);
|
||||
|
||||
LambdaQueryWrapper<BizContestWork> submittedWrapper = new LambdaQueryWrapper<>();
|
||||
@ -226,6 +306,10 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
submittedWrapper.eq(BizContestWork::getContestId, contestId);
|
||||
}
|
||||
submittedWrapper.eq(BizContestWork::getIsLatest, true);
|
||||
submittedWrapper.eq(BizContestWork::getValidState, 1);
|
||||
if (!isSuperTenant && tenantId != null) {
|
||||
submittedWrapper.eq(BizContestWork::getTenantId, tenantId);
|
||||
}
|
||||
submittedWrapper.eq(BizContestWork::getStatus, "submitted");
|
||||
long submitted = count(submittedWrapper);
|
||||
|
||||
@ -234,31 +318,31 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
|
||||
reviewingWrapper.eq(BizContestWork::getContestId, contestId);
|
||||
}
|
||||
reviewingWrapper.eq(BizContestWork::getIsLatest, true);
|
||||
reviewingWrapper.eq(BizContestWork::getValidState, 1);
|
||||
if (!isSuperTenant && tenantId != null) {
|
||||
reviewingWrapper.eq(BizContestWork::getTenantId, tenantId);
|
||||
}
|
||||
reviewingWrapper.eq(BizContestWork::getStatus, "reviewing");
|
||||
long reviewing = count(reviewingWrapper);
|
||||
|
||||
LambdaQueryWrapper<BizContestWork> acceptedWrapper = new LambdaQueryWrapper<>();
|
||||
LambdaQueryWrapper<BizContestWork> reviewedWrapper = new LambdaQueryWrapper<>();
|
||||
if (contestId != null) {
|
||||
acceptedWrapper.eq(BizContestWork::getContestId, contestId);
|
||||
reviewedWrapper.eq(BizContestWork::getContestId, contestId);
|
||||
}
|
||||
acceptedWrapper.eq(BizContestWork::getIsLatest, true);
|
||||
acceptedWrapper.eq(BizContestWork::getStatus, "accepted");
|
||||
long accepted = count(acceptedWrapper);
|
||||
|
||||
LambdaQueryWrapper<BizContestWork> rejectedWrapper = new LambdaQueryWrapper<>();
|
||||
if (contestId != null) {
|
||||
rejectedWrapper.eq(BizContestWork::getContestId, contestId);
|
||||
reviewedWrapper.eq(BizContestWork::getIsLatest, true);
|
||||
reviewedWrapper.eq(BizContestWork::getValidState, 1);
|
||||
if (!isSuperTenant && tenantId != null) {
|
||||
reviewedWrapper.eq(BizContestWork::getTenantId, tenantId);
|
||||
}
|
||||
rejectedWrapper.eq(BizContestWork::getIsLatest, true);
|
||||
rejectedWrapper.eq(BizContestWork::getStatus, "rejected");
|
||||
long rejected = count(rejectedWrapper);
|
||||
// 已评完口径:兼容 accepted/awarded 两种结果状态
|
||||
reviewedWrapper.in(BizContestWork::getStatus, Arrays.asList("accepted", "awarded"));
|
||||
long reviewed = count(reviewedWrapper);
|
||||
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("total", total);
|
||||
stats.put("submitted", submitted);
|
||||
stats.put("reviewing", reviewing);
|
||||
stats.put("accepted", accepted);
|
||||
stats.put("rejected", rejected);
|
||||
stats.put("reviewed", reviewed);
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
||||
@ -2,10 +2,14 @@ package com.competition.modules.sys.controller;
|
||||
|
||||
import com.competition.common.result.PageResult;
|
||||
import com.competition.common.result.Result;
|
||||
import com.competition.common.exception.BusinessException;
|
||||
import com.competition.common.enums.ErrorCode;
|
||||
import com.competition.common.util.SecurityUtil;
|
||||
import com.competition.modules.sys.dto.CreateTenantDto;
|
||||
import com.competition.modules.sys.dto.UpdateTenantDto;
|
||||
import com.competition.modules.sys.entity.SysMenu;
|
||||
import com.competition.modules.sys.entity.SysTenant;
|
||||
import com.competition.modules.sys.service.ISysMenuService;
|
||||
import com.competition.modules.sys.service.ISysTenantService;
|
||||
import com.competition.security.annotation.RequirePermission;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@ -15,6 +19,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Tag(name = "租户管理")
|
||||
@RestController
|
||||
@ -23,6 +29,7 @@ import java.util.Map;
|
||||
public class SysTenantController {
|
||||
|
||||
private final ISysTenantService tenantService;
|
||||
private final ISysMenuService menuService;
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission("tenant:create")
|
||||
@ -90,4 +97,24 @@ public class SysTenantController {
|
||||
tenantService.removeTenant(id, currentTenantId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/menus")
|
||||
@RequirePermission("tenant:read")
|
||||
@Operation(summary = "获取租户菜单树")
|
||||
public Result<List<SysMenu>> getTenantMenus(@PathVariable Long id) {
|
||||
Long currentTenantId = SecurityUtil.getCurrentTenantId();
|
||||
boolean isSuperAdmin = SecurityUtil.isSuperAdmin();
|
||||
|
||||
// 非超管只能查询自身租户
|
||||
if (!isSuperAdmin && !Objects.equals(id, currentTenantId)) {
|
||||
throw BusinessException.of(ErrorCode.FORBIDDEN, "只能查询当前租户菜单");
|
||||
}
|
||||
|
||||
// 校验租户是否存在
|
||||
if (tenantService.getById(id) == null) {
|
||||
throw BusinessException.of(ErrorCode.NOT_FOUND, "租户不存在");
|
||||
}
|
||||
|
||||
return Result.success(menuService.getTenantMenus(id));
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,11 @@ public interface ISysMenuService extends IService<SysMenu> {
|
||||
|
||||
List<SysMenu> findAllTree();
|
||||
|
||||
/**
|
||||
* 获取指定租户可用的菜单树(基于 t_sys_tenant_menu 分配)
|
||||
*/
|
||||
List<SysMenu> getTenantMenus(Long tenantId);
|
||||
|
||||
List<SysMenu> getUserMenus(Long userId, Long tenantId, boolean isSuperAdmin);
|
||||
|
||||
SysMenu findDetail(Long id);
|
||||
|
||||
@ -95,6 +95,34 @@ public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> impl
|
||||
return buildTree(filteredMenus, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SysMenu> getTenantMenus(Long tenantId) {
|
||||
if (tenantId == null) return Collections.emptyList();
|
||||
|
||||
// 获取所有有效菜单
|
||||
List<SysMenu> allMenus = list(new LambdaQueryWrapper<SysMenu>()
|
||||
.eq(SysMenu::getValidState, 1)
|
||||
.orderByAsc(SysMenu::getSort));
|
||||
|
||||
// 获取租户已分配的菜单 ID
|
||||
List<SysTenantMenu> tenantMenus = tenantMenuMapper.selectList(
|
||||
new LambdaQueryWrapper<SysTenantMenu>().eq(SysTenantMenu::getTenantId, tenantId));
|
||||
Set<Long> tenantMenuIds = tenantMenus.stream().map(SysTenantMenu::getMenuId).collect(Collectors.toSet());
|
||||
|
||||
// 过滤出租户可用的菜单
|
||||
List<SysMenu> filteredMenus = allMenus.stream()
|
||||
.filter(menu -> tenantMenuIds.contains(menu.getId()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 补全父菜单(确保树结构完整)
|
||||
Set<Long> filteredIds = filteredMenus.stream().map(SysMenu::getId).collect(Collectors.toSet());
|
||||
for (SysMenu menu : new ArrayList<>(filteredMenus)) {
|
||||
addParentsIfMissing(menu, allMenus, filteredMenus, filteredIds);
|
||||
}
|
||||
|
||||
return buildTree(filteredMenus, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SysMenu findDetail(Long id) {
|
||||
SysMenu menu = getById(id);
|
||||
|
||||
@ -568,6 +568,7 @@ CREATE TABLE `t_biz_contest_work` (
|
||||
`user_work_id` int DEFAULT NULL,
|
||||
`create_by` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人账号',
|
||||
`update_by` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人账号',
|
||||
`deleted` tinyint NOT NULL DEFAULT '0' COMMENT '逻辑删除:0-未删除,1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `t_contest_work_work_no_key` (`work_no`),
|
||||
KEY `t_contest_work_tenant_id_contest_id_is_latest_idx` (`tenant_id`,`contest_id`,`is_latest`),
|
||||
@ -579,7 +580,7 @@ CREATE TABLE `t_biz_contest_work` (
|
||||
KEY `t_contest_work_user_work_id_fkey` (`user_work_id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
INSERT INTO `t_biz_contest_work` VALUES (1,9,4,4,NULL,'森林里的音乐会','小动物们在森林里举办了一场盛大的音乐会',NULL,1,1,'awarded','2026-03-18 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c1-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.394',1,'一等奖','一等奖',NULL,90.33,NULL,1,NULL,NULL,NULL),(2,9,4,5,NULL,'海边的贝壳梦','小女孩在海边捡到了一个会说话的贝壳',NULL,1,1,'awarded','2026-03-20 14:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c1-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.401',1,'三等奖','三等奖',NULL,79.00,NULL,3,NULL,NULL,NULL),(3,9,4,6,NULL,'云端上的图书馆','一座建在云朵上的神奇图书馆',NULL,1,1,'awarded','2026-03-22 16:30:00.000',14,NULL,'student','https://picsum.photos/seed/work-c1-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.398',1,'二等奖','二等奖',NULL,85.33,NULL,2,NULL,NULL,NULL),(4,9,5,9,NULL,'和妈妈一起画星星','记录了和妈妈一起画画的温馨夜晚',NULL,1,1,'submitted','2026-03-05 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c2-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(5,9,5,10,NULL,'爸爸的大手','用绘画记录爸爸温暖的大手牵着我的小手',NULL,1,1,'submitted','2026-03-08 11:30:00.000',13,NULL,'student','https://picsum.photos/seed/work-c2-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(6,9,5,11,NULL,'全家福的故事','一幅全家福背后的温暖故事',NULL,1,1,'submitted','2026-03-10 09:00:00.000',14,NULL,'student','https://picsum.photos/seed/work-c2-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(7,9,6,13,NULL,'我的寒假阅读日记','记录了寒假30天的阅读旅程',NULL,1,1,'accepted','2026-02-10 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c3-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(8,9,6,14,NULL,'绘本里的四季','用绘画展现四季的变化',NULL,1,1,'accepted','2026-02-12 15:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c3-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);
|
||||
INSERT INTO `t_biz_contest_work` (id,tenant_id,contest_id,registration_id,work_no,title,description,files,version,is_latest,status,submit_time,submitter_user_id,submitter_account_no,submit_source,preview_url,ai_model_meta,creator,modifier,create_time,modify_time,valid_state,award_level,award_name,certificate_url,final_score,preview_urls,rank,user_work_id,create_by,update_by) VALUES (1,9,4,4,NULL,'森林里的音乐会','小动物们在森林里举办了一场盛大的音乐会',NULL,1,1,'awarded','2026-03-18 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c1-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.394',1,'一等奖','一等奖',NULL,90.33,NULL,1,NULL,NULL,NULL),(2,9,4,5,NULL,'海边的贝壳梦','小女孩在海边捡到了一个会说话的贝壳',NULL,1,1,'awarded','2026-03-20 14:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c1-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.401',1,'三等奖','三等奖',NULL,79.00,NULL,3,NULL,NULL,NULL),(3,9,4,6,NULL,'云端上的图书馆','一座建在云朵上的神奇图书馆',NULL,1,1,'awarded','2026-03-22 16:30:00.000',14,NULL,'student','https://picsum.photos/seed/work-c1-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 11:43:55.398',1,'二等奖','二等奖',NULL,85.33,NULL,2,NULL,NULL,NULL),(4,9,5,9,NULL,'和妈妈一起画星星','记录了和妈妈一起画画的温馨夜晚',NULL,1,1,'submitted','2026-03-05 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c2-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(5,9,5,10,NULL,'爸爸的大手','用绘画记录爸爸温暖的大手牵着我的小手',NULL,1,1,'submitted','2026-03-08 11:30:00.000',13,NULL,'student','https://picsum.photos/seed/work-c2-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(6,9,5,11,NULL,'全家福的故事','一幅全家福背后的温暖故事',NULL,1,1,'submitted','2026-03-10 09:00:00.000',14,NULL,'student','https://picsum.photos/seed/work-c2-3/400/533',NULL,14,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(7,9,6,13,NULL,'我的寒假阅读日记','记录了寒假30天的阅读旅程',NULL,1,1,'accepted','2026-02-10 10:00:00.000',12,NULL,'student','https://picsum.photos/seed/work-c3-1/400/533',NULL,12,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL),(8,9,6,14,NULL,'绘本里的四季','用绘画展现四季的变化',NULL,1,1,'accepted','2026-02-12 15:00:00.000',13,NULL,'student','https://picsum.photos/seed/work-c3-2/400/533',NULL,13,NULL,'2026-03-31 16:23:06.000','2026-03-31 16:23:06.000',1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `t_biz_contest_work_attachment` (
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
-- Flyway migration:
|
||||
-- 兼容部分环境没有跑过最新的 init.sql,
|
||||
-- 导致 t_biz_contest_work 缺少 deleted 字段,从而逻辑删除查询报错。
|
||||
SET @column_cnt := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 't_biz_contest_work'
|
||||
AND column_name = 'deleted'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@column_cnt = 0,
|
||||
'ALTER TABLE t_biz_contest_work ADD COLUMN deleted tinyint NOT NULL DEFAULT ''0'' COMMENT ''逻辑删除:0-未删除,1-已删除''',
|
||||
'SELECT 1'
|
||||
);
|
||||
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
@ -145,7 +145,7 @@ const users = await prisma.user.findMany({ where });
|
||||
- `GET /api/tenants/:id` - 获取租户详情
|
||||
- `PATCH /api/tenants/:id` - 更新租户(包括菜单分配)
|
||||
- `DELETE /api/tenants/:id` - 删除租户
|
||||
- `GET /api/tenants/:id/menus` - 获取租户菜单树
|
||||
- `GET /api/tenants/:id/menus` - 获取租户菜单树(返回与 `/api/menus/user-menus` 相同的菜单树结构;数据来源 `t_sys_tenant_menu`;非超管只能查询自身租户)
|
||||
|
||||
### 其他接口
|
||||
|
||||
|
||||
@ -275,8 +275,9 @@ const handleEdit = async (record: Role) => {
|
||||
form.code = detail.code
|
||||
form.description = detail.description || ''
|
||||
// 回显权限
|
||||
form.permissionIds = detail.permissions?.map((rp) => rp.permission.id) || []
|
||||
form.permissionIds = detail.permissions?.map((rp) => rp.id) || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error('获取角色详情失败')
|
||||
modalVisible.value = false
|
||||
} finally {
|
||||
|
||||
@ -7,12 +7,8 @@
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div
|
||||
v-for="item in statsItems"
|
||||
:key="item.type"
|
||||
:class="['stat-card', { active: activeType === item.type }]"
|
||||
@click="handleStatClick(item.type)"
|
||||
>
|
||||
<div v-for="item in statsItems" :key="item.type" :class="['stat-card', { active: activeType === item.type }]"
|
||||
@click="handleStatClick(item.type)">
|
||||
<div class="stat-icon" :style="{ background: item.bgColor }">
|
||||
<component :is="item.icon" :style="{ color: item.color }" />
|
||||
</div>
|
||||
@ -27,43 +23,21 @@
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline" @finish="handleSearch">
|
||||
<a-form-item label="关键词">
|
||||
<a-input
|
||||
v-model:value="searchParams.keyword"
|
||||
placeholder="用户名 / 昵称 / 手机号"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<a-input v-model:value="searchParams.keyword" placeholder="用户名 / 昵称 / 手机号" allow-clear style="width: 200px"
|
||||
@press-enter="handleSearch" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!activeType || activeType === 'org'" label="所属机构">
|
||||
<a-select
|
||||
v-model:value="searchParams.filterTenantId"
|
||||
placeholder="全部机构"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterTenantOption"
|
||||
style="width: 180px"
|
||||
:options="tenantOptions"
|
||||
/>
|
||||
<a-select v-model:value="searchParams.filterTenantId" placeholder="全部机构" allow-clear show-search
|
||||
:filter-option="filterTenantOption" style="width: 180px" :options="tenantOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="用户来源">
|
||||
<a-select
|
||||
v-model:value="searchParams.userSource"
|
||||
placeholder="全部"
|
||||
allow-clear
|
||||
style="width: 130px"
|
||||
>
|
||||
<a-select v-model:value="searchParams.userSource" placeholder="全部" allow-clear style="width: 130px">
|
||||
<a-select-option value="admin_created">管理创建</a-select-option>
|
||||
<a-select-option value="self_registered">自主注册</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model:value="searchParams.status"
|
||||
placeholder="全部"
|
||||
allow-clear
|
||||
style="width: 100px"
|
||||
>
|
||||
<a-select v-model:value="searchParams.status" placeholder="全部" allow-clear style="width: 100px">
|
||||
<a-select-option value="enabled">正常</a-select-option>
|
||||
<a-select-option value="disabled">禁用</a-select-option>
|
||||
</a-select>
|
||||
@ -71,11 +45,15 @@
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
@ -84,15 +62,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="data-table"
|
||||
>
|
||||
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id"
|
||||
@change="handleTableChange" class="data-table">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'userInfo'">
|
||||
<div class="user-cell">
|
||||
@ -156,12 +127,7 @@
|
||||
</a-table>
|
||||
|
||||
<!-- 详情 Drawer -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
title="用户详情"
|
||||
:width="560"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-drawer v-model:open="detailVisible" title="用户详情" :width="560" :destroy-on-close="true">
|
||||
<template v-if="detailLoading">
|
||||
<div class="drawer-loading"><a-spin /></div>
|
||||
</template>
|
||||
@ -191,7 +157,8 @@
|
||||
{{ detailData.status === 'enabled' ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailData.organization" label="单位">{{ detailData.organization
|
||||
}}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<!-- 公众用户:子女账号 -->
|
||||
@ -273,12 +240,8 @@
|
||||
</a-drawer>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="passwordModalVisible"
|
||||
title="重置密码"
|
||||
:confirm-loading="passwordLoading"
|
||||
@ok="handlePasswordSubmit"
|
||||
>
|
||||
<a-modal v-model:open="passwordModalVisible" title="重置密码" :confirm-loading="passwordLoading"
|
||||
@ok="handlePasswordSubmit">
|
||||
<a-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" :label-col="{ span: 6 }">
|
||||
<a-form-item label="新密码" name="newPassword">
|
||||
<a-input-password v-model:value="passwordForm.newPassword" placeholder="请输入新密码" />
|
||||
@ -571,11 +534,13 @@ $primary: #6366f1;
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-bottom: none;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user