feat(submit): 提交 + 红黄绿校验(PRD §5.7 的 VAL 系列)
一次给全,不"改一条再拒一次":学生一次可能同时缺 3 项材料、2 个字段没填,
逐条拒绝会让他改 5 次提交 5 次。所以 validate 收全部问题后一次性返回
{ruleCode, severity, title, detail},ruleCode 用 PRD 的 VAL-xx 编号。
两段式提交:只有黄色警告时先返回 submitted=false 让学生确认,
再带 confirmWarnings=true 才真的提交。为什么不前端自己判断 ——
只有服务端的校验结果算数,两次请求之间学生可能又改坏了什么。
实现的规则:VAL-01 证件号 / VAL-05 材料完整性 / VAL-06 格式 /
VAL-07 体积 / VAL-08 清晰度 / VAL-09 重复文件 / VAL-13 报考三项 /
VAL-15 补考科目数按等级 / VAL-16 在校生三项 / VAL-17 工作年限 /
VAL-18 未规范化 / VAL-20 体积下限 / VAL-22 表单必填(**新增**:
PRD 的 VAL-01~21 是具体业务规则,没覆盖动态表单的必填字段,按系列顺延补的)。
═══ 本轮抓到的四个真问题(都记在代码注释里了)═══
① **校验器不能用列名当字段 key**。字段 key 是 snake_case
(work_unit / job_level / enroll_date),不是列名;而且报考三项
存在 ext_json 里、**不是独立列**(契约 §2.4)。写 camelCase 的表现是
"明明填了却说没填"。更糟的是 VAL-16 读列、VAL-22 读表单装配值 ——
**两条规则对同一份数据给出相反结论**,学生不知道该信哪一条。
现在统一用 FillService.getForm 装配出来的那一份作为唯一取值口径。
② **VAL-22 没判可见性**。不判可见就会要求一个在校生填「补考理论」——
而那个字段在他页面上根本不存在。求值口径已与前端 fill.vue 的 visible()
逐字对齐(norm 归一 true/false↔1/0、未知 op 一律可见)。
③ **VAL-01 读错了列**:collect_roster.id_card_no 是 **AES 密文**
(列注释写明的),明文只在导入与门禁时短暂存在。一开始拿它算校验位,
于是每一份填报单都被判"身份证号未通过校验" —— 100% 阻断。
这种"规则很对但读错列"的错,反而容易被当成"校验太严"而不是 bug。
现在只确认名册里有证件号(用脱敏列),校验位交给导入与门禁。
④ **静态资源没有 Cache-Control/ETag**(新增 StaticCacheHeaderFilter)。
没有它浏览器会走启发式缓存:旧 index.html → 旧 chunk →
**学生发版后看到的是上个月的页面**,而开发机硬刷新一次就没了,
极容易被当成"偶发"。现在 /assets/** 一年 immutable(文件名带内容哈希)、
*.html no-cache(每次校验)、/api/** no-store。
排查过程里还确认了一件事:材料上传那轮的"浏览器加载旧 chunk"
不是构建问题 —— 从 8080 下载的 chunk 与磁盘文件 SHA-256 一致、里面确实有新代码,
是缓存头缺失导致的浏览器行为。
验收:e2e-m3-submit.py **30/30**(含"该拦的确实拦了"和"修好后确实放过")。
回归:草稿 27 / 门禁 29 / 材料上传 29 / 材料目录 68 全绿。
This commit is contained in:
@@ -108,10 +108,50 @@
|
||||
|
||||
<view class="fill__bar">
|
||||
<text class="fill__save-hint">{{ saveHint }}</text>
|
||||
<button class="fill__btn" @click="doSubmit">提交审核</button>
|
||||
<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="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>
|
||||
|
||||
@@ -299,7 +339,7 @@ function token(): string {
|
||||
}
|
||||
}
|
||||
|
||||
async // ── 材料上传 ─────────────────────────────────
|
||||
// ── 材料上传 ─────────────────────────────────
|
||||
|
||||
interface Material {
|
||||
materialCode: string
|
||||
@@ -321,12 +361,61 @@ interface UploadedFile {
|
||||
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[]>>({})
|
||||
/** 已提交/已截止时不给增删材料(与后端同口径)。 */
|
||||
const filesDeletable = computed(() =>
|
||||
submissionId.value !== null && (submissionStatus.value ?? 0) === 0)
|
||||
/** 填报单状态: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'
|
||||
@@ -428,13 +517,71 @@ function removeFile(f: UploadedFile) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交按钮的入口。
|
||||
*
|
||||
* <p>⚠️ **不要给这个函数加参数**。模板里写的是 `@click="doSubmit"`,
|
||||
* 而 uni-app 会把事件对象作为第一个参数传进来 —— 如果签名是
|
||||
* `doSubmit(confirmWarnings = false)`,那个事件对象会被当成"已确认警告"(真值),
|
||||
* 于是有警告时会被静默强制提交。这类 bug 不会有任何报错。
|
||||
*/
|
||||
function doSubmit() {
|
||||
// 提交(含校验、红黄绿问题)是下一段。这里明确说清,而不是静默无反应
|
||||
uni.showModal({
|
||||
title: '提交功能开发中',
|
||||
content: '你填的内容已经自动保存为草稿。提交与校验属下一阶段开发。',
|
||||
showCancel: false
|
||||
})
|
||||
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) => {
|
||||
@@ -458,6 +605,7 @@ onLoad(async (options) => {
|
||||
})
|
||||
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> = {}
|
||||
@@ -466,6 +614,8 @@ onLoad(async (options) => {
|
||||
}
|
||||
values.value = v
|
||||
materials.value = (form.materials || []) as Material[]
|
||||
// 载入已传的材料:有草稿时学生上次传的还在,不拉一次他会以为白传了
|
||||
await loadFiles()
|
||||
|
||||
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 submissionId 为空而静默不存
|
||||
if (!submissionId.value) {
|
||||
@@ -553,5 +703,74 @@ page { background-color: $page-bg; }
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 校验问题清单(红黄绿)──
|
||||
&__mask {
|
||||
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 90;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user