feat: 公告附件持久化与 OSS 支持 svg、编辑时加载活动列表

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-15 11:43:35 +08:00
parent 079aed72e8
commit b8019ac4ee
12 changed files with 449 additions and 105 deletions

View File

@ -13,6 +13,8 @@ import com.lesingle.modules.biz.contest.dto.CreateNoticeDto;
import com.lesingle.modules.biz.contest.entity.BizContestNotice;
import com.lesingle.modules.biz.contest.service.IContestNoticeService;
import com.lesingle.security.annotation.RequirePermission;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@ -33,6 +35,7 @@ import java.util.Map;
public class ContestNoticeController {
private final IContestNoticeService noticeService;
private final ObjectMapper objectMapper;
/**
* 解析日期时间字符串兼容 ISO 格式带毫秒和 Z 时区标记
@ -92,6 +95,7 @@ public class ContestNoticeController {
.eq(BizContestNotice::getTenantId, tenantId)
.orderByDesc(BizContestNotice::getCreateTime));
noticeService.fillContestInfo(list);
noticeService.fillNoticeAttachments(list);
return Result.success(list);
}
@ -118,6 +122,7 @@ public class ContestNoticeController {
wrapper.orderByDesc(BizContestNotice::getCreateTime);
Page<BizContestNotice> result = noticeService.page(new Page<>(page, pageSize), wrapper);
noticeService.fillContestInfo(result.getRecords());
noticeService.fillNoticeAttachments(result.getRecords());
return Result.success(PageResult.from(result));
}
@ -128,6 +133,7 @@ public class ContestNoticeController {
BizContestNotice notice = noticeService.getById(id);
if (notice != null) {
noticeService.fillContestInfo(Collections.singletonList(notice));
noticeService.fillNoticeAttachments(Collections.singletonList(notice));
}
return Result.success(notice);
}
@ -149,40 +155,40 @@ public class ContestNoticeController {
uw.eq(BizContestNotice::getId, id);
uw.eq(BizContestNotice::getTenantId, tenantId);
boolean hasUpdate = false;
boolean hasFieldUpdate = false;
if (body.containsKey("title")) {
Object v = body.get("title");
if (v != null) {
uw.set(BizContestNotice::getTitle, String.valueOf(v));
hasUpdate = true;
hasFieldUpdate = true;
}
}
if (body.containsKey("content")) {
Object v = body.get("content");
if (v != null) {
uw.set(BizContestNotice::getContent, String.valueOf(v));
hasUpdate = true;
hasFieldUpdate = true;
}
}
if (body.containsKey("noticeType")) {
Object v = body.get("noticeType");
if (v != null) {
uw.set(BizContestNotice::getNoticeType, String.valueOf(v));
hasUpdate = true;
hasFieldUpdate = true;
}
}
if (body.containsKey("priority")) {
Object v = body.get("priority");
if (v instanceof Number) {
uw.set(BizContestNotice::getPriority, ((Number) v).intValue());
hasUpdate = true;
hasFieldUpdate = true;
}
}
if (body.containsKey("contestId")) {
Object v = body.get("contestId");
if (v instanceof Number) {
uw.set(BizContestNotice::getContestId, ((Number) v).longValue());
hasUpdate = true;
hasFieldUpdate = true;
}
}
// 仅当请求体包含 publishTime 键时才改发布时间null / 空串 = 取消发布必须写入 SQL NULL
@ -190,20 +196,36 @@ public class ContestNoticeController {
Object v = body.get("publishTime");
if (v == null || (v instanceof String && !StringUtils.hasText((String) v))) {
uw.set(BizContestNotice::getPublishTime, null);
hasUpdate = true;
hasFieldUpdate = true;
} else if (v instanceof String && StringUtils.hasText((String) v)) {
LocalDateTime pt = parseDateTime((String) v);
if (pt != null) {
uw.set(BizContestNotice::getPublishTime, pt);
hasUpdate = true;
hasFieldUpdate = true;
}
}
}
if (!hasUpdate) {
boolean attachmentSynced = false;
if (body.containsKey("attachments")) {
Object raw = body.get("attachments");
List<CreateNoticeDto.NoticeAttachmentItem> items;
if (raw == null) {
items = Collections.emptyList();
} else {
items = objectMapper.convertValue(raw, new TypeReference<List<CreateNoticeDto.NoticeAttachmentItem>>() {
});
}
noticeService.syncNoticeAttachments(id, tenantId, items);
attachmentSynced = true;
}
if (!hasFieldUpdate && !attachmentSynced) {
return Result.success();
}
if (hasFieldUpdate) {
noticeService.getBaseMapper().update(null, uw);
}
return Result.success();
}

View File

@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "创建公告DTO")
public class CreateNoticeDto {
@ -29,4 +31,30 @@ public class CreateNoticeDto {
@Schema(description = "发布时间")
private String publishTime;
/**
* 公告附件全量列表 PATCH 对齐 null 或省略表示创建时不写附件传空数组表示无附件
*/
@Schema(description = "公告附件列表id 为空表示新增,已有 id 表示保留/更新;全量覆盖")
private List<NoticeAttachmentItem> attachments;
@Data
@Schema(description = "公告附件项")
public static class NoticeAttachmentItem {
@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

@ -8,6 +8,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
/**
* 活动公告实体
@ -47,4 +48,9 @@ public class BizContestNotice extends BaseEntity {
@Schema(description = "关联活动")
@TableField(exist = false)
private BizContest contest;
/** 公告附件(仅查询接口填充,不落库) */
@Schema(description = "公告附件列表")
@TableField(exist = false)
private List<BizContestNoticeAttachment> attachments;
}

View File

@ -0,0 +1,44 @@
package com.lesingle.modules.biz.contest.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lesingle.common.entity.BaseEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 活动公告附件
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("t_biz_contest_notice_attachment")
@Schema(description = "活动公告附件实体")
public class BizContestNoticeAttachment extends BaseEntity {
@Schema(description = "公告ID")
@TableField("notice_id")
private Long noticeId;
@Schema(description = "租户ID")
@TableField("tenant_id")
private Long tenantId;
@Schema(description = "文件名称")
@TableField("file_name")
private String fileName;
@Schema(description = "文件URL")
@TableField("file_url")
private String fileUrl;
@Schema(description = "文件格式")
private String format;
@Schema(description = "文件类型")
@TableField("file_type")
private String fileType;
@Schema(description = "文件大小")
private String size;
}

View File

@ -0,0 +1,9 @@
package com.lesingle.modules.biz.contest.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lesingle.modules.biz.contest.entity.BizContestNoticeAttachment;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContestNoticeAttachmentMapper extends BaseMapper<BizContestNoticeAttachment> {
}

View File

@ -1,6 +1,7 @@
package com.lesingle.modules.biz.contest.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.lesingle.modules.biz.contest.dto.CreateNoticeDto;
import com.lesingle.modules.biz.contest.entity.BizContestNotice;
import java.util.List;
@ -11,4 +12,14 @@ public interface IContestNoticeService extends IService<BizContestNotice> {
* 批量填充关联活动名称仅设置 idcontestName供前端展示
*/
void fillContestInfo(List<BizContestNotice> notices);
/**
* 批量填充公告附件当前租户
*/
void fillNoticeAttachments(List<BizContestNotice> notices);
/**
* 全量同步公告附件列表中有 id 的保留并更新 id 的新增库中已有但未出现在列表中的删除
*/
void syncNoticeAttachments(Long noticeId, Long tenantId, List<CreateNoticeDto.NoticeAttachmentItem> items);
}

View File

@ -1,14 +1,25 @@
package com.lesingle.modules.biz.contest.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.lesingle.common.enums.ErrorCode;
import com.lesingle.common.exception.BusinessException;
import com.lesingle.common.util.SecurityUtil;
import com.lesingle.modules.biz.contest.dto.CreateNoticeDto;
import com.lesingle.modules.biz.contest.entity.BizContest;
import com.lesingle.modules.biz.contest.entity.BizContestNotice;
import com.lesingle.modules.biz.contest.entity.BizContestNoticeAttachment;
import com.lesingle.modules.biz.contest.mapper.ContestNoticeAttachmentMapper;
import com.lesingle.modules.biz.contest.mapper.ContestNoticeMapper;
import com.lesingle.modules.biz.contest.service.IContestNoticeService;
import com.lesingle.modules.biz.contest.service.IContestService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -21,6 +32,7 @@ import java.util.stream.Collectors;
public class ContestNoticeServiceImpl extends ServiceImpl<ContestNoticeMapper, BizContestNotice> implements IContestNoticeService {
private final IContestService contestService;
private final ContestNoticeAttachmentMapper noticeAttachmentMapper;
@Override
public void fillContestInfo(List<BizContestNotice> notices) {
@ -51,4 +63,117 @@ public class ContestNoticeServiceImpl extends ServiceImpl<ContestNoticeMapper, B
}
}
}
@Override
public void fillNoticeAttachments(List<BizContestNotice> notices) {
if (notices == null || notices.isEmpty()) {
return;
}
Set<Long> ids = notices.stream()
.map(BizContestNotice::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (ids.isEmpty()) {
return;
}
Long tenantId = SecurityUtil.getCurrentTenantId();
LambdaQueryWrapper<BizContestNoticeAttachment> qw = new LambdaQueryWrapper<>();
qw.in(BizContestNoticeAttachment::getNoticeId, ids);
qw.eq(BizContestNoticeAttachment::getTenantId, tenantId);
qw.orderByAsc(BizContestNoticeAttachment::getCreateTime);
List<BizContestNoticeAttachment> all = noticeAttachmentMapper.selectList(qw);
Map<Long, List<BizContestNoticeAttachment>> byNotice = all.stream()
.collect(Collectors.groupingBy(BizContestNoticeAttachment::getNoticeId));
for (BizContestNotice notice : notices) {
notice.setAttachments(byNotice.getOrDefault(notice.getId(), Collections.emptyList()));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void syncNoticeAttachments(Long noticeId, Long tenantId, List<CreateNoticeDto.NoticeAttachmentItem> items) {
if (items == null) {
return;
}
BizContestNotice n = getOne(
new LambdaQueryWrapper<BizContestNotice>()
.eq(BizContestNotice::getId, noticeId)
.eq(BizContestNotice::getTenantId, tenantId));
if (n == null) {
throw BusinessException.of(ErrorCode.NOT_FOUND, "公告不存在");
}
LambdaQueryWrapper<BizContestNoticeAttachment> qw = new LambdaQueryWrapper<>();
qw.eq(BizContestNoticeAttachment::getNoticeId, noticeId);
qw.eq(BizContestNoticeAttachment::getTenantId, tenantId);
List<BizContestNoticeAttachment> existing = noticeAttachmentMapper.selectList(qw);
Set<Long> keepIds = items.stream()
.map(CreateNoticeDto.NoticeAttachmentItem::getId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
for (BizContestNoticeAttachment row : existing) {
if (!keepIds.contains(row.getId())) {
noticeAttachmentMapper.deleteById(row.getId());
}
}
for (CreateNoticeDto.NoticeAttachmentItem item : items) {
if (!StringUtils.hasText(item.getFileUrl())) {
continue;
}
String fileName = StringUtils.hasText(item.getFileName()) ? item.getFileName().trim() : "附件";
if (item.getId() == null) {
BizContestNoticeAttachment na = new BizContestNoticeAttachment();
na.setNoticeId(noticeId);
na.setTenantId(tenantId);
na.setFileName(fileName);
na.setFileUrl(item.getFileUrl().trim());
na.setFormat(item.getFormat());
na.setFileType(item.getFileType());
na.setSize(item.getSize());
na.setValidState(1);
noticeAttachmentMapper.insert(na);
} else {
BizContestNoticeAttachment a = noticeAttachmentMapper.selectById(item.getId());
if (a == null || !noticeId.equals(a.getNoticeId()) || !tenantId.equals(a.getTenantId())) {
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());
}
noticeAttachmentMapper.updateById(a);
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean removeById(Serializable id) {
Long tenantId = SecurityUtil.getCurrentTenantId();
BizContestNotice n = getOne(
new LambdaQueryWrapper<BizContestNotice>()
.eq(BizContestNotice::getId, id)
.eq(BizContestNotice::getTenantId, tenantId));
if (n == null) {
return false;
}
LambdaQueryWrapper<BizContestNoticeAttachment> aw = new LambdaQueryWrapper<>();
aw.eq(BizContestNoticeAttachment::getNoticeId, id);
aw.eq(BizContestNoticeAttachment::getTenantId, tenantId);
List<BizContestNoticeAttachment> rows = noticeAttachmentMapper.selectList(aw);
for (BizContestNoticeAttachment row : rows) {
noticeAttachmentMapper.deleteById(row.getId());
}
return super.removeById(id);
}
}

View File

@ -35,7 +35,7 @@ public class OssConfig {
/** 允许的文件扩展名 */
private String[] allowedExtensions = {
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp",
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp4", ".mp3", ".wav", ".avi",
".zip", ".rar",

View File

@ -0,0 +1,22 @@
-- 公告附件表(与活动附件独立,按 notice_id 归属)
CREATE TABLE t_biz_contest_notice_attachment (
id BIGINT NOT NULL AUTO_INCREMENT,
notice_id BIGINT NOT NULL COMMENT '公告ID',
tenant_id BIGINT NOT NULL COMMENT '租户ID',
file_name VARCHAR(512) NOT NULL COMMENT '文件名称',
file_url VARCHAR(2048) NOT NULL COMMENT '文件URL',
format VARCHAR(32) DEFAULT NULL COMMENT '扩展名',
file_type VARCHAR(128) DEFAULT NULL COMMENT 'MIME 类型',
size VARCHAR(64) DEFAULT NULL COMMENT '文件大小',
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人账号',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人账号',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除0-未删除1-已删除',
creator INT DEFAULT NULL COMMENT '创建人ID',
modifier INT DEFAULT NULL COMMENT '修改人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
modify_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
valid_state TINYINT NOT NULL DEFAULT 1 COMMENT '有效状态1-有效2-失效',
PRIMARY KEY (id),
INDEX idx_notice_id (notice_id),
INDEX idx_tenant_notice (tenant_id, notice_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动公告附件表';

View File

@ -542,6 +542,18 @@ export interface CreateScoreForm {
}
// ==================== 公告相关类型 ====================
/** 公告附件(与后端 BizContestNoticeAttachment 对齐) */
export interface ContestNoticeAttachment {
id: number;
noticeId?: number;
fileName: string;
fileUrl: string;
format?: string;
fileType?: string;
size?: string;
createTime?: string;
}
export interface ContestNotice {
id: number;
contestId: number;
@ -556,6 +568,8 @@ export interface ContestNotice {
modifyTime?: string;
validState?: number;
contest?: Contest;
/** 公告附件(详情/列表接口填充) */
attachments?: ContestNoticeAttachment[];
}
export interface CreateNoticeForm {
@ -564,6 +578,8 @@ export interface CreateNoticeForm {
content: string;
noticeType?: "system" | "manual" | "urgent";
priority?: number;
/** 与后端 CreateNoticeDto.attachments 一致;创建时随 POST 全量提交 */
attachments?: ContestAttachmentInput[];
}
// ==================== 评委相关类型 ====================

View File

@ -125,8 +125,8 @@
<a-textarea v-model:value="formData.content" placeholder="请输入正文内容" :rows="8" />
</a-form-item>
<a-form-item label="活动附件">
<a-upload v-model:file-list="attachmentFileList" :before-upload="beforeUpload" :custom-request="handleUpload"
:on-remove="handleRemoveAttachment">
<a-upload v-model:file-list="attachmentFileList" :before-upload="beforeFileUpload"
:custom-request="handleAttachmentUpload" :max-count="10" @change="handleAttachmentListChange">
<a-button>
<template #icon>
<UploadOutlined />
@ -134,26 +134,8 @@
上传附件
</a-button>
</a-upload>
<div v-if="attachmentList.length > 0" style="margin-top: 16px">
<div v-for="attachment in attachmentList" :key="attachment.id" style="
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
margin-bottom: 8px;
">
<span>{{ attachment.fileName }}</span>
<a-space>
<a-button type="link" size="small" @click="handleDownload(attachment)">
下载
</a-button>
<a-button type="link" danger size="small" @click="handleDeleteAttachment(attachment.id)">
删除
</a-button>
</a-space>
</div>
<div style="margin-top: 8px; color: rgba(0, 0, 0, 0.45); font-size: 12px">
最多 10 个文件每个不超过 30MOSS 直传
</div>
</a-form-item>
</a-form>
@ -171,7 +153,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from "vue"
import { ref, onMounted, reactive, nextTick } from "vue"
import { useRoute } from "vue-router"
import { message, Modal } from "ant-design-vue"
import { PlusOutlined, UploadOutlined, SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
@ -180,12 +162,13 @@ import type { FormInstance, UploadFile } from "ant-design-vue"
import {
contestsApi,
noticesApi,
attachmentsApi,
type Contest,
type ContestAttachmentInput,
type ContestNotice,
type ContestNoticeAttachment,
type CreateNoticeForm,
type ContestAttachment,
} from "@/api/contests"
import { uploadFile } from "@/api/upload"
const route = useRoute()
const tenantCode = route.params.tenantCode as string
@ -225,9 +208,8 @@ const formData = reactive<CreateNoticeForm>({
const contestsList = ref<Contest[]>([])
const contestsLoading = ref(false)
//
// contests/Create.vue OSS contest/attachment
const attachmentFileList = ref<UploadFile[]>([])
const attachmentList = ref<ContestAttachment[]>([])
const formRules = {
title: [{ required: true, message: "请输入标题名称", trigger: "blur" }],
@ -291,9 +273,12 @@ const formatDateTime = (dateStr?: string) => {
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
}
//
// Vue3 + Select option.children VNode toLowerCase value contestsList
const filterContestOption = (input: string, option: any) => {
return option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
const id = option?.value ?? option?.option?.value
const contest = contestsList.value.find((c) => c.id === id)
const name = contest?.contestName ?? ""
return name.toLowerCase().includes((input ?? "").toLowerCase())
}
//
@ -351,23 +336,6 @@ const fetchNotices = async () => {
}
}
//
const fetchAttachments = async (noticeId: number) => {
try {
// TODO: API
// 使API
const notice = dataSource.value.find((n) => n.id === noticeId)
if (notice) {
const attachments = await attachmentsApi.getList(notice.contestId)
attachmentList.value = attachments.filter(
(a) => a.contestId === notice.contestId
)
}
} catch (error) {
console.error("获取附件列表失败", error)
}
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current
@ -403,7 +371,6 @@ const handleAdd = async () => {
formData.contestId = undefined;
formData.title = ""
formData.content = ""
attachmentList.value = []
attachmentFileList.value = []
//
@ -412,15 +379,67 @@ const handleAdd = async () => {
}
//
function attachmentToUploadFile(
att: ContestNoticeAttachment | 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,
}
}
/** 与后端 CreateNoticeDto.attachments / PATCH 全量同步对齐 */
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 handleEdit = async (record: ContestNotice) => {
isEditing.value = true
editingId.value = record.id
formData.contestId = record.contestId
formData.title = record.title
formData.content = record.content
attachmentList.value = []
attachmentFileList.value = []
await fetchAttachments(record.id)
// contestsList
await fetchContests()
try {
const detail = await noticesApi.getDetail(record.id)
const list = detail.attachments ?? []
attachmentFileList.value = list.map((a, i) =>
attachmentToUploadFile(a as ContestNoticeAttachment, i),
)
} catch {
/* 详情失败时仍打开抽屉,附件为空 */
}
drawerVisible.value = true
}
@ -462,55 +481,94 @@ const handleTogglePublish = (record: ContestNotice) => {
})
}
//
const beforeUpload = (_file: File) => {
//
return true
}
//
const handleUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
// TODO:
//
const attachment: ContestAttachment = {
id: Date.now(),
contestId: formData.contestId,
fileName: file.name,
fileUrl: URL.createObjectURL(file),
size: String(file.size),
}
attachmentList.value.push(attachment)
onSuccess()
message.success("上传成功")
} catch (error) {
onError(error)
message.error("上传失败")
}
}
//
const handleRemoveAttachment = (file: UploadFile) => {
//
attachmentList.value = attachmentList.value.filter(
(a) => a.fileName !== file.name
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()
)
}
//
const handleDownload = (attachment: ContestAttachment) => {
window.open(attachment.fileUrl, "_blank")
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()
: "") ||
"附件"
)
}
//
const handleDeleteAttachment = async (id: number) => {
/** 从 response 补全 Upload 条目上的 url/name */
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 beforeFileUpload = (file: File) => {
if (file.size / 1024 / 1024 >= 30) {
message.error("文件大小不能超过30M")
return false
}
return true
}
const handleAttachmentUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
// TODO:
attachmentList.value = attachmentList.value.filter((a) => a.id !== id)
message.success("删除成功")
} catch (error: any) {
message.error(error?.response?.data?.message || "删除失败")
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,
}
}
normalizeAttachmentFileList()
message.success("附件上传成功")
} catch (e: any) {
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 || "附件上传失败")
}
}
@ -521,18 +579,21 @@ const handleSubmit = async () => {
await formRef.value?.validate()
submitLoading.value = true
const attachments = buildAttachmentsPayload()
if (isEditing.value && editingId.value) {
await noticesApi.update(editingId.value, {
contestId: formData.contestId,
title: formData.title,
content: formData.content,
attachments,
})
message.success("更新成功")
} else {
await noticesApi.create({
contestId: formData.contestId,
contestId: formData.contestId!,
title: formData.title,
content: formData.content,
attachments,
})
message.success("创建成功")
}

View File

@ -57,7 +57,7 @@ public class OssConfig {
private String[] allowedExtensions = new String[]{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp4", ".avi", ".mov", ".wmv",
".mp4", ".avi", ".mov", ".wmv", ".svg",
".mp3", ".wav",
".txt"
};