From e484fa3965cff0fa5abbea8e9ce5e7995e1fec98 Mon Sep 17 00:00:00 2001 From: zhonghua Date: Tue, 14 Apr 2026 19:13:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B4=BB=E5=8A=A8=E5=88=9B=E5=BB=BA/?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=99=84=E4=BB=B6=E4=B8=8E=20PATCH=20?= =?UTF-8?q?=E4=B8=BB=E4=BD=93=E5=90=8C=E6=AD=A5=E5=8F=8A=E8=A1=A8=E5=8D=95?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 CreateContestDto 增加 attachments,创建/更新时全量同步附件表 - 前端 Create:form.attachments、URL/文件名解析、上传归一化、编辑省略空 attachments 防误删 - API 增加 ContestAttachmentInput、CreateContestForm.reviewRuleId/attachments Made-with: Cursor --- .../biz/contest/dto/CreateContestDto.java | 26 +++ .../service/impl/ContestServiceImpl.java | 65 ++++++ .../src/api/contests.ts | 13 ++ .../src/views/contests/Create.vue | 188 +++++++++++++++--- 4 files changed, 262 insertions(+), 30 deletions(-) diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateContestDto.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateContestDto.java index 21a73f2..0c8ea9e 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateContestDto.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/dto/CreateContestDto.java @@ -127,4 +127,30 @@ public class CreateContestDto { @Schema(description = "结果发布时间") private String resultPublishTime; + + /** + * 活动附件全量列表(与 PATCH/POST 对齐)。为 null 时不修改原有附件;非 null(含空列表)则按列表覆盖同步。 + */ + @Schema(description = "活动附件列表:id 为空表示新增,已有 id 表示保留/更新;列表中不出现的已存附件将被删除") + private List 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; + } } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestServiceImpl.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestServiceImpl.java index 2a445ed..de81aad 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestServiceImpl.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/biz/contest/service/impl/ContestServiceImpl.java @@ -103,6 +103,9 @@ public class ContestServiceImpl extends ServiceImpl i save(entity); log.info("活动创建成功,ID:{}, 名称:{}", entity.getId(), entity.getContestName()); + if (dto.getAttachments() != null) { + syncContestAttachments(entity.getId(), dto.getAttachments()); + } return entity; } catch (Exception e) { log.error("创建活动失败,名称:{}", dto.getContestName(), e); @@ -340,6 +343,9 @@ public class ContestServiceImpl extends ServiceImpl i mapDtoToEntity(dto, entity); updateById(entity); + if (dto.getAttachments() != null) { + syncContestAttachments(id, dto.getAttachments()); + } log.info("活动更新成功,ID:{}", id); return entity; @@ -607,6 +613,65 @@ public class ContestServiceImpl extends ServiceImpl i // ====== 私有辅助方法 ====== + /** + * 按请求体全量同步活动附件:列表中有 id 的保留并更新 URL/名称;无 id 的新增;库中已有但未出现在列表中的删除。 + */ + private void syncContestAttachments(Long contestId, List items) { + if (items == null) { + return; + } + + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.eq(BizContestAttachment::getContestId, contestId); + List existing = contestAttachmentMapper.selectList(qw); + Set 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) { if (StringUtils.hasText(dto.getContestName())) { entity.setContestName(dto.getContestName()); diff --git a/lesingle-creation-frontend/src/api/contests.ts b/lesingle-creation-frontend/src/api/contests.ts index 5701d57..41a6046 100644 --- a/lesingle-creation-frontend/src/api/contests.ts +++ b/lesingle-creation-frontend/src/api/contests.ts @@ -108,9 +108,22 @@ export interface CreateContestForm { workType?: "image" | "video" | "document" | "code" | "other"; workRequirement?: string; // 评审配置 + reviewRuleId?: number; reviewStartTime: string; reviewEndTime: 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 { diff --git a/lesingle-creation-frontend/src/views/contests/Create.vue b/lesingle-creation-frontend/src/views/contests/Create.vue index 3445c1b..b373a17 100644 --- a/lesingle-creation-frontend/src/views/contests/Create.vue +++ b/lesingle-creation-frontend/src/views/contests/Create.vue @@ -135,7 +135,7 @@ + :custom-request="handleAttachmentUpload" :max-count="10" @change="handleAttachmentListChange"> 上传附件
最多 10 个文件,每个不超过 30M
@@ -209,7 +209,7 @@ import type { Dayjs } from "dayjs" import dayjs from "dayjs" import { PlusOutlined, UploadOutlined, ArrowLeftOutlined } from "@ant-design/icons-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" const router = useRouter() @@ -222,7 +222,7 @@ const pageLoading = ref(false) const formRef = ref() const submitLoading = ref(false) -const form = reactive({ +const form = reactive({ contestName: "", contestType: "individual", visibility: "public", targetCities: [] as string[], ageMin: undefined as number | undefined, ageMax: undefined as number | undefined, startTime: "", endTime: "", content: "", coverUrl: "", posterUrl: "", @@ -230,7 +230,10 @@ const form = reactive({ registerStartTime: "", registerEndTime: "", requireAudit: false, submitRule: "once", submitStartTime: "", submitEndTime: "", + reviewRuleId: undefined, reviewStartTime: "", reviewEndTime: "", resultPublishTime: "", + /** 与 name=\"attachments\" 对应;提交前由 buildAttachmentsPayload 写入 */ + attachments: undefined as ContestAttachmentInput[] | undefined, }) const timeRange = ref<[Dayjs, Dayjs] | null>(null) @@ -242,8 +245,72 @@ const resultPublishTime = ref(null) const coverFileList = ref([]) const posterFileList = ref([]) const attachmentFileList = ref([]) +/** 编辑进入页时从服务端加载到的附件条数,用于提交时是否携带 attachments 字段 */ +const initialAttachmentCountAtLoad = ref(0) 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, index: number): UploadFile { + const raw = att as Record + 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 () => { try { 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 || "海报上传失败") } } +/** 从 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 | 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 { file, onSuccess, onError } = options try { - const result: any = await uploadFile(file, "contest/attachment") - const url = result.data?.url || result.url - if (url) { - await nextTick() - const idx = attachmentFileList.value.findIndex(f => f.uid === file.uid || f.name === file.name) - if (idx !== -1) { - attachmentFileList.value[idx] = { ...attachmentFileList.value[idx], url, response: result, status: "done" } - } else { - attachmentFileList.value.push({ uid: file.uid, name: file.name, status: "done", url, response: result }) + const result = await uploadFile(file as File, "contest/attachment") + const url = result.url + if (!url) throw new Error("无法获取文件地址") + const mergedResponse = { ...result, url } + onSuccess(mergedResponse, file) + await nextTick() + const idx = attachmentFileList.value.findIndex( + (f) => f.uid === (file as UploadFile).uid, + ) + 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) { - 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" - 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 () => { if (!contestId.value) return pageLoading.value = true + initialAttachmentCountAtLoad.value = 0 try { const c = await contestsApi.getDetail(contestId.value) 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.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() } finally { pageLoading.value = false } } @@ -440,6 +559,23 @@ const handleSubmit = async () => { await formRef.value?.validate() 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 = { contestName: form.contestName, contestType: form.contestType, startTime: form.startTime, endTime: form.endTime, content: form.content, coverUrl: form.coverUrl, posterUrl: form.posterUrl, @@ -455,23 +591,15 @@ const handleSubmit = async () => { ageMin: form.ageMin, ageMax: form.ageMax, } + if (!omitAttachments) { + submitData.attachments = payload + } if (isEdit.value && contestId.value) { await contestsApi.update(contestId.value, submitData) message.success("保存成功") } else { - const contest = 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("活动创建成功,但部分附件记录创建失败") } - } + await contestsApi.create(submitData) message.success("创建成功") } router.push(`/${tenantCode}/contests/list`)