feat: 活动创建/编辑附件与 PATCH 主体同步及表单对齐

- 后端 CreateContestDto 增加 attachments,创建/更新时全量同步附件表
- 前端 Create:form.attachments、URL/文件名解析、上传归一化、编辑省略空 attachments 防误删
- API 增加 ContestAttachmentInput、CreateContestForm.reviewRuleId/attachments

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-14 19:13:50 +08:00
parent ba93872922
commit e484fa3965
4 changed files with 262 additions and 30 deletions

View File

@ -127,4 +127,30 @@ public class CreateContestDto {
@Schema(description = "结果发布时间") @Schema(description = "结果发布时间")
private String resultPublishTime; private String resultPublishTime;
/**
* 活动附件全量列表 PATCH/POST 对齐 null 时不修改原有附件 null含空列表则按列表覆盖同步
*/
@Schema(description = "活动附件列表id 为空表示新增,已有 id 表示保留/更新;列表中不出现的已存附件将被删除")
private List<AttachmentItem> attachments;
@Data
@Schema(description = "活动附件项")
public static class AttachmentItem {
@Schema(description = "附件主键,新建不传")
private Long id;
@Schema(description = "文件名")
private String fileName;
@Schema(description = "文件访问 URL")
private String fileUrl;
private String format;
private String fileType;
private String size;
}
} }

View File

@ -103,6 +103,9 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
save(entity); save(entity);
log.info("活动创建成功ID{}, 名称:{}", entity.getId(), entity.getContestName()); log.info("活动创建成功ID{}, 名称:{}", entity.getId(), entity.getContestName());
if (dto.getAttachments() != null) {
syncContestAttachments(entity.getId(), dto.getAttachments());
}
return entity; return entity;
} catch (Exception e) { } catch (Exception e) {
log.error("创建活动失败,名称:{}", dto.getContestName(), e); log.error("创建活动失败,名称:{}", dto.getContestName(), e);
@ -340,6 +343,9 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
mapDtoToEntity(dto, entity); mapDtoToEntity(dto, entity);
updateById(entity); updateById(entity);
if (dto.getAttachments() != null) {
syncContestAttachments(id, dto.getAttachments());
}
log.info("活动更新成功ID{}", id); log.info("活动更新成功ID{}", id);
return entity; return entity;
@ -607,6 +613,65 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
// ====== 私有辅助方法 ====== // ====== 私有辅助方法 ======
/**
* 按请求体全量同步活动附件列表中有 id 的保留并更新 URL/名称 id 的新增库中已有但未出现在列表中的删除
*/
private void syncContestAttachments(Long contestId, List<CreateContestDto.AttachmentItem> items) {
if (items == null) {
return;
}
LambdaQueryWrapper<BizContestAttachment> qw = new LambdaQueryWrapper<>();
qw.eq(BizContestAttachment::getContestId, contestId);
List<BizContestAttachment> existing = contestAttachmentMapper.selectList(qw);
Set<Long> keepIds = items.stream()
.map(CreateContestDto.AttachmentItem::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
for (BizContestAttachment row : existing) {
if (!keepIds.contains(row.getId())) {
contestAttachmentMapper.deleteById(row.getId());
}
}
for (CreateContestDto.AttachmentItem item : items) {
if (!StringUtils.hasText(item.getFileUrl())) {
continue;
}
String fileName = StringUtils.hasText(item.getFileName()) ? item.getFileName().trim() : "附件";
if (item.getId() == null) {
BizContestAttachment na = new BizContestAttachment();
na.setContestId(contestId);
na.setFileName(fileName);
na.setFileUrl(item.getFileUrl().trim());
na.setFormat(item.getFormat());
na.setFileType(item.getFileType());
na.setSize(item.getSize());
na.setValidState(1);
contestAttachmentMapper.insert(na);
} else {
BizContestAttachment a = contestAttachmentMapper.selectById(item.getId());
if (a == null || !contestId.equals(a.getContestId())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "附件不存在或不属于该活动:" + item.getId());
}
a.setFileName(fileName);
a.setFileUrl(item.getFileUrl().trim());
if (item.getFormat() != null) {
a.setFormat(item.getFormat());
}
if (item.getFileType() != null) {
a.setFileType(item.getFileType());
}
if (item.getSize() != null) {
a.setSize(item.getSize());
}
contestAttachmentMapper.updateById(a);
}
}
}
private void mapDtoToEntity(CreateContestDto dto, BizContest entity) { private void mapDtoToEntity(CreateContestDto dto, BizContest entity) {
if (StringUtils.hasText(dto.getContestName())) { if (StringUtils.hasText(dto.getContestName())) {
entity.setContestName(dto.getContestName()); entity.setContestName(dto.getContestName());

View File

@ -108,9 +108,22 @@ export interface CreateContestForm {
workType?: "image" | "video" | "document" | "code" | "other"; workType?: "image" | "video" | "document" | "code" | "other";
workRequirement?: string; workRequirement?: string;
// 评审配置 // 评审配置
reviewRuleId?: number;
reviewStartTime: string; reviewStartTime: string;
reviewEndTime: string; reviewEndTime: string;
resultPublishTime?: string; resultPublishTime?: string;
/** 活动附件全量;与后端 PATCH/POST 对齐,传 null/省略表示不改附件,传数组(含 [])则按列表同步 */
attachments?: ContestAttachmentInput[];
}
/** 创建/更新活动时随主体提交的附件项(与后端 CreateContestDto.AttachmentItem 一致) */
export interface ContestAttachmentInput {
id?: number;
fileName: string;
fileUrl: string;
format?: string;
fileType?: string;
size?: string;
} }
export interface UpdateContestForm extends Partial<CreateContestForm> { export interface UpdateContestForm extends Partial<CreateContestForm> {

View File

@ -135,7 +135,7 @@
<a-col :span="24"> <a-col :span="24">
<a-form-item label="活动附件" name="attachments"> <a-form-item label="活动附件" name="attachments">
<a-upload v-model:file-list="attachmentFileList" :before-upload="beforeFileUpload" <a-upload v-model:file-list="attachmentFileList" :before-upload="beforeFileUpload"
:custom-request="handleAttachmentUpload" :max-count="10"> :custom-request="handleAttachmentUpload" :max-count="10" @change="handleAttachmentListChange">
<a-button><upload-outlined /> 上传附件</a-button> <a-button><upload-outlined /> 上传附件</a-button>
</a-upload> </a-upload>
<div class="form-hint">最多 10 个文件每个不超过 30M</div> <div class="form-hint">最多 10 个文件每个不超过 30M</div>
@ -209,7 +209,7 @@ import type { Dayjs } from "dayjs"
import dayjs from "dayjs" import dayjs from "dayjs"
import { PlusOutlined, UploadOutlined, ArrowLeftOutlined } from "@ant-design/icons-vue" import { PlusOutlined, UploadOutlined, ArrowLeftOutlined } from "@ant-design/icons-vue"
import RichTextEditor from "@/components/RichTextEditor.vue" import RichTextEditor from "@/components/RichTextEditor.vue"
import { contestsApi, attachmentsApi, reviewRulesApi, type CreateContestForm, type Contest } from "@/api/contests" import { contestsApi, attachmentsApi, reviewRulesApi, type CreateContestForm, type Contest, type ContestAttachment, type ContestAttachmentInput } from "@/api/contests"
import { uploadFile } from "@/api/upload" import { uploadFile } from "@/api/upload"
const router = useRouter() const router = useRouter()
@ -222,7 +222,7 @@ const pageLoading = ref(false)
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const submitLoading = ref(false) const submitLoading = ref(false)
const form = reactive<CreateContestForm & { reviewRuleId?: number }>({ const form = reactive<CreateContestForm>({
contestName: "", contestType: "individual", visibility: "public", contestName: "", contestType: "individual", visibility: "public",
targetCities: [] as string[], ageMin: undefined as number | undefined, ageMax: undefined as number | undefined, targetCities: [] as string[], ageMin: undefined as number | undefined, ageMax: undefined as number | undefined,
startTime: "", endTime: "", content: "", coverUrl: "", posterUrl: "", startTime: "", endTime: "", content: "", coverUrl: "", posterUrl: "",
@ -230,7 +230,10 @@ const form = reactive<CreateContestForm & { reviewRuleId?: number }>({
registerStartTime: "", registerEndTime: "", registerStartTime: "", registerEndTime: "",
requireAudit: false, requireAudit: false,
submitRule: "once", submitStartTime: "", submitEndTime: "", submitRule: "once", submitStartTime: "", submitEndTime: "",
reviewRuleId: undefined,
reviewStartTime: "", reviewEndTime: "", resultPublishTime: "", reviewStartTime: "", reviewEndTime: "", resultPublishTime: "",
/** 与 name=\"attachments\" 对应;提交前由 buildAttachmentsPayload 写入 */
attachments: undefined as ContestAttachmentInput[] | undefined,
}) })
const timeRange = ref<[Dayjs, Dayjs] | null>(null) const timeRange = ref<[Dayjs, Dayjs] | null>(null)
@ -242,8 +245,72 @@ const resultPublishTime = ref<Dayjs | null>(null)
const coverFileList = ref<UploadFile[]>([]) const coverFileList = ref<UploadFile[]>([])
const posterFileList = ref<UploadFile[]>([]) const posterFileList = ref<UploadFile[]>([])
const attachmentFileList = ref<UploadFile[]>([]) const attachmentFileList = ref<UploadFile[]>([])
/** 编辑进入页时从服务端加载到的附件条数,用于提交时是否携带 attachments 字段 */
const initialAttachmentCountAtLoad = ref(0)
const reviewRuleOptions = ref<{ value: number; label: string }[]>([]) const reviewRuleOptions = ref<{ value: number; label: string }[]>([])
function getAttachmentFileUrl(f: UploadFile): string {
const r = f.response as { url?: string; data?: { url?: string } } | undefined
return (
String(f.url || "").trim() ||
String(f.thumbUrl || "").trim() ||
String(r?.url || "").trim() ||
String(r?.data?.url || "").trim()
)
}
function getAttachmentFileName(f: UploadFile): string {
const r = f.response as { filename?: string; originalname?: string } | undefined
return (
String(f.name || "").trim() ||
String(r?.filename || "").trim() ||
String(r?.originalname || "").trim() ||
(f.originFileObj && "name" in f.originFileObj
? String((f.originFileObj as File).name || "").trim()
: "") ||
"附件"
)
}
/** 兼容后端 camelCase / snake_case 及详情嵌套与独立列表接口 */
function attachmentToUploadFile(att: ContestAttachment | Record<string, unknown>, index: number): UploadFile {
const raw = att as Record<string, unknown>
const id = Number((raw.id as number | string | undefined) ?? 0)
const fileName = String(raw.fileName ?? raw.file_name ?? "附件")
const fileUrl = String(raw.fileUrl ?? raw.file_url ?? "")
return {
uid: id > 0 ? `exist-${id}` : `tmp-${index}-${fileName}`,
name: fileName,
status: "done",
url: fileUrl,
size: raw.size != null ? Number(raw.size) : undefined,
response: id > 0 ? { id } : undefined,
}
}
/** 与后端 PATCH/POST 的 attachments 字段对齐,随活动主体一次提交 */
function buildAttachmentsPayload(): ContestAttachmentInput[] {
const out: ContestAttachmentInput[] = []
for (const f of attachmentFileList.value) {
if (f.status !== "done") continue
const fileUrl = getAttachmentFileUrl(f)
const fileName = getAttachmentFileName(f)
if (!fileUrl) continue
const rid = (f.response as { id?: number } | undefined)?.id
const item: ContestAttachmentInput = {
fileName,
fileUrl,
format: fileName.includes(".") ? fileName.split(".").pop()?.toLowerCase() : undefined,
size: f.size != null ? String(f.size) : undefined,
}
if (typeof rid === "number" && rid > 0) {
item.id = rid
}
out.push(item)
}
return out
}
const fetchReviewRules = async () => { const fetchReviewRules = async () => {
try { try {
const rules = await reviewRulesApi.getForSelect() const rules = await reviewRulesApi.getForSelect()
@ -324,25 +391,63 @@ const handlePosterUpload = async (options: any) => {
} catch (e: any) { onError(e); message.error(e?.response?.data?.message || "海报上传失败") } } catch (e: any) { onError(e); message.error(e?.response?.data?.message || "海报上传失败") }
} }
/** 从 response 补全 Upload 条目上的 url/name避免仅存在 response 时无法写入后端 */
function normalizeAttachmentFileList() {
attachmentFileList.value = attachmentFileList.value.map((item) => {
if (item.status !== "done") return item
const url = getAttachmentFileUrl(item)
const name = getAttachmentFileName(item)
const r = item.response as Record<string, unknown> | undefined
const mergedResp =
r && url
? { ...r, url: (r.url as string) || url }
: url
? { url, ...(typeof r === "object" && r ? r : {}) }
: r
return {
...item,
url: item.url || url,
name: item.name || name,
response: mergedResp,
}
})
}
const handleAttachmentListChange = () => {
nextTick(() => normalizeAttachmentFileList())
}
const handleAttachmentUpload = async (options: any) => { const handleAttachmentUpload = async (options: any) => {
const { file, onSuccess, onError } = options const { file, onSuccess, onError } = options
try { try {
const result: any = await uploadFile(file, "contest/attachment") const result = await uploadFile(file as File, "contest/attachment")
const url = result.data?.url || result.url const url = result.url
if (url) { if (!url) throw new Error("无法获取文件地址")
await nextTick() const mergedResponse = { ...result, url }
const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name) onSuccess(mergedResponse, file)
if (idx !== -1) { await nextTick()
attachmentFileList.value[idx] = { ...attachmentFileList.value[idx], url, response: result, status: "done" } const idx = attachmentFileList.value.findIndex(
} else { (f) => f.uid === (file as UploadFile).uid,
attachmentFileList.value.push({ uid: file.uid, name: file.name, status: "done", url, response: result }) )
if (idx !== -1) {
const prev = attachmentFileList.value[idx]
attachmentFileList.value[idx] = {
...prev,
name: prev.name || (file as UploadFile).name || result.filename,
url,
status: "done",
response: mergedResponse,
} }
onSuccess(); message.success("附件上传成功") }
} else throw new Error("无法获取文件地址") normalizeAttachmentFileList()
message.success("附件上传成功")
} catch (e: any) { } catch (e: any) {
const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name) const idx = attachmentFileList.value.findIndex(
(f) => f.uid === (file as UploadFile).uid,
)
if (idx !== -1) attachmentFileList.value[idx].status = "error" if (idx !== -1) attachmentFileList.value[idx].status = "error"
onError(e); message.error(e?.response?.data?.message || "附件上传失败") onError(e)
message.error(e?.response?.data?.message || "附件上传失败")
} }
} }
@ -392,6 +497,7 @@ const disabledPublishDate = (current: Dayjs | null) => {
const loadContestData = async () => { const loadContestData = async () => {
if (!contestId.value) return if (!contestId.value) return
pageLoading.value = true pageLoading.value = true
initialAttachmentCountAtLoad.value = 0
try { try {
const c = await contestsApi.getDetail(contestId.value) const c = await contestsApi.getDetail(contestId.value)
form.contestName = c.contestName || "" form.contestName = c.contestName || ""
@ -427,9 +533,22 @@ const loadContestData = async () => {
if (c.coverUrl) coverFileList.value = [{ uid: "-1", name: "cover", status: "done", url: c.coverUrl }] if (c.coverUrl) coverFileList.value = [{ uid: "-1", name: "cover", status: "done", url: c.coverUrl }]
if (c.posterUrl) posterFileList.value = [{ uid: "-2", name: "poster", status: "done", url: c.posterUrl }] if (c.posterUrl) posterFileList.value = [{ uid: "-2", name: "poster", status: "done", url: c.posterUrl }]
if (c.attachments?.length) {
attachmentFileList.value = c.attachments.map((att: any, i: number) => ({ uid: `-${i + 3}`, name: att.fileName, status: "done", url: att.fileUrl })) attachmentFileList.value = []
let list: ContestAttachment[] = []
try {
list = (await attachmentsApi.getList(contestId.value)) || []
} catch {
list = []
} }
if (!list.length && (c as Contest).attachments?.length) {
list = (c as Contest).attachments as ContestAttachment[]
}
if (list.length) {
attachmentFileList.value = list.map((att, i) => attachmentToUploadFile(att, i))
}
initialAttachmentCountAtLoad.value = list.length
form.attachments = buildAttachmentsPayload()
} catch (e: any) { message.error(e?.response?.data?.message || "加载活动数据失败"); router.back() } } catch (e: any) { message.error(e?.response?.data?.message || "加载活动数据失败"); router.back() }
finally { pageLoading.value = false } finally { pageLoading.value = false }
} }
@ -440,6 +559,23 @@ const handleSubmit = async () => {
await formRef.value?.validate() await formRef.value?.validate()
submitLoading.value = true submitLoading.value = true
const payload = buildAttachmentsPayload()
form.attachments = payload
const doneFiles = attachmentFileList.value.filter((f) => f.status === "done")
if (doneFiles.length > 0 && payload.length === 0) {
message.error("附件已添加但无法解析文件地址,请删除对应项后重新上传")
submitLoading.value = false
return
}
/** 编辑且从未有过附件、当前仍为空:省略 attachments后端 null 不覆盖附件表 */
const omitAttachments =
isEdit.value &&
payload.length === 0 &&
attachmentFileList.value.length === 0 &&
initialAttachmentCountAtLoad.value === 0
const submitData: CreateContestForm = { const submitData: CreateContestForm = {
contestName: form.contestName, contestType: form.contestType, startTime: form.startTime, endTime: form.endTime, contestName: form.contestName, contestType: form.contestType, startTime: form.startTime, endTime: form.endTime,
content: form.content, coverUrl: form.coverUrl, posterUrl: form.posterUrl, content: form.content, coverUrl: form.coverUrl, posterUrl: form.posterUrl,
@ -455,23 +591,15 @@ const handleSubmit = async () => {
ageMin: form.ageMin, ageMin: form.ageMin,
ageMax: form.ageMax, ageMax: form.ageMax,
} }
if (!omitAttachments) {
submitData.attachments = payload
}
if (isEdit.value && contestId.value) { if (isEdit.value && contestId.value) {
await contestsApi.update(contestId.value, submitData) await contestsApi.update(contestId.value, submitData)
message.success("保存成功") message.success("保存成功")
} else { } else {
const contest = await contestsApi.create(submitData) await contestsApi.create(submitData)
if (attachmentFileList.value.length > 0) {
try {
await Promise.all(attachmentFileList.value.map(file => {
const fileUrl = file.url || file.response?.url || file.response?.data?.url
if (fileUrl && file.name) {
return attachmentsApi.create({ contestId: contest.id, fileName: file.name, fileUrl, format: file.name.split(".").pop()?.toLowerCase(), size: file.size?.toString() })
}
return Promise.resolve()
}))
} catch { message.warning("活动创建成功,但部分附件记录创建失败") }
}
message.success("创建成功") message.success("创建成功")
} }
router.push(`/${tenantCode}/contests/list`) router.push(`/${tenantCode}/contests/list`)