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:
2026-09-29 11:59:58 +08:00
parent 80a4050e53
commit 8ef09733b1
4 changed files with 254 additions and 2 deletions
+173 -1
View File
@@ -65,6 +65,47 @@
</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">
<text class="fill__save-hint">{{ saveHint }}</text>
<button class="fill__btn" @click="doSubmit">提交审核</button>
@@ -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<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({
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) {