feat: 作品分配仅限活动评委、评委库仅启用及 UGC 调整

- 作品管理分配评委仅使用活动显式名单,assignWork 校验 t_biz_contest_judge

- 添加评委/评审进度选择评委时仅查询启用账号;接口文档与 API 注释

- UGC 作品分页与公开创作服务相关改动

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-09 11:04:37 +08:00
parent 937f0650f0
commit b19acbd6d5
13 changed files with 194 additions and 27 deletions

View File

@ -30,7 +30,8 @@ public class JudgesManagementController {
@GetMapping @GetMapping
@RequirePermission("judge:read") @RequirePermission("judge:read")
@Operation(summary = "查询评委列表") @Operation(summary = "查询评委列表",
description = "statusenabled/disabled不传则返回全部状态。向活动添加评委、评审进度等「仅用于选择评委」的场景请传 status=enabled不包含停用账号。")
public Result<PageResult<Map<String, Object>>> findAll( public Result<PageResult<Map<String, Object>>> findAll(
@RequestParam(defaultValue = "1") Long page, @RequestParam(defaultValue = "1") Long page,
@RequestParam(defaultValue = "10") Long pageSize, @RequestParam(defaultValue = "10") Long pageSize,

View File

@ -36,7 +36,7 @@ public class ContestJudgeController {
@GetMapping("/contest/{contestId}") @GetMapping("/contest/{contestId}")
@RequirePermission("contest:read") @RequirePermission("contest:read")
@Operation(summary = "查询活动评委列表", @Operation(summary = "查询活动评委列表",
description = "返回 assigned显式关联)与 implicitPool平台隐式池。添加评委抽屉仅用 assigned 回显;作品分配可选池为 assigned implicitPool前端合并") description = "返回 assignedt_biz_contest_judge 显式关联)与 implicitPool平台评委租户下启用用户未写入关联表时由业务视为可选池。添加评委抽屉仅用 assigned 回显;作品管理「分配评委」仅应使用 assigned")
public Result<ContestJudgesForContestVo> findByContest(@PathVariable Long contestId) { public Result<ContestJudgesForContestVo> findByContest(@PathVariable Long contestId) {
return Result.success(contestJudgeService.findByContest(contestId)); return Result.success(contestJudgeService.findByContest(contestId));
} }

View File

@ -50,6 +50,19 @@ public class ContestReviewServiceImpl implements IContestReviewService {
private final ContestReviewRuleMapper reviewRuleMapper; private final ContestReviewRuleMapper reviewRuleMapper;
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
/**
* 仅允许将已在本活动评委表 t_biz_contest_judge 中显式配置的评委新分配到作品
*/
private void assertJudgeAssignedToContest(Long contestId, Long judgeId) {
LambdaQueryWrapper<BizContestJudge> w = new LambdaQueryWrapper<>();
w.eq(BizContestJudge::getContestId, contestId);
w.eq(BizContestJudge::getJudgeId, judgeId);
w.eq(BizContestJudge::getValidState, 1);
if (judgeMapper.selectCount(w) == 0) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "所选评委未加入本活动,请先在活动评委管理中配置");
}
}
// ====== 作品分配 ====== // ====== 作品分配 ======
@Override @Override
@ -99,6 +112,8 @@ public class ContestReviewServiceImpl implements IContestReviewService {
continue; continue;
} }
assertJudgeAssignedToContest(contestId, judgeId);
BizContestWorkJudgeAssignment assignment = new BizContestWorkJudgeAssignment(); BizContestWorkJudgeAssignment assignment = new BizContestWorkJudgeAssignment();
assignment.setContestId(contestId); assignment.setContestId(contestId);
assignment.setWorkId(workId); assignment.setWorkId(workId);

View File

@ -24,6 +24,7 @@ public class PublicCreationService {
private final UgcWorkMapper ugcWorkMapper; private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper; private final UgcWorkPageMapper ugcWorkPageMapper;
private final PublicUserWorkService publicUserWorkService;
/** /**
* 提交 AI 创作保留但降级为辅助接口 * 提交 AI 创作保留但降级为辅助接口
@ -118,6 +119,7 @@ public class PublicCreationService {
.orderByDesc(UgcWork::getCreateTime); .orderByDesc(UgcWork::getCreateTime);
IPage<UgcWork> result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); IPage<UgcWork> result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper);
publicUserWorkService.attachPageCounts(result.getRecords());
return PageResult.from(result); return PageResult.from(result);
} }
} }

View File

@ -11,6 +11,7 @@ import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage; import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.entity.UgcWorkTag; import com.competition.modules.ugc.entity.UgcWorkTag;
import com.competition.modules.ugc.mapper.UgcWorkMapper; import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageCountRow;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper; import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import com.competition.modules.ugc.mapper.UgcWorkTagMapper; import com.competition.modules.ugc.mapper.UgcWorkTagMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -21,6 +22,7 @@ import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
@ -89,9 +91,36 @@ public class PublicUserWorkService {
wrapper.orderByDesc(UgcWork::getCreateTime); wrapper.orderByDesc(UgcWork::getCreateTime);
IPage<UgcWork> result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper); IPage<UgcWork> result = ugcWorkMapper.selectPage(new Page<>(page, pageSize), wrapper);
fillPageCounts(result.getRecords());
return PageResult.from(result); return PageResult.from(result);
} }
/**
* 为作品列表填充 _count.pages供其它模块复用如创作历史
*/
public void attachPageCounts(List<UgcWork> works) {
fillPageCounts(works);
}
/** 为列表中的每条作品填充 _count.pages来自 t_ugc_work_page 行数) */
private void fillPageCounts(List<UgcWork> works) {
if (works == null || works.isEmpty()) {
return;
}
List<Long> ids = works.stream().map(UgcWork::getId).collect(Collectors.toList());
List<UgcWorkPageCountRow> rows = ugcWorkPageMapper.countPagesGroupedByWorkIds(ids);
Map<Long, Integer> idToPages = rows.stream()
.collect(Collectors.toMap(
UgcWorkPageCountRow::getWorkId,
r -> r.getPageCount() != null ? r.getPageCount().intValue() : 0,
(a, b) -> a));
for (UgcWork work : works) {
UgcWork.WorkCountMeta meta = new UgcWork.WorkCountMeta();
meta.setPages(idToPages.getOrDefault(work.getId(), 0));
work.setCount(meta);
}
}
/** /**
* 获取作品详情 * 获取作品详情
*/ */

View File

@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@ -137,4 +138,19 @@ public class UgcWork implements Serializable {
@Schema(description = "修改时间") @Schema(description = "修改时间")
@TableField("modify_time") @TableField("modify_time")
private LocalDateTime modifyTime; private LocalDateTime modifyTime;
/**
* 列表接口填充绘本页数等非表字段与前端 _count.pages 对齐
*/
@Schema(description = "统计信息(列表接口填充)")
@TableField(exist = false)
@JsonProperty("_count")
private WorkCountMeta count;
@Data
@Schema(description = "作品计数")
public static class WorkCountMeta implements Serializable {
@Schema(description = "绘本页数")
private Integer pages;
}
} }

View File

@ -0,0 +1,12 @@
package com.competition.modules.ugc.mapper;
import lombok.Data;
/**
* 按作品分组的绘本页数统计列表接口用
*/
@Data
public class UgcWorkPageCountRow {
private Long workId;
private Long pageCount;
}

View File

@ -3,7 +3,21 @@ package com.competition.modules.ugc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.competition.modules.ugc.entity.UgcWorkPage; import com.competition.modules.ugc.entity.UgcWorkPage;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper @Mapper
public interface UgcWorkPageMapper extends BaseMapper<UgcWorkPage> { public interface UgcWorkPageMapper extends BaseMapper<UgcWorkPage> {
/**
* 批量统计各作品的绘本页数用于作品列表展示
*/
@Select("<script>"
+ "SELECT work_id AS workId, COUNT(1) AS pageCount FROM t_ugc_work_page WHERE work_id IN "
+ "<foreach collection=\"workIds\" item=\"id\" open=\"(\" separator=\",\" close=\")\">#{id}</foreach> "
+ "GROUP BY work_id"
+ "</script>")
List<UgcWorkPageCountRow> countPagesGroupedByWorkIds(@Param("workIds") List<Long> workIds);
} }

View File

@ -551,6 +551,8 @@ export interface ContestJudge {
validState?: number; validState?: number;
/** 隐式平台评委(未写入关联表时由后端追加) */ /** 隐式平台评委(未写入关联表时由后端追加) */
isPlatform?: boolean; isPlatform?: boolean;
/** 作品分配抽屉:历史隐式池分配回显(未在活动评委表中显式配置) */
legacyImplicit?: boolean;
judgeName?: string; judgeName?: string;
judgeUsername?: string; judgeUsername?: string;
assignedCount?: number; assignedCount?: number;
@ -591,7 +593,10 @@ export interface ContestJudgesForContestResponse {
implicitPool: ContestJudge[]; implicitPool: ContestJudge[];
} }
/** 作品分配等场景合并为可选评委池assigned implicitPool */ /**
* assigned implicitPool使 `assigned`
*
*/
export function flattenContestJudgePool( export function flattenContestJudgePool(
r: ContestJudgesForContestResponse, r: ContestJudgesForContestResponse,
): ContestJudge[] { ): ContestJudge[] {

View File

@ -74,7 +74,7 @@ export interface JudgeListResponse {
pageSize: number; pageSize: number;
} }
// 获取评委列表 // 获取评委列表(评委管理页可不传 status 以查看全部;添加评委/选择评委等场景传 status: 'enabled'
export async function getJudgesList( export async function getJudgesList(
params: QueryJudgeParams params: QueryJudgeParams
): Promise<JudgeListResponse> { ): Promise<JudgeListResponse> {

View File

@ -195,6 +195,8 @@ const loadJudges = async () => {
pageSize: judgePagination.pageSize, pageSize: judgePagination.pageSize,
nickname: searchParams.nickname || undefined, nickname: searchParams.nickname || undefined,
organization: searchParams.organization || undefined, organization: searchParams.organization || undefined,
/** 仅可选择启用中的评委,停用账号不展示 */
status: "enabled",
} }
const res = await judgesManagementApi.getList(params) const res = await judgesManagementApi.getList(params)
judgeList.value = res.list judgeList.value = res.list

View File

@ -464,6 +464,7 @@ const fetchJudgeList = async () => {
page: judgePagination.current, page: judgePagination.current,
pageSize: judgePagination.pageSize, pageSize: judgePagination.pageSize,
nickname: judgeSearchParams.nickname || undefined, nickname: judgeSearchParams.nickname || undefined,
status: "enabled",
}) })
judgeList.value = response.list judgeList.value = response.list
judgePagination.total = response.total judgePagination.total = response.total

View File

@ -169,17 +169,28 @@
</a-form> </a-form>
</a-card> </a-card>
<!-- 全部评委列表 --> <a-alert
v-if="!judgeListLoading && judgeListSource.length === 0"
type="info"
show-icon
class="mb-4"
message="请先在活动评委管理中为本活动添加评委"
/>
<!-- 本活动评委列表仅活动显式配置的评委 -->
<a-card class="mb-4" size="small"> <a-card class="mb-4" size="small">
<template #title>全部评委</template> <template #title>本活动评委</template>
<a-table :columns="judgeColumns" :data-source="judgeList" :loading="judgeListLoading" <a-table :columns="judgeColumns" :data-source="judgeTablePageData" :loading="judgeListLoading"
:pagination="judgePagination" :row-selection="{ :pagination="judgePaginationComputed" :row-selection="{
selectedRowKeys: selectedJudgeKeys, selectedRowKeys: selectedJudgeKeys,
onChange: handleJudgeSelectionChange, onChange: handleJudgeSelectionChange,
}" row-key="judgeId" size="small" @change="handleJudgeTableChange"> }" row-key="judgeId" size="small" @change="handleJudgeTableChange">
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'judgeName'"> <template v-if="column.key === 'judgeName'">
{{ record.judgeName || record.judgeUsername || "-" }} <a-space>
<span>{{ record.judgeName || record.judgeUsername || "-" }}</span>
<a-tag v-if="record.legacyImplicit" color="orange">历史分配</a-tag>
</a-space>
</template> </template>
<template v-else-if="column.key === 'phone'"> <template v-else-if="column.key === 'phone'">
{{ record.phone || "-" }} {{ record.phone || "-" }}
@ -204,7 +215,10 @@
<a-list-item> <a-list-item>
<a-list-item-meta> <a-list-item-meta>
<template #title> <template #title>
{{ item.judgeName || item.judgeUsername || "-" }} <a-space>
<span>{{ item.judgeName || item.judgeUsername || "-" }}</span>
<a-tag v-if="item.legacyImplicit" color="orange">历史分配</a-tag>
</a-space>
</template> </template>
<template #description> <template #description>
{{ item.organization || item.tenantName || "-" }} {{ item.organization || item.tenantName || "-" }}
@ -259,7 +273,6 @@ import {
worksApi, worksApi,
reviewsApi, reviewsApi,
judgesApi, judgesApi,
flattenContestJudgePool,
type ContestWork, type ContestWork,
type ContestJudge, type ContestJudge,
} from "@/api/contests" } from "@/api/contests"
@ -354,13 +367,12 @@ const assignLoading = ref(false)
const currentAssignWork = ref<ContestWork | null>(null) const currentAssignWork = ref<ContestWork | null>(null)
const isBatchAssign = ref(false) const isBatchAssign = ref(false)
// // +
const judgeList = ref<ContestJudge[]>([]) const judgeListSource = ref<ContestJudge[]>([])
const judgeListLoading = ref(false) const judgeListLoading = ref(false)
const judgePagination = reactive({ const judgePagination = reactive({
current: 1, current: 1,
pageSize: 10, pageSize: 10,
total: 0,
}) })
const judgeSearchParams = reactive({ const judgeSearchParams = reactive({
nickname: "", nickname: "",
@ -369,23 +381,77 @@ const judgeSearchParams = reactive({
const selectedJudgeKeys = ref<number[]>([]) const selectedJudgeKeys = ref<number[]>([])
const selectedJudgeRows = ref<ContestJudge[]>([]) const selectedJudgeRows = ref<ContestJudge[]>([])
/** 将作品上已分配但不在活动显式名单中的评委并入列表,避免误删历史分配 */
function mergeOrphanJudges(record: ContestWork, base: ContestJudge[]): ContestJudge[] {
const seen = new Set(base.map((j) => j.judgeId))
const extra: ContestJudge[] = []
for (const a of record.assignments || []) {
if (!seen.has(a.judgeId)) {
seen.add(a.judgeId)
extra.push({
contestId,
judgeId: a.judgeId,
judgeName: a.judge?.nickname || a.judge?.username,
judgeUsername: a.judge?.username,
assignedCount: 0,
legacyImplicit: true,
} as ContestJudge)
}
}
return [...base, ...extra]
}
const judgeFiltered = computed(() => {
const nick = (judgeSearchParams.nickname || "").trim().toLowerCase()
const tenant = (judgeSearchParams.tenantName || "").trim().toLowerCase()
let list = judgeListSource.value
if (nick) {
list = list.filter(
(j) =>
(j.judgeName || "").toLowerCase().includes(nick) ||
(j.judgeUsername || "").toLowerCase().includes(nick),
)
}
if (tenant) {
list = list.filter(
(j) =>
(j.organization || "").toLowerCase().includes(tenant) ||
(j.tenantName || "").toLowerCase().includes(tenant),
)
}
return list
})
const judgeTablePageData = computed(() => {
const start = (judgePagination.current - 1) * judgePagination.pageSize
return judgeFiltered.value.slice(start, start + judgePagination.pageSize)
})
const judgePaginationComputed = computed(() => ({
current: judgePagination.current,
pageSize: judgePagination.pageSize,
total: judgeFiltered.value.length,
showSizeChanger: true,
showTotal: (total: number) => `${total}`,
}))
// 使 judgeId // 使 judgeId
const handleJudgeSelectionChange = (selectedKeys: number[]) => { const handleJudgeSelectionChange = (selectedKeys: number[]) => {
const newSelectedIds = selectedKeys.filter( const newSelectedIds = selectedKeys.filter(
(id) => !selectedJudgeKeys.value.includes(id) (id) => !selectedJudgeKeys.value.includes(id),
) )
const removedIds = selectedJudgeKeys.value.filter( const removedIds = selectedJudgeKeys.value.filter(
(id) => !selectedKeys.includes(id) (id) => !selectedKeys.includes(id),
) )
selectedJudgeKeys.value = selectedKeys selectedJudgeKeys.value = selectedKeys
selectedJudgeRows.value = selectedJudgeRows.value.filter( selectedJudgeRows.value = selectedJudgeRows.value.filter(
(judge) => !removedIds.includes(judge.judgeId) (judge) => !removedIds.includes(judge.judgeId),
) )
const newSelectedJudges = judgeList.value.filter((judge) => const newSelectedJudges = judgeFiltered.value.filter((judge) =>
newSelectedIds.includes(judge.judgeId) newSelectedIds.includes(judge.judgeId),
) )
selectedJudgeRows.value = [...selectedJudgeRows.value, ...newSelectedJudges] selectedJudgeRows.value = [...selectedJudgeRows.value, ...newSelectedJudges]
} }
@ -462,14 +528,18 @@ const fetchList = async () => {
} }
} }
// + // 便
const fetchJudgeList = async () => { const fetchJudgeList = async () => {
judgeListLoading.value = true judgeListLoading.value = true
try { try {
const response = await judgesApi.getList(contestId) const response = await judgesApi.getList(contestId)
const pool = flattenContestJudgePool(response) const assigned = Array.isArray(response.assigned) ? response.assigned : []
judgeList.value = pool let merged = assigned
judgePagination.total = pool.length if (!isBatchAssign.value && currentAssignWork.value) {
merged = mergeOrphanJudges(currentAssignWork.value, assigned)
}
judgeListSource.value = merged
judgePagination.current = 1
} catch (error) { } catch (error) {
message.error("获取评委列表失败") message.error("获取评委列表失败")
} finally { } finally {
@ -525,8 +595,8 @@ const handleAssignJudge = async (record: ContestWork) => {
// //
if (record.assignments && record.assignments.length > 0) { if (record.assignments && record.assignments.length > 0) {
const assignedJudgeUserIds = record.assignments.map((a) => a.judgeId) const assignedJudgeUserIds = record.assignments.map((a) => a.judgeId)
const matchedJudges = judgeList.value.filter((judge) => const matchedJudges = judgeListSource.value.filter((judge) =>
assignedJudgeUserIds.includes(judge.judgeId) assignedJudgeUserIds.includes(judge.judgeId),
) )
selectedJudgeKeys.value = matchedJudges.map((j) => j.judgeId) selectedJudgeKeys.value = matchedJudges.map((j) => j.judgeId)
selectedJudgeRows.value = matchedJudges selectedJudgeRows.value = matchedJudges
@ -539,6 +609,7 @@ const handleBatchAssign = () => {
message.warning("请先选择作品") message.warning("请先选择作品")
return return
} }
currentAssignWork.value = null
isBatchAssign.value = true isBatchAssign.value = true
selectedJudgeKeys.value = [] selectedJudgeKeys.value = []
selectedJudgeRows.value = [] selectedJudgeRows.value = []
@ -568,11 +639,10 @@ const handleResetJudgeSearch = () => {
fetchJudgeList() fetchJudgeList()
} }
// //
const handleJudgeTableChange = (pag: any) => { const handleJudgeTableChange = (pag: any) => {
judgePagination.current = pag.current judgePagination.current = pag.current
judgePagination.pageSize = pag.pageSize judgePagination.pageSize = pag.pageSize
fetchJudgeList()
} }
// //