diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java index e65ad0d..6b1c946 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/controller/MaterialFileController.java @@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +20,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -66,8 +69,30 @@ public class MaterialFileController { + "压到材料项配的体积上限内(默认 1024KB),并按命名模板生成规范文件名") public ApiResult> process(@PathVariable Long id) { CollectFile f = fileService.process(id); + return ApiResult.ok(toVo(f)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "删除材料(软删)", + description = "学生传错了要能自己删 —— 否则他只能传一份错的来交,或者去找班主任") + public ApiResult delete(@PathVariable Long id) { + fileService.delete(id); + return ApiResult.ok(); + } + + @GetMapping("/submissions/{submissionId}") + @Operation(summary = "列出某份填报单下的材料", + description = "按材料码分组。前端据此显示「已传了什么、压到多大、叫什么名字」") + public ApiResult>> list(@PathVariable Long submissionId) { + return ApiResult.ok(fileService.listMine(submissionId).stream() + .map(MaterialFileController::toVo).toList()); + } + + private static Map toVo(CollectFile f) { Map out = new LinkedHashMap<>(); out.put("id", f.getId()); + out.put("materialCode", f.getMaterialCode()); + out.put("version", f.getVersion()); out.put("originalName", f.getOriginalName()); out.put("standardName", f.getStandardName()); out.put("rawSize", f.getRawSize()); @@ -75,6 +100,7 @@ public class MaterialFileController { out.put("width", f.getWidth()); out.put("height", f.getHeight()); out.put("quality", f.getQuality()); - return ApiResult.ok(out); + out.put("validateStatus", f.getValidateStatus()); + return out; } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java index c377aeb..5f891e0 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/mapper/FileMapper.java @@ -35,4 +35,23 @@ public interface FileMapper extends BaseMapper { Long countCurrent(@Param("tenantId") Long tenantId, @Param("submissionId") Long submissionId, @Param("materialCode") String materialCode); + + /** + * 列出某份填报单下当前生效的文件。 + * + *

归属条件({@code submitter_id})写进 WHERE —— 与门禁、草稿同一套写法: + * 少一个条件就是"能列出别人传的材料"。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT f.* FROM collect_file f + JOIN collect_submission s ON s.id = f.submission_id AND s.deleted = 0 + WHERE f.tenant_id = #{tenantId} AND f.submission_id = #{submissionId} + AND f.is_current = 1 AND f.deleted = 0 + AND s.submitter_id = #{submitterId} + ORDER BY f.material_code, f.version + """) + java.util.List listMine(@Param("tenantId") Long tenantId, + @Param("submissionId") Long submissionId, + @Param("submitterId") Long submitterId); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java index 4afe506..3b50dea 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/file/service/MaterialFileService.java @@ -193,6 +193,41 @@ public class MaterialFileService { return f; } + // ── 列表与删除 ──────────────────────────────── + + public List listMine(Long submissionId) { + LoginUser me = UserContext.require(); + CollectSubmission sub = submissionMapper.selectMineById(submissionId, me.tenantId(), me.uid()); + if (sub == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你"); + } + return fileMapper.listMine(me.tenantId(), submissionId, me.uid()); + } + + /** + * 删除材料(软删)。 + * + *

学生传错了要能自己删 —— 否则他只能传一份错的来交,或者去找班主任。 + * 已提交的填报单不能删(要改先撤回),与草稿保存同一口径。 + */ + @Transactional(rollbackFor = Exception.class) + public void delete(Long fileId) { + LoginUser me = UserContext.require(); + CollectFile f = fileMapper.selectMine(fileId, me.tenantId(), me.uid()); + if (f == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或不属于你"); + } + CollectSubmission sub = submissionMapper.selectMineById( + f.getSubmissionId(), me.tenantId(), me.uid()); + if (sub != null && sub.getStatus() != null + && sub.getStatus() != CollectSubmission.STATUS_DRAFT) { + throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, "已提交的填报单不能删除材料"); + } + // 软删:磁盘上的原始件保留(万一删错了、或事后要核对) + fileMapper.deleteById(fileId); + log.info("删除材料:fileId={} {}", fileId, f.getOriginalName()); + } + // ── 内部 ────────────────────────────────────── /** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */ diff --git a/frontend-mp/src/pages/fill/fill.vue b/frontend-mp/src/pages/fill/fill.vue index 61985c0..9a0dff1 100644 --- a/frontend-mp/src/pages/fill/fill.vue +++ b/frontend-mp/src/pages/fill/fill.vue @@ -65,6 +65,47 @@ + + + 需要上传的材料 + + 选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理 + + + + + {{ m.materialName }} + 必传 + + {{ (m.fileTypes || []).join('/') }} · ≤{{ m.maxSizeKb || 1024 }}KB + + + + {{ m.tipText }} + + + + + {{ f.standardName || f.originalName }} + 删除 + + + + {{ kb(f.rawSize) }} → {{ kb(f.finalSize) }} + + {{ kb(f.finalSize) }} + · {{ f.width }}×{{ f.height }} + · 处理中… + + + + + + + {{ saveHint }} @@ -78,6 +119,7 @@ import { computed, ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { request } from '@/utils/request' + import { API_BASE_URL as API_BASE } from '@/config' /** * 填报页。 @@ -257,7 +299,136 @@ function token(): string { } } -async function doSubmit() { +async // ── 材料上传 ───────────────────────────────── + +interface Material { + materialCode: string + materialName: string + required?: boolean + fileTypes?: string[] + maxSizeKb?: number + maxCount?: number + tipText?: string | null +} +interface UploadedFile { + id: number + materialCode: string + originalName: string + standardName: string | null + rawSize: number + finalSize: number + width?: number + height?: number +} + +const materials = ref([]) +const filesByMaterial = ref>({}) +/** 已提交/已截止时不给增删材料(与后端同口径)。 */ +const filesDeletable = computed(() => + submissionId.value !== null && (submissionStatus.value ?? 0) === 0) +const submissionStatus = ref(0) + +function kb(n?: number): string { + if (!n) return '0KB' + return n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + 'MB' : Math.round(n / 1024) + 'KB' +} + +async function loadFiles() { + if (!submissionId.value) return + try { + const list = await request({ + url: `/api/v1/mp/files/submissions/${submissionId.value}`, + token: token() + }) + const m: Record = {} + for (const f of list || []) { + (m[f.materialCode] = m[f.materialCode] || []).push(f) + } + filesByMaterial.value = m + } catch { + // 列不出来不该让整页崩 —— 表单还能填 + } +} + +/** + * 选文件 → 上传 → 立刻处理。 + * + *

**为什么上传后马上处理**:学生要能看到"压到了多大、叫什么名字"才算完成。 + * 若把处理留到提交时,学生上传时会以为"传上去了",提交时才发现体积超标 —— + * 而那时他可能已经在回家的路上。 + */ +function uploadFor(m: Material) { + uni.chooseImage({ + count: 1, + success: async (res) => { + const path = (res.tempFilePaths || [])[0] + if (!path || !submissionId.value) return + uni.showLoading({ title: '上传中…' }) + try { + const uploaded = await new Promise<{ id: number }>((resolve, reject) => { + uni.uploadFile({ + url: API_BASE + '/api/v1/mp/files/upload', + filePath: path, + name: 'file', + formData: { + submissionId: String(submissionId.value), + materialCode: m.materialCode + }, + header: token() ? { Authorization: `Bearer ${token()}` } : {}, + success: (r) => { + try { + const body = JSON.parse(r.data || '{}') + if (body.code === 0) resolve(body.data) + else reject(new Error(body.msg || '上传失败')) + } catch (e) { + reject(new Error('上传响应格式不对')) + } + }, + fail: (e) => reject(new Error(e.errMsg || '上传失败')) + }) + }) + uni.showLoading({ title: '处理中…' }) + await request({ + url: `/api/v1/mp/files/${uploaded.id}/process`, + method: 'POST', + token: token() + }) + await loadFiles() + uni.showToast({ title: '已压缩并改名', icon: 'success' }) + } catch (e) { + uni.showModal({ + title: '上传未完成', + content: e instanceof Error ? e.message : '请重试', + showCancel: false + }) + } finally { + uni.hideLoading() + } + } + }) +} + +function removeFile(f: UploadedFile) { + uni.showModal({ + title: '删掉这份材料?', + content: `「${f.standardName || f.originalName}」将被移除,之后可以重新上传。`, + success: async (r) => { + if (!r.confirm) return + try { + await request({ + url: `/api/v1/mp/files/${f.id}`, + method: 'DELETE', + token: token() + }) + await loadFiles() + } catch (e) { + uni.showToast({ title: e instanceof Error ? e.message : '删除失败', icon: 'none' }) + } + } + }) +} + +function doSubmit() { // 提交(含校验、红黄绿问题)是下一段。这里明确说清,而不是静默无反应 uni.showModal({ title: '提交功能开发中', @@ -294,6 +465,7 @@ onLoad(async (options) => { for (const f of s.fields) v[f.key] = f.value ?? '' } values.value = v + materials.value = (form.materials || []) as Material[] // 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存 if (!submissionId.value) {