feat(file): 材料上传的前端界面 + 列出/删除接口
后端:GET /mp/files/submissions/{id}(列出我的材料)+ DELETE /mp/files/{id}(软删)。
两者都把归属条件(submitter_id)写进 SQL 的 WHERE —— 与门禁、草稿同一套写法,
少一个条件就是"能看/能删别人的材料"。
前端(填报页的材料区):
· 按材料项列出:名称、必传标记、格式白名单与体积上限、来源引导语
· 已传的显示「原名 → 规范名」与「压缩前 → 压缩后」体积(学生要能看见系统做了什么)
· 传完**立刻处理**并显示结果
⚠️ 为什么上传后马上处理而不是留到提交时:学生要能看到"压到了多大、叫什么名字"
才算完成。若把处理留到提交时,他上传时会以为"传上去了",提交时才发现体积超标 ——
而那时他可能已经在回家的路上。
· 学生能自己删(传错了不该只能找班主任)。已提交的填报单不给增删,与草稿同口径。
⚠️ 本轮**未完成浏览器肉眼验证**:构建产物里确实有材料区的代码
(产物 grep 到「需要上传的材料」),但浏览器加载的是**缓存的旧 chunk**
(实测 .fill__wrap 只有 4 个子元素,没有材料卡),我用带时间戳的方式绕缓存没成功。
代码与产物是对的,需要一次硬刷新(Ctrl+Shift+R)或清缓存后再确认。
这条记下来,不要当成"已验证"。
This commit is contained in:
+27
-1
@@ -9,6 +9,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.MediaType;
|
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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -18,6 +20,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,8 +69,30 @@ public class MaterialFileController {
|
|||||||
+ "压到材料项配的体积上限内(默认 1024KB),并按命名模板生成规范文件名")
|
+ "压到材料项配的体积上限内(默认 1024KB),并按命名模板生成规范文件名")
|
||||||
public ApiResult<Map<String, Object>> process(@PathVariable Long id) {
|
public ApiResult<Map<String, Object>> process(@PathVariable Long id) {
|
||||||
CollectFile f = fileService.process(id);
|
CollectFile f = fileService.process(id);
|
||||||
|
return ApiResult.ok(toVo(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Operation(summary = "删除材料(软删)",
|
||||||
|
description = "学生传错了要能自己删 —— 否则他只能传一份错的来交,或者去找班主任")
|
||||||
|
public ApiResult<Void> delete(@PathVariable Long id) {
|
||||||
|
fileService.delete(id);
|
||||||
|
return ApiResult.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/{submissionId}")
|
||||||
|
@Operation(summary = "列出某份填报单下的材料",
|
||||||
|
description = "按材料码分组。前端据此显示「已传了什么、压到多大、叫什么名字」")
|
||||||
|
public ApiResult<List<Map<String, Object>>> list(@PathVariable Long submissionId) {
|
||||||
|
return ApiResult.ok(fileService.listMine(submissionId).stream()
|
||||||
|
.map(MaterialFileController::toVo).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> toVo(CollectFile f) {
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
out.put("id", f.getId());
|
out.put("id", f.getId());
|
||||||
|
out.put("materialCode", f.getMaterialCode());
|
||||||
|
out.put("version", f.getVersion());
|
||||||
out.put("originalName", f.getOriginalName());
|
out.put("originalName", f.getOriginalName());
|
||||||
out.put("standardName", f.getStandardName());
|
out.put("standardName", f.getStandardName());
|
||||||
out.put("rawSize", f.getRawSize());
|
out.put("rawSize", f.getRawSize());
|
||||||
@@ -75,6 +100,7 @@ public class MaterialFileController {
|
|||||||
out.put("width", f.getWidth());
|
out.put("width", f.getWidth());
|
||||||
out.put("height", f.getHeight());
|
out.put("height", f.getHeight());
|
||||||
out.put("quality", f.getQuality());
|
out.put("quality", f.getQuality());
|
||||||
return ApiResult.ok(out);
|
out.put("validateStatus", f.getValidateStatus());
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -35,4 +35,23 @@ public interface FileMapper extends BaseMapper<CollectFile> {
|
|||||||
Long countCurrent(@Param("tenantId") Long tenantId,
|
Long countCurrent(@Param("tenantId") Long tenantId,
|
||||||
@Param("submissionId") Long submissionId,
|
@Param("submissionId") Long submissionId,
|
||||||
@Param("materialCode") String materialCode);
|
@Param("materialCode") String materialCode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出某份填报单下当前生效的文件。
|
||||||
|
*
|
||||||
|
* <p>归属条件({@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<CollectFile> listMine(@Param("tenantId") Long tenantId,
|
||||||
|
@Param("submissionId") Long submissionId,
|
||||||
|
@Param("submitterId") Long submitterId);
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -193,6 +193,41 @@ public class MaterialFileService {
|
|||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 列表与删除 ────────────────────────────────
|
||||||
|
|
||||||
|
public List<CollectFile> 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除材料(软删)。
|
||||||
|
*
|
||||||
|
* <p>学生传错了要能自己删 —— 否则他只能传一份错的来交,或者去找班主任。
|
||||||
|
* 已提交的填报单不能删(要改先撤回),与草稿保存同一口径。
|
||||||
|
*/
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
|
||||||
// ── 内部 ──────────────────────────────────────
|
// ── 内部 ──────────────────────────────────────
|
||||||
|
|
||||||
/** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */
|
/** 命名变量。名字要能与学生对上 —— 所以姓名与证件号必须来自名册,不来自学生输入。 */
|
||||||
|
|||||||
@@ -65,6 +65,47 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 材料上传 -->
|
||||||
|
<view class="fill__card">
|
||||||
|
<view class="fill__section-title">需要上传的材料</view>
|
||||||
|
<view class="fill__section-note">
|
||||||
|
选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-for="m in materials" :key="m.materialCode" class="fill__mat">
|
||||||
|
<view class="fill__mat-head">
|
||||||
|
<text class="fill__mat-name">{{ m.materialName }}</text>
|
||||||
|
<text v-if="m.required" class="fill__req">必传</text>
|
||||||
|
<text class="fill__mat-req">
|
||||||
|
{{ (m.fileTypes || []).join('/') }} · ≤{{ m.maxSizeKb || 1024 }}KB
|
||||||
|
<template v-if="(m.maxCount || 1) > 1"> · 最多 {{ m.maxCount }} 份</template>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="m.tipText" class="fill__mat-tip">{{ m.tipText }}</view>
|
||||||
|
|
||||||
|
<!-- 已传的 -->
|
||||||
|
<view v-for="f in (filesByMaterial[m.materialCode] || [])" :key="f.id" class="fill__file">
|
||||||
|
<view class="fill__file-line">
|
||||||
|
<text class="fill__file-name">{{ f.standardName || f.originalName }}</text>
|
||||||
|
<text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
|
||||||
|
</view>
|
||||||
|
<view class="fill__file-meta">
|
||||||
|
<text v-if="f.standardName && f.rawSize > f.finalSize">
|
||||||
|
{{ kb(f.rawSize) }} → <text class="fill__file-ok">{{ kb(f.finalSize) }}</text>
|
||||||
|
</text>
|
||||||
|
<text v-else>{{ kb(f.finalSize) }}</text>
|
||||||
|
<text v-if="f.width"> · {{ f.width }}×{{ f.height }}</text>
|
||||||
|
<text v-if="!f.standardName" class="fill__file-pending"> · 处理中…</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button v-if="filesDeletable" class="fill__mat-btn" size="mini"
|
||||||
|
@click="uploadFor(m)">
|
||||||
|
+ {{ (filesByMaterial[m.materialCode] || []).length ? '再传一份' : '选择文件' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="fill__bar">
|
<view class="fill__bar">
|
||||||
<text class="fill__save-hint">{{ saveHint }}</text>
|
<text class="fill__save-hint">{{ saveHint }}</text>
|
||||||
<button class="fill__btn" @click="doSubmit">提交审核</button>
|
<button class="fill__btn" @click="doSubmit">提交审核</button>
|
||||||
@@ -78,6 +119,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { request } from '@/utils/request'
|
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<Material[]>([])
|
||||||
|
const filesByMaterial = ref<Record<string, UploadedFile[]>>({})
|
||||||
|
/** 已提交/已截止时不给增删材料(与后端同口径)。 */
|
||||||
|
const filesDeletable = computed(() =>
|
||||||
|
submissionId.value !== null && (submissionStatus.value ?? 0) === 0)
|
||||||
|
const submissionStatus = ref<number>(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<UploadedFile[]>({
|
||||||
|
url: `/api/v1/mp/files/submissions/${submissionId.value}`,
|
||||||
|
token: token()
|
||||||
|
})
|
||||||
|
const m: Record<string, UploadedFile[]> = {}
|
||||||
|
for (const f of list || []) {
|
||||||
|
(m[f.materialCode] = m[f.materialCode] || []).push(f)
|
||||||
|
}
|
||||||
|
filesByMaterial.value = m
|
||||||
|
} catch {
|
||||||
|
// 列不出来不该让整页崩 —— 表单还能填
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选文件 → 上传 → 立刻处理。
|
||||||
|
*
|
||||||
|
* <p>**为什么上传后马上处理**:学生要能看到"压到了多大、叫什么名字"才算完成。
|
||||||
|
* 若把处理留到提交时,学生上传时会以为"传上去了",提交时才发现体积超标 ——
|
||||||
|
* 而那时他可能已经在回家的路上。
|
||||||
|
*/
|
||||||
|
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<void>({
|
||||||
|
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<void>({
|
||||||
|
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({
|
uni.showModal({
|
||||||
title: '提交功能开发中',
|
title: '提交功能开发中',
|
||||||
@@ -294,6 +465,7 @@ onLoad(async (options) => {
|
|||||||
for (const f of s.fields) v[f.key] = f.value ?? ''
|
for (const f of s.fields) v[f.key] = f.value ?? ''
|
||||||
}
|
}
|
||||||
values.value = v
|
values.value = v
|
||||||
|
materials.value = (form.materials || []) as Material[]
|
||||||
|
|
||||||
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
|
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
|
||||||
if (!submissionId.value) {
|
if (!submissionId.value) {
|
||||||
|
|||||||
Reference in New Issue
Block a user