Files
xiaotutongxue/frontend-mp/src/pages/fill/fill.vue
T
lovife 66e4ccade9 feat(mp): 前端压缩做完 —— 上传体积降到 1/8,顺带修掉两个真 bug
一、效果(浏览器实测,真存储、真接口)
   手机竖拍 3200×2400 + EXIF Orientation=6,3699KB
     → 1200×1600(竖的,转正正确)· 438KB · q88 · 73ms
   服务端**原样收下**:collect_file 里 raw_size=448940、final_size=448940、
   quality=0(NOT_REENCODED)—— 字节完全相同,没有二次编码。

二、🐛 bug 一:浏览器对 EXIF 的处理**不一致**,猜必错一半
   写完前端旋正后实测,输出是 1600×1200(横的)—— 又躺倒了。量出来:
     文件里的像素            3200×2400(横)+ Orientation=6
     img.naturalWidth/Height     2400×3200  ← 浏览器**已经自己按 EXIF 转正了**
     createImageBitmap('none')   2400×3200  ← 连明确要求不给转也被忽略
   现代浏览器会把 EXIF 先转正再交给 drawImage,我又转一次 → 转两下 = 又躺倒;
   而老实现(旧 X5 / 旧 WKWebView)给的是原始像素,不转就是躺倒。
   两端行为相反,**任何写死的做法都必错一半**。

   解法:不猜,量出来再决定 —— 自己从 JPEG 的 SOF 段读**文件里的原始像素**
   (readJpegSize,与 EXIF 无关),再和 naturalWidth/Height 比:
     · 与原始尺寸互换 → 浏览器已经转过 → 不要再转
     · 两者相同       → 给的是原始像素   → 我们自己转
     · 都对不上       → 按现代浏览器的默认(已转正)处理

   这和第 §一·二十 的 EXIF bug 是同一件事的两面:
   后端不转是躺倒,前端多转一次也是躺倒。

三、🐛 bug 二:按后缀判格式 → 前端压缩一上线,学生就传不了材料
   上传被拒:「身份证」只接受 jpg/jpeg/png,你传的是 .bin。
   H5 用 **blob 地址**调 uni.uploadFile,multipart 里的文件名是一串 UUID、
   **没有后缀**,而后端当时按 extOf(name) 判白名单 → 判成 .bin → 拒。
   这不是脚手架的问题:前端压缩产出的就是 blob 地址,一上线所有压缩过的照片
   都会被拒。两处一起修:
     · 后端改为按**字节幻数**判格式(sniffExt),幻数认不出再退回看后缀
       (顺带变强:PNG 改名成 .jpg 也能正确识别)
     · 接口新增可选 fileName 字段,客户端显式声明文件名;
       original_name 只用于展示留痕、不进路径拼接,并截断防超长

四、档位 → 前端行为的映射(**不按材料码猜**,档位是管理员可改的配置)
     P2 证件翻拍 → 压:长边 1600 + q88 起阶梯
     P3 扫描件   → 压:长边 3508 + 降质量(别套 P2 的 1600,会看不清字)
     AUTO/P5     → 只降质量不缩尺寸(未知档位擅自缩尺寸是危险的)
     P1 证件照   → 不压(服务端要 295×413 裁切 + 补白底)
     P4 截图     → 不压(关键是保持 PNG,canvas 只能出 JPEG 会糊掉表格线)
   与服务端配合的关键:必须压成**正好落在服务端那套目标里** ——
   压狠了白掉一次画质,压轻了服务端再压一次、前端白干(实测 quality=0 证明走对了)。

五、小程序端:**刻意不做 canvas 旋正**(真机未验证)
   orientation === 'up' → compressImage 压;其它朝向 → 不压、直传,交服务端转正。
   理由:小程序 canvas 的旋正在真机上无法验证,转错了的后果是机构收到躺倒的身份证;
   宁可慢一点,也不能赌。

六、E2E
   e2e-m3-file 39/39,新增第 8 节守这两个 bug:
     ✓ **没有后缀**的真 JPEG 被接受(按字节判格式,不看后缀)
     ✓ 字节是 PNG、后缀写着 .txt → 按字节认作 png 并接受
   全量 469 条断言、0 失败。

七、验证脚手架已拆
   pages/dev/compress-check.vue + static/__devcheck/ + 造图脚本都是一次性的,
   已全部删除并重建 H5(自查过产物里不再有 compress-check / __devcheck)。
   它的价值在于:文件选择框没法自动化(chooseImage 会弹系统对话框),
   所以那页绕开"选文件",直接拿真构造的竖拍照片喂给**真正的压缩模块**、
   走**真正的上传通道** —— 验的比"尺寸对不对"更硬:
   **定位色块落在右上角**(证明转的是 90° 顺时针,不只是宽高换了位置)。
2026-09-29 16:25:51 +08:00

963 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="fill">
<view class="fill__wrap">
<view class="fill__hero">
<text class="fill__tenant">{{ tenant }}</text>
<text class="fill__task">{{ taskName }}</text>
<text v-if="deadlineTip" class="fill__deadline">{{ deadlineTip }}</text>
</view>
<view v-if="loading" class="fill__loading">正在读取…</view>
<view v-else-if="loadError" class="fill__error">{{ loadError }}</view>
<template v-else>
<view v-for="sec in visibleSections" :key="sec.title" class="fill__card">
<view class="fill__section-title">{{ sec.title }}</view>
<view v-for="f in sec.fields" :key="f.key" class="fill__field">
<view class="fill__label-row">
<text class="fill__label">{{ f.label }}</text>
<text v-if="f.required" class="fill__req">必填</text>
<text v-if="f.locked" class="fill__lock">只读</text>
</view>
<!-- ① 只读(老师确定,学生不能改) -->
<view v-if="f.locked" class="fill__ro">
<text>{{ display(f) }}</text>
<text class="fill__ro-hint">如有误请联系班主任</text>
</view>
<!-- ② 是/否 -->
<view v-else-if="f.type === 'bool'" class="fill__seg">
<view class="fill__seg-item" :class="{ 'fill__seg-item--on': boolVal(f.key) === true }"
@click="setValue(f.key, true)">
是
</view>
<view class="fill__seg-item" :class="{ 'fill__seg-item--on': boolVal(f.key) === false }"
@click="setValue(f.key, false)">
否
</view>
</view>
<!-- ③ 下拉(学历/等级/补考科目等) -->
<picker v-else-if="f.type === 'select' || f.type === 'cascade'" :range="f.options || []"
:value="optionIndex(f)" @change="onPick(f.key, $event)">
<view class="fill__input fill__input--pick">
{{ display(f) || '请选择' }}
</view>
</picker>
<!-- ④ 日期 -->
<picker v-else-if="f.type === 'date'" mode="date" :value="String(values[f.key] || '')"
@change="onDatePick(f.key, $event)">
<view class="fill__input fill__input--pick">
{{ display(f) || '请选择日期' }}
</view>
</picker>
<!-- ⑤ 文本类(含手机号/证件号/数字) -->
<input v-else class="fill__input" :type="inputType(f)" :value="String(values[f.key] ?? '')"
:placeholder="placeholder(f)"
@input="onInput(f.key, $event as unknown as UniInputEvent)" @blur="onBlur(f.key)" />
<view v-if="f.tip" class="fill__tip" :class="{ 'fill__tip--prefill': f.prefilled }">
{{ f.tip }}
</view>
</view>
</view>
<!-- 材料上传。用 visibleMaterials(已按条件求值)—— 在校生不该看到「工作证明」 -->
<view v-if="visibleMaterials.length" class="fill__card">
<view class="fill__section-title">需要上传的材料</view>
<view class="fill__section-note">
选好文件后系统会自动压到 1M 内并按规则改名,不用你自己处理
</view>
<view v-for="m in visibleMaterials" :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>
<view class="fill__file-ops">
<!-- 预览:学生最在意的是"我传对了吗"。没有预览他只能靠文件名猜,
而"看着差不多"的照片(比如背面传成了正面)只有看才认得出 -->
<text class="fill__file-view" @click="preview(f)">预览</text>
<text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
</view>
</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 v-if="canEdit" class="fill__btn" :disabled="submitting" @click="doSubmit">
{{ submitting ? '正在校验…' : '提交审核' }}
</button>
<view v-else class="fill__done">{{ statusText }}</view>
</view>
</template>
</view>
<!-- 校验问题清单(红黄绿)。
用页面内浮层而不是 uni.showModal:showModal 只能放一段纯文本,
而问题清单要分级上色、每条要带规则编号 —— 这些正是学生能看懂"哪里不对"的关键。
全屏纯文本会让人只看到"提交失败",然后放弃 -->
<!-- 材料预览。全屏浮层,点图片以外的地方关闭 —— 学生看完就退回表单,不要多一步"关闭" -->
<view v-if="previewSrc" class="fill__viewer" @click="closePreview">
<view class="fill__viewer-head">
<text class="fill__viewer-name">{{ previewName }}</text>
<text class="fill__viewer-close">关闭</text>
</view>
<image class="fill__viewer-img" :src="previewSrc" mode="widthFix" @click.stop />
<view class="fill__viewer-hint">点图片以外的地方关闭</view>
</view>
<view v-if="report" class="fill__mask" @click="report = null">
<view class="fill__sheet" @click.stop>
<view class="fill__sheet-head">
<text class="fill__sheet-title">{{ reportTitle }}</text>
<text class="fill__sheet-close" @click="report = null">关闭</text>
</view>
<view class="fill__sheet-sum">
<text v-if="report.redCount" class="fill__pill fill__pill--red">
必须处理 {{ report.redCount }}
</text>
<text v-if="report.yellowCount" class="fill__pill fill__pill--yellow">
提醒 {{ report.yellowCount }}
</text>
<text v-if="report.infoCount" class="fill__pill fill__pill--info">
提示 {{ report.infoCount }}
</text>
</view>
<scroll-view scroll-y class="fill__sheet-body">
<view v-for="(it, i) in report.issues" :key="i"
class="fill__issue" :class="'fill__issue--s' + it.severity">
<view class="fill__issue-top">
<text class="fill__issue-code">{{ it.ruleCode }}</text>
<text class="fill__issue-title">{{ it.title }}</text>
</view>
<text v-if="it.detail" class="fill__issue-detail">{{ it.detail }}</text>
</view>
</scroll-view>
<view class="fill__sheet-foot">
<button class="fill__btn" @click="onReportAction">{{ reportAction }}</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
import { compressForUpload, planFor, releaseCompressedPath, type CompressOutcome } from '@/utils/image-compress'
import { API_BASE_URL as API_BASE } from '@/config'
/**
* 填报页。
*
* <p>三态渲染是这一屏的核心(契约 §9.2):
* <ul>
* <li><b>只读</b>({@code locked}):姓名、身份证号、报考职业/工种/等级 ——
* 样式灰底 + 「如有误请联系班主任」。<b>不要渲染成可编辑再靠服务端拒绝</b> ——
* 让学生白改一次是最糟的体验,而报错工种在机构那边直接不受理。</li>
* <li><b>老师已填,可改</b>({@code prefilled && !locked}):学历等 —— 标注来源,
* 改了会留痕(服务端记 changed_fields)。</li>
* <li><b>空白可填</b>:其余。</li>
* </ul>
*
* <p><b>条件级联</b>({@code visibleWhen})在这一层求值:补考三科只在「是否补考=是」时出现,
* 在校生三项只在「是否在校生=是」时出现。**隐藏 ≠ 清空** —— 学生把「是否补考」改回"否",
* 已填的补考科目仍然留在草稿里(万一他改回来)。要不要清空由后端在提交时判断,
* 前端不该替用户做不可逆的事。
*
* <p><b>保存策略:失焦即存单个字段</b>。不做"整表提交":学生填到一半切出去接电话,
* 回来时整表丢失是最让人恼火的失败。单字段保存的代价是一次请求,
* 而一次请求远小于"白填一遍"。
*/
defineOptions({ name: 'Fill' })
interface Field {
key: string
label: string
type: string
builtin?: boolean
locked?: boolean
required?: boolean
options?: string[]
tip?: string | null
prefilled?: boolean
visibleWhen?: Record<string, unknown> | null
value?: unknown
}
interface Section { title: string, fields: Field[] }
interface FormData {
taskId: number
taskName: string
formVersion: number
submissionId: number | null
/** 0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档 */
submissionStatus?: number
sections: Section[]
materials: unknown[]
submitPolicy?: { deadlineTip?: string }
}
/**
* uni-app 的 `input` 事件在**运行时**是 `{ detail: { value } }`,
* 而它的 DOM 类型是 `InputEvent`(后者的 `detail` 是个数字)——两者对不上,
* 所以模板里要显式收窄。类型收窄只是给编译器看的,不影响运行时。
*/
type UniInputEvent = { detail: { value: string } }
const taskId = ref(0)
const tenant = ref('')
const taskName = ref('')
const deadlineTip = ref('')
const loading = ref(true)
const loadError = ref('')
const sections = ref<Section[]>([])
const values = ref<Record<string, unknown>>({})
const submissionId = ref<number | null>(null)
const saveHint = ref('')
let saveTimer: ReturnType<typeof setTimeout> | null = null
/** 条件级联求值。布尔的两端表示(true/false 与 1/0)要归一,与后端 sameValue 同口径。 */
function norm(v: unknown): string {
const s = v === null || v === undefined ? '' : String(v).trim()
if (s.toLowerCase() === 'true') return '1'
if (s.toLowerCase() === 'false') return '0'
return s
}
/**
* 条件显示求值(字段与**材料**共用这一份)。
*
* ⚠️ 材料也必须走这里。**实测漏过**:材料侧一开始没求值,
* 于是「工作证明(仅非在校生)」照样显示给在校生 —— 而不传它又不会报错,
* 因为**后端校验是排除它的**(`SubmissionValidator.visible`)。
* 表现是"系统让我传,我不传也没事",学生只能自己猜,最后去问班主任。
*/
function whenVisible(w: Record<string, unknown> | null | undefined): boolean {
const f = w?.field
if (!w || !f) return true
if ((w.op ?? 'eq') !== 'eq') return true
return norm(values.value[String(f)]) === norm(w.value)
}
function visible(f: Field): boolean {
return whenVisible(f.visibleWhen as Record<string, unknown> | null)
}
const visibleSections = computed(() =>
sections.value
.map(s => ({ title: s.title, fields: s.fields.filter(visible) }))
.filter(s => s.fields.length > 0)
)
/** 当前真正要收的材料。与 visibleSections 同一套规则、同一份值。 */
const visibleMaterials = computed(() =>
materials.value.filter(m => whenVisible(m.visibleWhen))
)
function display(f: Field): string {
const v = values.value[f.key]
if (v === null || v === undefined || v === '') return ''
if (f.type === 'bool') return v === true || norm(v) === '1' ? '是' : '否'
return String(v)
}
function boolVal(key: string): boolean | null {
const v = values.value[key]
if (v === null || v === undefined || v === '') return null
if (v === true || norm(v) === '1') return true
return false
}
function optionIndex(f: Field): number {
const opts = f.options || []
const i = opts.indexOf(String(values.value[f.key] ?? ''))
return i < 0 ? 0 : i
}
function inputType(f: Field): string {
if (f.type === 'number') return 'number'
if (f.type === 'phone') return 'number'
return 'text'
}
function placeholder(f: Field): string {
if (f.type === 'date') return '请选择日期'
if (f.type === 'idcard') return '18 位身份证号'
return f.required ? '必填' : '选填'
}
// ── 取值与保存 ────────────────────────────────
function onInput(key: string, e: { detail: { value: string } }) {
values.value[key] = e.detail.value
}
function onBlur(key: string) {
persist(key)
}
/** 日期选择。与 onPick 分开是因为日期的值是字符串、下拉的值是索引 —— 混在一起容易串。 */
function onDatePick(key: string, e: { detail: { value: string } }) {
setValue(key, e.detail.value)
}
function onPick(key: string, e: { detail: { value: number | string } }) {
const f = allFields.value[key]
if (!f || !f.options) return
const idx = Number(e.detail.value)
values.value[key] = f.options[idx]
persist(key)
}
function setValue(key: string, v: unknown) {
values.value[key] = v
persist(key)
}
const allFields = computed(() => {
const m: Record<string, Field> = {}
for (const s of sections.value) {
for (const f of s.fields) m[f.key] = f
}
return m
})
/**
* 保存单个字段。
*
* <p>只读字段不保存(服务端也会拒)—— 在前端拦掉能省一次必然失败的请求,
* 但**不能只靠前端**:服务端那道拒绝才是真的。
*/
async function persist(key: string) {
const f = allFields.value[key]
if (!f || f.locked) return
if (!submissionId.value) return
const v = values.value[key]
const body: Record<string, unknown> = {}
body[key] = typeof v === 'boolean' ? v : (v === undefined ? '' : v)
try {
await request<void>({
url: `/api/v1/mp/submissions/${submissionId.value}`,
method: 'PUT',
token: token(),
data: { formData: body }
})
saveHint.value = '已保存 ' + new Date().toLocaleTimeString().slice(0, 8)
} catch (e) {
saveHint.value = e instanceof Error ? `保存失败:${e.message}` : '保存失败'
}
}
function token(): string {
try {
return uni.getStorageSync('token') || ''
} catch {
return ''
}
}
// ── 材料上传 ─────────────────────────────────
interface Material {
materialCode: string
materialName: string
required?: boolean
fileTypes?: string[]
maxSizeKb?: number
maxCount?: number
/**
* 压缩档位(`AUTO`/`P1`~`P5`)。**前端压缩目标由它推导**,不按材料码猜 ——
* 档位是管理员可改的配置,猜材料码的话管理员一改前端就错,而且错得悄无声息。
*/
compressProfile?: string | null
/** 质量下限。低于它就不再降质量(与服务端同口径)。 */
minQuality?: number | null
tipText?: string | null
/** 条件显示规则;null = 无条件显示。**必须与字段一样求值** —— 详见 visibleMaterials */
visibleWhen?: Record<string, unknown> | null
}
interface UploadedFile {
id: number
materialCode: string
originalName: string
standardName: string | null
rawSize: number
finalSize: number
width?: number
height?: number
}
interface SubmitIssue {
ruleCode: string
severity: number
title: string
detail?: string | null
}
interface SubmitReport {
submitted: boolean
status: number
statusText: string
redCount: number
yellowCount: number
infoCount: number
issues: SubmitIssue[]
}
const materials = ref<Material[]>([])
const filesByMaterial = ref<Record<string, UploadedFile[]>>({})
/** 填报单状态:0 草稿 / 1 待初审 / 2 待终审 / 3 已通过 / 4 已驳回 / 5 已归档。 */
const submissionStatus = ref<number>(0)
const submitting = ref(false)
/** 提交结果报告。null = 没有要展示的清单。 */
const report = ref<SubmitReport | null>(null)
/**
* 可编辑 = 草稿 或 被驳回。
*
* <p>已提交/已通过/已归档都不给改,与后端同口径(后端也会拒)。
* 前端隐藏按钮不是为了"安全",而是**别让学生白改一遍再被拒** ——
* 他改了半小时、点了提交,才被告知"要改先撤回",那个体验很伤人。
*/
const canEdit = computed(() => {
const st = submissionStatus.value
return submissionId.value !== null && (st === 0 || st === 4)
})
const filesDeletable = canEdit
const statusText = computed(() => {
const t: Record<number, string> = {
1: '已提交,等待班主任初审',
2: '初审通过,等待管理员终审',
3: '已通过',
4: '已驳回,请修改后重新提交',
5: '已归档'
}
return t[submissionStatus.value] ?? '草稿'
})
const reportBlocked = computed(() => (report.value?.redCount ?? 0) > 0)
const reportTitle = computed(() =>
reportBlocked.value ? '还不能提交' : '提交前请确认'
)
const reportAction = computed(() =>
reportBlocked.value ? '知道了,去修改' : '仍然提交'
)
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>**为什么要在前端压**:上传这一步发生在服务端压缩**之前** —— 服务端压得再好,
* 学生的 3MB 也已经从流量里走完了。前端先压到几百 KB,上传时间大致降到 1/4。
* 压不了的(证件照、截图、需要转正的小程序照片)就原样上传,由服务端兜底 ——
* **前端压缩只是优化,不该成为新的失败点**(见 utils/image-compress.ts)。
*
* <p>**不再调 `/process`**:服务端改成"收上来即压缩、只存规范件"了(见
* docs/交接摘要.md §一·二十)。所以原来那次"上传完再让服务端处理"的往返
* 现在没有任何意义 —— 少一次请求,学生少等一个来回。
*/
function uploadFor(m: Material) {
uni.chooseImage({
count: 1,
success: async (res) => {
const path = (res.tempFilePaths || [])[0]
if (!path || !submissionId.value) return
let outcome: CompressOutcome | null = null
let sendPath = path
// 选中的原始文件名。**H5 用 blob 地址上传时 multipart 里带不过去**(是一串 UUID、
// 没有后缀),所以下面要显式告诉服务端叫什么 —— 否则它按后缀判格式会直接拒掉。
const pickedName = ((res.tempFiles || [])[0] as { name?: string } | undefined)?.name || ''
try {
const plan = planFor(m.compressProfile, m.maxSizeKb, m.minQuality)
if (plan) {
uni.showLoading({ title: '压缩中…' })
outcome = await compressForUpload(path, plan)
sendPath = outcome.path
// 结论写日志:学生说"我的图怎么糊了"时,这是唯一能回看"压过几次、压到多少"的地方
console.log('[材料压缩]', m.materialCode, outcome.note)
}
uni.showLoading({ title: '上传中…' })
// 压缩后内容是 JPEG,所以扩展名必须跟着改成 .jpg(原名可能是 .png/.heic)
const baseName = pickedName ? pickedName.replace(/\.[^.]+$/, '') : m.materialName
const sendName = outcome?.compressed
? baseName + '.jpg'
: (pickedName || m.materialName + '.jpg')
await new Promise<{ id: number }>((resolve, reject) => {
uni.uploadFile({
url: API_BASE + '/api/v1/mp/files/upload',
filePath: sendPath,
name: 'file',
formData: {
submissionId: String(submissionId.value),
materialCode: m.materialCode,
fileName: sendName
},
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 || '上传失败'))
})
})
await loadFiles()
// 如实告诉学生发生了什么:压过了就说压到多大,没压就说已上传 ——
// 不说清的话,他看到"体积比原图小"会以为材料被换了
uni.showToast({
title: outcome?.compressed ? `已压缩 ${kb(outcome.afterBytes)}` : '已上传',
icon: 'success'
})
} catch (e) {
uni.showModal({
title: '上传未完成',
content: e instanceof Error ? e.message : '请重试',
showCancel: false
})
} finally {
if (outcome) releaseCompressedPath(sendPath, outcome)
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' })
}
}
})
}
// ── 材料预览 ─────────────────────────────────
const previewSrc = ref<string | null>(null)
const previewName = ref('')
const previewing = ref(false)
/** 扩展名 → MIME。做 blob 时要用,猜错的表现是"图裂了"。 */
function mimeOf(name: string): string {
const ext = (name.split('.').pop() || '').toLowerCase()
if (ext === 'png') return 'image/png'
if (ext === 'pdf') return 'application/pdf'
if (ext === 'webp') return 'image/webp'
return 'image/jpeg'
}
/**
* 预览一份材料。
*
* <p>两种存储两种取法,**不能只写一种**:
* <ul>
* <li>七牛:接口返回的是**带签名的绝对 URL**,可以直接交给 {@code <image src>};</li>
* <li>本地磁盘:那个接口要带 Authorization 头,而 {@code <image src>} **发不了请求头** ——
* 所以先用 uni.request 把字节取回来,再转成 blob URL。
* 只写七牛那一种,本地开发时预览会一片空白,而原因完全看不出来。</li>
* </ul>
*
* <p>⚠️ 签名链接有有效期(默认 30 分钟),所以**每次预览都现取**,
* 不要把 URL 缓存起来 —— 缓存那份过期后是 403,而学生只会觉得"打不开"。
*/
async function preview(f: UploadedFile) {
if (previewing.value) return
previewing.value = true
uni.showLoading({ title: '加载中…' })
try {
const r = await request<{ url: string, name: string }>({
url: `/api/v1/mp/files/${f.id}/url`,
token: token()
})
previewName.value = r.name || f.standardName || f.originalName
if (/^https?:\/\//i.test(r.url)) {
previewSrc.value = r.url
} else {
const buf = await new Promise<ArrayBuffer>((resolve, reject) => {
uni.request({
url: API_BASE + r.url,
header: token() ? { Authorization: `Bearer ${token()}` } : {},
responseType: 'arraybuffer',
success: (res) => {
if (res.statusCode === 200) resolve(res.data as ArrayBuffer)
else reject(new Error(`打不开(HTTP ${res.statusCode})`))
},
fail: (e) => reject(new Error(e.errMsg || '打不开'))
})
})
revokePreview()
previewSrc.value = URL.createObjectURL(new Blob([buf], { type: mimeOf(previewName.value) }))
}
} catch (e) {
uni.showModal({
title: '打不开这份材料',
content: e instanceof Error ? e.message : '请稍后重试',
showCancel: false
})
} finally {
previewing.value = false
uni.hideLoading()
}
}
/** blob URL 是浏览器持有的内存,不释放就会一直占着(学生可能连看十几张)。 */
function revokePreview() {
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
URL.revokeObjectURL(previewSrc.value)
}
}
function closePreview() {
revokePreview()
previewSrc.value = null
}
/**
* 提交按钮的入口。
*
* <p>⚠️ **不要给这个函数加参数**。模板里写的是 `@click="doSubmit"`,
* 而 uni-app 会把事件对象作为第一个参数传进来 —— 如果签名是
* `doSubmit(confirmWarnings = false)`,那个事件对象会被当成"已确认警告"(真值),
* 于是有警告时会被静默强制提交。这类 bug 不会有任何报错。
*/
function doSubmit() {
void submitNow(false)
}
/**
* 真正提交。
*
* <p>**两次调用是刻意的**:第一次不带 {@code confirmWarnings},
* 服务端若发现只有黄色警告就返回 {@code submitted=false} 让我们弹确认;
* 学生点「仍然提交」才带 {@code confirmWarnings=true} 再调一次。
*
* <p>为什么不在前端自己判断"只有警告就直接放行":
* 因为只有服务端的校验结果算数 —— 两次请求之间学生可能又改坏了什么。
* 前端手上那份清单只用来渲染,不能用来做决定。
*/
async function submitNow(confirmWarnings: boolean) {
if (!submissionId.value || submitting.value) return
submitting.value = true
uni.showLoading({ title: '正在校验…' })
try {
const r = await request<SubmitReport>({
url: `/api/v1/mp/submissions/${submissionId.value}/submit`,
method: 'POST',
token: token(),
data: { confirmWarnings }
})
if (r.submitted) {
report.value = null
submissionStatus.value = r.status ?? 1
uni.showModal({
title: '提交成功',
content: r.statusText || '已提交,等待班主任初审',
showCancel: false
})
} else {
report.value = r
}
} catch (e) {
uni.showModal({
title: '提交失败',
content: e instanceof Error ? e.message : '请稍后重试',
showCancel: false
})
} finally {
submitting.value = false
uni.hideLoading()
}
}
function onReportAction() {
if (reportBlocked.value) {
// 有红色项:关掉清单让他去改。**不要**顺手滚到第一个错误处 ——
// 那会在学生正在读清单时把页面拽走
report.value = null
return
}
void submitNow(true)
}
onLoad(async (options) => {
taskId.value = Number(options?.taskId || 0)
const scene = options?.scene || ''
try {
if (scene) {
const ctx = await request<{ tenantName: string, taskName: string }>({
url: `/api/v1/mp/scene-context?scene=${encodeURIComponent(scene)}`
})
tenant.value = ctx.tenantName || ''
}
if (!taskId.value) {
loadError.value = '没有取到任务信息,请重新扫码进入'
loading.value = false
return
}
const form = await request<FormData>({
url: `/api/v1/mp/tasks/${taskId.value}/form`,
token: token()
})
sections.value = form.sections || []
submissionId.value = form.submissionId
submissionStatus.value = form.submissionStatus ?? 0
taskName.value = form.taskName
deadlineTip.value = form.submitPolicy?.deadlineTip || ''
const v: Record<string, unknown> = {}
for (const s of sections.value) {
for (const f of s.fields) v[f.key] = f.value ?? ''
}
values.value = v
materials.value = (form.materials || []) as Material[]
// 载入已传的材料:有草稿时学生上次传的还在,不拉一次他会以为白传了
await loadFiles()
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
if (!submissionId.value) {
const d = await request<{ id: number }>({
url: '/api/v1/mp/submissions',
method: 'POST',
token: token(),
data: { taskId: taskId.value }
})
submissionId.value = d.id
}
saveHint.value = '内容会自动保存'
} catch (e) {
loadError.value = e instanceof Error ? e.message : '读取表单失败'
} finally {
loading.value = false
}
})
</script>
<style lang="scss">
$primary: #0052d9;
$success: #00a870;
$danger: #e34d59;
$page-bg: #f3f3f3;
page { background-color: $page-bg; }
.fill {
min-height: 100vh;
padding: 24rpx;
&__wrap { max-width: 720rpx; margin: 0 auto; }
&__hero {
padding: 28rpx;
background: linear-gradient(135deg, $primary 0%, #2b6fe0 100%);
border-radius: 16rpx;
}
&__tenant { display: block; font-size: 24rpx; color: rgba(255,255,255,.82); }
&__task { display: block; margin-top: 8rpx; font-size: 34rpx; font-weight: 600; color: #fff; }
&__deadline { display: block; margin-top: 12rpx; font-size: 24rpx; color: rgba(255,255,255,.85); }
&__loading, &__error {
margin-top: 24rpx; padding: 40rpx 28rpx; font-size: 28rpx; text-align: center;
background: #fff; border-radius: 12rpx; color: #00000080;
}
&__error { color: $danger; }
&__card { margin-top: 20rpx; padding: 28rpx; background: #fff; border-radius: 12rpx; }
&__section-title { font-size: 30rpx; font-weight: 600; color: #000000e6; }
&__field { margin-top: 28rpx; }
&__label-row { display: flex; align-items: center; gap: 12rpx; margin-bottom: 10rpx; }
&__label { font-size: 28rpx; color: #00000099; }
&__req { font-size: 22rpx; color: $danger; }
&__lock { padding: 2rpx 10rpx; font-size: 20rpx; color: #00000073; background: #f0f0f0; border-radius: 6rpx; }
&__ro {
display: flex; align-items: baseline; justify-content: space-between;
min-height: 88rpx; padding: 20rpx; font-size: 30rpx; color: #000000a6;
background: #f5f6f8; border-radius: 10rpx;
}
&__ro-hint { font-size: 22rpx; color: #0000004d; }
&__input {
box-sizing: border-box; width: 100%; height: 88rpx; padding: 0 20rpx;
font-size: 30rpx; background: #f5f6f8; border-radius: 10rpx;
}
&__input--pick { display: flex; align-items: center; color: #000000e6; }
&__seg { display: flex; gap: 16rpx; }
&__seg-item {
flex: 1; height: 88rpx; line-height: 88rpx; font-size: 30rpx; text-align: center;
color: #000000a6; background: #f5f6f8; border-radius: 10rpx;
&--on { color: #fff; background: $primary; }
}
&__tip { margin-top: 8rpx; font-size: 24rpx; color: #0000004d; }
&__tip--prefill { color: #ed7b2f; }
&__bar { margin-top: 32rpx; padding-bottom: 40rpx; }
&__save-hint { display: block; margin-bottom: 16rpx; font-size: 24rpx; text-align: center; color: #0000004d; }
&__btn {
font-size: 32rpx; color: #fff; background: $primary; border-radius: 12rpx;
&::after { border: none; }
}
// ── 材料上传 ──
&__section-note { margin-bottom: 20rpx; font-size: 24rpx; color: #00000099; line-height: 1.5; }
&__mat {
padding: 24rpx 0;
& + & { border-top: 1rpx solid #f0f0f0; }
}
&__mat-head { display: flex; align-items: center; flex-wrap: wrap; gap: 12rpx; }
&__mat-name { font-size: 30rpx; font-weight: 600; color: #000000e6; }
&__mat-req { width: 100%; font-size: 24rpx; color: #0000004d; }
&__mat-tip { margin-top: 10rpx; font-size: 24rpx; color: #00000099; line-height: 1.5; }
&__mat-btn {
display: inline-block; margin: 20rpx 0 0; padding: 0 28rpx; font-size: 26rpx;
line-height: 2.3; color: $primary; background: #ecf2fe; border-radius: 8rpx;
&::after { border: none; }
}
// 已传的材料:要让学生一眼看清「原名 → 规范名」「压前 → 压后」——
// 这正是他花钱买的那个能力,藏起来等于没做
&__file { margin-top: 16rpx; padding: 16rpx 20rpx; background: #f7f8fa; border-radius: 10rpx; }
&__file-line { display: flex; align-items: baseline; justify-content: space-between; gap: 20rpx; }
&__file-name { flex: 1; font-size: 26rpx; color: #000000e6; word-break: break-all; }
&__file-del { flex-shrink: 0; font-size: 24rpx; color: $danger; }
&__file-meta { margin-top: 8rpx; font-size: 22rpx; color: #0000004d; }
&__file-ok { color: #00a870; }
&__file-pending { color: #ed7b2f; }
&__done {
padding: 22rpx 0; font-size: 28rpx; text-align: center; color: #00000073;
background: #f0f0f0; border-radius: 12rpx;
}
&__file-ops { display: flex; flex-shrink: 0; align-items: baseline; gap: 20rpx; }
&__file-view { font-size: 24rpx; color: $primary; }
// ── 材料预览(全屏,深底 —— 材料照片常是白底的证件件,浅底会看不清边界)──
//
// ⚠️ z-index 必须**高过 uni-app 的页面导航栏**(它是 998),否则浮层的标题与
// 「关闭」会被导航栏盖住 —— 而那看起来像"标题没渲染出来"。
// 同时用 padding-top 给导航栏让出位置(44px ≈ 88rpx),再叠加刘海屏安全区。
&__viewer {
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9999;
display: flex; flex-direction: column; overflow-y: auto; background: rgba(0, 0, 0, .92);
padding-top: calc(88rpx + constant(safe-area-inset-top));
padding-top: calc(88rpx + env(safe-area-inset-top));
}
&__viewer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 32rpx 28rpx 16rpx;
}
&__viewer-name { flex: 1; font-size: 28rpx; color: #fff; word-break: break-all; }
&__viewer-close { flex-shrink: 0; margin-left: 24rpx; font-size: 28rpx; color: #fff; }
&__viewer-img { width: 100%; }
&__viewer-hint { padding: 24rpx; font-size: 24rpx; text-align: center; color: rgba(255, 255, 255, .6); }
// ── 校验问题清单(红黄绿)──
&__mask {
/* 同样要盖过 uni-app 的导航栏(998),否则清单高一点时顶部会被压住 */
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9998;
display: flex; align-items: flex-end; background: rgba(0, 0, 0, .45);
}
&__sheet {
box-sizing: border-box; width: 100%; max-height: 82vh; padding: 28rpx 28rpx 40rpx;
background: #fff; border-radius: 24rpx 24rpx 0 0;
}
&__sheet-head { display: flex; align-items: center; justify-content: space-between; }
&__sheet-title { font-size: 34rpx; font-weight: 600; color: #000000e6; }
&__sheet-close { font-size: 26rpx; color: #00000073; }
&__sheet-sum { display: flex; flex-wrap: wrap; gap: 12rpx; margin-top: 20rpx; }
&__pill { padding: 6rpx 18rpx; font-size: 22rpx; border-radius: 999rpx; }
&__pill--red { color: $danger; background: #fdecee; }
&__pill--yellow { color: #ed7b2f; background: #fef3e8; }
&__pill--info { color: #0052d9; background: #ecf2fe; }
&__sheet-body { max-height: 52vh; margin-top: 20rpx; }
&__issue {
margin-bottom: 16rpx; padding: 20rpx; border-left: 8rpx solid #dcdcdc;
background: #f7f8fa; border-radius: 8rpx;
&--s3 { border-left-color: $danger; background: #fdf3f4; }
&--s2 { border-left-color: #ed7b2f; background: #fef8f2; }
&--s1 { border-left-color: #0052d9; background: #f4f7fe; }
}
&__issue-top { display: flex; align-items: baseline; gap: 12rpx; }
&__issue-code {
flex-shrink: 0; padding: 2rpx 10rpx; font-size: 20rpx; color: #00000073;
background: #ffffff; border-radius: 6rpx;
}
&__issue-title { flex: 1; font-size: 28rpx; font-weight: 600; color: #000000e6; }
&__issue-detail {
display: block; margin-top: 10rpx; font-size: 26rpx; line-height: 1.6; color: #00000099;
}
&__sheet-foot { margin-top: 24rpx; }
}
</style>