feat(h5): 填报页(三态 + 条件级联 + 失焦自动存草稿)

学生终于能在手机上真正填一份表了。

三态渲染(契约 §9.2):
  locked=true                → 只读灰底 + 「如有误请联系班主任」
  prefilled=true,locked=false → 可编辑 + 橙字「老师已填,请核对」
  其余                        → 普通空白
实测:姓名/身份证号/联系电话/报考职业/工种/等级 只读,学历可改并标注来源。

条件级联(visibleWhen)在前端求值,实测正确:
  「是否在校生=是」→ 在读学校/所学专业/入学时间 出现;
  「是否补考」未填 → 补考三科不出现。

**隐藏 ≠ 清空**:学生把「是否补考」改回"否",已填的补考科目仍留在草稿里
(万一他改回来)。要不要清空由后端在提交时判断 —— 前端不该替用户做不可逆的事。

保存策略:**失焦即存单个字段**。不做"整表提交"——学生填到一半切出去接电话,
回来整表丢失是最让人恼火的失败。单字段保存的代价是一次请求,
远小于"白填一遍"。
进页面时若还没有草稿会先建一份(否则第一次失焦保存会因 submissionId 为空而静默不存)。

前端拦住只读字段的保存请求(能省一次必然失败的请求),但**服务端那道拒绝才是真的**。

顺带:核验页的「开始填写材料」接上了(带 taskId + scene —— H5 跳转不保留 URL 参数)。

验收:构建产物含 pages-fill-fill;用无头浏览器实测渲染,
三态与两处级联均正确,抬头显示「请在 2026-10-15 前提交」。
This commit is contained in:
2026-09-29 11:43:45 +08:00
parent 5de9ccc100
commit 465700969a
3 changed files with 411 additions and 1 deletions
+8
View File
@@ -16,6 +16,14 @@
"navigationBarBackgroundColor": "#0052D9", "navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white" "navigationBarTextStyle": "white"
} }
},
{
"path": "pages/fill/fill",
"style": {
"navigationBarTitleText": "填写材料",
"navigationBarBackgroundColor": "#0052D9",
"navigationBarTextStyle": "white"
}
} }
], ],
"globalStyle": { "globalStyle": {
+385
View File
@@ -0,0 +1,385 @@
<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="(e) => setValue(f.key, e.detail.value)">
<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)" @blur="onBlur(f.key)" />
<view v-if="f.tip" class="fill__tip" :class="{ 'fill__tip--prefill': f.prefilled }">
{{ f.tip }}
</view>
</view>
</view>
<view class="fill__bar">
<text class="fill__save-hint">{{ saveHint }}</text>
<button class="fill__btn" @click="doSubmit">提交审核</button>
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
/**
* 填报页。
*
* <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
sections: Section[]
materials: unknown[]
submitPolicy?: { deadlineTip?: 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
}
function visible(f: Field): boolean {
const w = f.visibleWhen as { field?: string, op?: string, value?: unknown } | null
if (!w || !w.field) return true
if ((w.op ?? 'eq') !== 'eq') return true
return norm(values.value[w.field]) === norm(w.value)
}
const visibleSections = computed(() =>
sections.value
.map(s => ({ title: s.title, fields: s.fields.filter(visible) }))
.filter(s => s.fields.length > 0)
)
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)
}
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 ''
}
}
async function doSubmit() {
// 提交(含校验、红黄绿问题)是下一段。这里明确说清,而不是静默无反应
uni.showModal({
title: '提交功能开发中',
content: '你填的内容已经自动保存为草稿。提交与校验属下一阶段开发。',
showCancel: false
})
}
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
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
// 没有草稿就先建一份 —— 否则第一次失焦保存会因为 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; }
}
}
</style>
+18 -1
View File
@@ -152,6 +152,17 @@ function token(): string {
} }
} }
/** 当前 URL 里的 scene(在 hash 路由的 query 里;H5 跳转不保留参数)。 */
function sceneFromUrl(): string {
try {
const hash = typeof location !== 'undefined' ? location.hash : ''
const q = hash.includes('?') ? hash.slice(hash.indexOf('?') + 1) : ''
return new URLSearchParams(q).get('scene') || ''
} catch {
return ''
}
}
async function loadContext(scene: string) { async function loadContext(scene: string) {
if (!scene) return if (!scene) return
try { try {
@@ -194,7 +205,13 @@ async function submit() {
} }
function next() { function next() {
uni.showToast({ title: '填写材料功能开发中', icon: 'none' }) // 带着 scene 与 taskId 跳到填报页 —— H5 的页面跳转不保留 URL 参数,
// 所以显式拼(同 index → verify 的处理)
const scene = sceneFromUrl()
const tid = ctx.value.taskId || 0
uni.navigateTo({
url: `/pages/fill/fill?taskId=${tid}&scene=${encodeURIComponent(scene)}`
})
} }
onLoad((options) => { onLoad((options) => {