66e4ccade9
一、效果(浏览器实测,真存储、真接口)
手机竖拍 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° 顺时针,不只是宽高换了位置)。
963 lines
37 KiB
Vue
963 lines
37 KiB
Vue
<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>
|