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:
@@ -16,6 +16,14 @@
|
|||||||
"navigationBarBackgroundColor": "#0052D9",
|
"navigationBarBackgroundColor": "#0052D9",
|
||||||
"navigationBarTextStyle": "white"
|
"navigationBarTextStyle": "white"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/fill/fill",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "填写材料",
|
||||||
|
"navigationBarBackgroundColor": "#0052D9",
|
||||||
|
"navigationBarTextStyle": "white"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user