fix(回执表单): 日期类专属区字段按类型分派校验,修「填完仍提示必填」
build / build (push) Successful in 9m1s

家长端回执弹窗的专属区动态规则此前只写 required + trigger、没给 type。
async-validator 的 getValidationMethod 仅在规则「除掉 message 后只剩 required」
时才走 required 校验器;带上 trigger 后会回退到默认 string 校验器,而 DatePicker
的 v-model 给出的是 Date 对象,被判类型不符。真正的报错又在 cb() 里被自定义
message 顶掉,界面上只剩「请填写XX」——表现为字段明明填了却不让提交。

- ParentNotices.vue: 新增 ruleTypeOf() 统一映射规则类型
  (number -> type:"number",date/datetime -> type:"date",其余保持默认 string)。
  注意 async-validator 没有 datetime 类型,写上去会导致 validate 抛异常。
- ParentNotices.vue: 日期字段回填统一转 Date(本地时间语义,避免 new Date("yyyy-MM-dd")
  的 UTC 偏移),提交时序列化成 yyyy-MM-dd / yyyy-MM-dd HH:mm。
  这同时修掉一处数据污染:此前 String(Date) 会把
  "Tue Sep 22 2026 10:33:00 GMT+0800 (中国标准时间)" 这类 locale 脏串写进 fillData。
- NewNotice.vue: 定时发布时间弹窗的规则存在同类问题,补 type:"date"。
- 覆盖四类回执单:stay / visit / adjust 的日期字段曾坏,general 及公共区字段本就正常。

.gitignore 追加 .workbuddy/(本地 AI 工作目录,不入库)。
This commit is contained in:
lijing
2026-09-22 11:01:12 +08:00
parent 580b05a5eb
commit 7daf3502bd
4 changed files with 71 additions and 11 deletions
+3
View File
@@ -31,3 +31,6 @@ yarn.lock
# 本地备份文件(不入库,如 icons.css.bak 为旧版图标集)
*.bak
*.orig
# AI agent workspace (local only)
.workbuddy/
@@ -254,7 +254,12 @@
这些是模板项目已经踩过并写进注释的坑,本模块原样继承:
1. **`<FormItem required>` 必须配 `prop`** —— 否则字段名被解析成 `undefined`,model 取到 `undefined`,家长**填了值也照样报错**、表单永远提交不了。
2. **数字字段规则必须显式写 `type: "number"`** —— async-validator 只在规则「除掉 message 后只剩 required」时走 required 校验器;一旦带 `trigger`,会回退到 `type: "string"`,`InputNumber` 给出的数字被判类型不符,错误文案还被自定义 message 掩盖,极具误导性。
2. **非 string 取值的控件,规则里必须显式写 `type`** —— async-validator 只在规则「除掉 message 后只剩 required」时走 required 校验器;一旦带 `trigger`,会回退到 `default validators[type || 'string']`,再拿 `typeof value !== 'string'` 判类型不符,而**真实报错会被自定义 `message` 覆盖**,于是「明明填了值,红字仍显示『请填写XX』、表单交不上去」,极具误导性。
- `InputNumber` → `type: "number"`;
- `DatePicker` → **`type: "date"`**(v-model 给的是 `Date` 对象;async-validator **没有 `datetime` 这个类型**,写上去 `getType()` 直接抛 `Unknown rule type datetime`,整个 validate 崩掉,被 `.catch(() => false)` 吞成「提交失败」);
- `Select`/`Input`/`Textarea` → string,默认即可。
- 配套:日期字段在模型里统一存 `Date`、提交时再格式化成 `yyyy-MM-dd` / `yyyy-MM-dd HH:mm`(本地时间手工拼,别 `new Date("2026-09-23")` —— 按 UTC 解析会跑日期),否则 `String(Date)` 会提交成 `"Tue Sep 22 2026 10:33:00 GMT+0800 (中国标准时间)"`,回执名单与导出 CSV 全废。
- 30 秒定位法:在 node 里用项目自己的 `node_modules/async-validator` 复刻 `FormItem.validate()`(`descriptor[prop] = rules`、`model[prop] = fieldValue`、`{ firstFields: true }`),逐个类型跑一遍,比读代码猜快得多。
3. **表单模型是稳定的 `reactive` 对象,不能写成 `computed(() => ({...}))`** —— 每次依赖变化生成新对象,而 `FormItem` 校验读的是 `Form.model`,props 要等父组件重渲染才更新,同步 change 校验拿到的还是旧对象(「选了却红字不消失」)。
4. **`rules` 写成普通常量/`computed` 要分清场景** —— Form 内有 `watch: { rules () { this.validate() } }`,引用一变就全量重校验。
5. **存草稿只校验标题**,并用 `clearFieldState()` 清掉其它字段的残留红字。
+4 -1
View File
@@ -341,7 +341,10 @@ const timeModal = reactive({ open: false, date: "" });
/** 定时发布弹窗表单(同样需要 :model + prop 才能正常校验) */
const timeFormRef = ref();
const timeRules = {
date: [{ required: true, message: "请选择定时发布时间", trigger: "change" }],
// 必须显式写 type:"date":DatePicker 给的是 Date 对象,规则带 trigger 时 async-validator
// 会退化成默认的 string 校验器,把 Date 判成「不是字符串」;又因带自定义 message,
// 报错文案被覆盖成「请选择定时发布时间」,看起来像「选了时间还提示必选、发不出去」
date: [{ required: true, type: "date", message: "请选择定时发布时间", trigger: "change" }],
};
const dateOpt = { disabledDate: (d: Date) => d && d.valueOf() < Date.now() - 86400000 };
function fmt(d: any) {
+58 -9
View File
@@ -220,6 +220,36 @@ function resetFillKeys() {
});
}
/* ---------- 日期字段的类型归一 ---------- */
/**
* 日期字段两端类型不一致,必须归一,否则校验必然打架:
* - 控件侧:`DatePicker` 的 v-model 拿到的是 **Date 对象**(picker.vue 的 publicVModelValue 原样 emit);
* - 后端侧:`fillData` 是 `Map<String,String>`,回填给的是 `"2026-09-22"` 字符串。
* 口径:**模型里统一存 Date**(与控件输出一致,校验规则才好写),提交时再格式化成字符串。
*/
function toDate(v: any): Date | undefined {
if (v === undefined || v === null || v === "") return undefined;
if (v instanceof Date) return isNaN(v.getTime()) ? undefined : v;
const m = String(v).match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
if (!m) return undefined;
// 手工拼本地时间:`new Date("2026-09-23")` 按 UTC 解析,时区一偏日期就会跑掉
return new Date(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/**
* 提交口径:date → `yyyy-MM-dd`,datetime → `yyyy-MM-dd HH:mm`。
* 不能直接 `String(value)` —— Date 会被序列化成
* `"Tue Sep 22 2026 10:33:00 GMT+0800 (中国标准时间)"`,回执名单与导出 CSV 全废。
*/
function fmtDateValue(v: any, type: FillField["type"]): string {
const d = toDate(v);
if (!d) return "";
const day = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
return type === "datetime" ? `${day} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` : day;
}
/** 是否日期类字段(date / datetime 在模型里都存 Date) */
const isDateType = (t: FillField["type"]) => t === "date" || t === "datetime";
/** 把已提交的 fillData 回填到表单(未提交过则留空) */
function prefillFillForm() {
const my = detail.value?.my;
@@ -227,7 +257,8 @@ function prefillFillForm() {
syncFillRules();
curFields.value.forEach(f => {
const v = my?.fillData?.[f.key];
fillModal[f.key] = f.type === "number" && v !== undefined && v !== "" ? Number(v) : v ?? "";
if (isDateType(f.type)) fillModal[f.key] = toDate(v);
else fillModal[f.key] = f.type === "number" && v !== undefined && v !== "" ? Number(v) : v ?? "";
});
fillModal.guardianName = my?.guardianName || "";
fillModal.guardianPhone = my?.guardianPhone || "";
@@ -244,7 +275,8 @@ function prefillFillForm() {
* 原地改键不会改引用,Form 不会触发全量重校验。
*
* 另两条硬约束:Form 必须绑 :model 且 FormItem 必须有 prop(否则字段名解析成 undefined,
* 填了值也照样报错);数字字段要显式 type:"number"(async-validator 默认按 string 校验)。
* 填了值也照样报错);非 string 取值的控件(number / DatePicker)规则里要显式写 type,
* 见下方 `ruleTypeOf()`。
*/
const fillRules: Record<string, any[]> = {
guardianName: [{ required: true, message: "请输入家长姓名", trigger: "blur" }],
@@ -255,6 +287,24 @@ const fillRules: Record<string, any[]> = {
};
/** 选择类控件(Select / DatePicker / InputNumber)派发的是 change,其余按 blur */
const CHANGE_TRIGGER_TYPES = ["select", "date", "datetime", "number"];
/**
* 规则里**必须显式声明 `type`**,否则 async-validator 会退化成默认的 `string` 校验器。
*
* 原理:`getValidationMethod()` 只在规则「除掉 message 后只剩 required」时才走 required 校验器;
* 一旦带上 `trigger`,就回退到 `validators[rule.type || 'string']`,然后 `rules.type` 拿
* `typeof value !== 'string'` 判定类型不符 —— 而规则一旦带自定义 `message`,真实报错会被
* `rule.message` 覆盖,于是**家长明明填了值,红字仍显示「请填写XX」、表单死活交不上去**。
*
* 各控件的取值类型:`Input`/`Select`/`Textarea` → string(默认即可);
* `InputNumber` → number(`type: "number"`);`DatePicker` → **Date 对象**(`type: "date"`)。
* 注意 async-validator 只有 `date` 没有 `datetime`,写 `datetime` 会直接抛 `Unknown rule type`,
* 所以 datetime 也用 `date` 承接(该校验器对 Date 对象与 `yyyy-MM-dd[ HH:mm]` 字符串都放行)。
*/
function ruleTypeOf(type: FillField["type"]) {
if (type === "number") return { type: "number" as const };
if (isDateType(type)) return { type: "date" as const };
return {};
}
/** 按当前单据的字段清单原地同步规则(保持对象引用不变) */
function syncFillRules() {
Object.keys(fillRules).forEach(k => {
@@ -264,11 +314,7 @@ function syncFillRules() {
const arr: any[] = [];
if (f.required) {
const trigger = CHANGE_TRIGGER_TYPES.includes(f.type) ? "change" : "blur";
arr.push(
f.type === "number"
? { required: true, type: "number", message: `请填写${f.label}`, trigger }
: { required: true, message: `请填写${f.label}`, trigger }
);
arr.push({ required: true, ...ruleTypeOf(f.type), message: `请填写${f.label}`, trigger });
}
fillRules[f.key] = arr;
});
@@ -298,11 +344,14 @@ async function doFill() {
}
fillModal.submitting = true;
try {
// 数值字段统一转字符串提交(后端 fillData 为 Map<String,String>)
// 后端 fillData 为 Map<String,String>,逐字段按控件类型序列化:
// 日期字段转 yyyy-MM-dd / yyyy-MM-dd HH:mm(不能直接 String(Date)),其余原样转串
const fillData: Record<string, string> = {};
curFields.value.forEach(f => {
const v = fillModal[f.key];
if (v !== undefined && v !== null && String(v) !== "") fillData[f.key] = String(v);
if (v === undefined || v === null || v === "") return;
const s = isDateType(f.type) ? fmtDateValue(v, f.type) : String(v);
if (s !== "") fillData[f.key] = s;
});
await noticeApi
.submitReceipt({