diff --git a/.gitignore b/.gitignore index 5dad77b..8f1e38a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ yarn.lock # 本地备份文件(不入库,如 icons.css.bak 为旧版图标集) *.bak *.orig + +# AI agent workspace (local only) +.workbuddy/ diff --git a/docs/宿舍通知模块-功能与接口说明-v1.md b/docs/宿舍通知模块-功能与接口说明-v1.md index 2cf6438..a976b75 100644 --- a/docs/宿舍通知模块-功能与接口说明-v1.md +++ b/docs/宿舍通知模块-功能与接口说明-v1.md @@ -254,7 +254,12 @@ 这些是模板项目已经踩过并写进注释的坑,本模块原样继承: 1. **`` 必须配 `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()` 清掉其它字段的残留红字。 diff --git a/src/views/dorm/NewNotice.vue b/src/views/dorm/NewNotice.vue index 6f96f8f..e88e74b 100644 --- a/src/views/dorm/NewNotice.vue +++ b/src/views/dorm/NewNotice.vue @@ -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) { diff --git a/src/views/dorm/ParentNotices.vue b/src/views/dorm/ParentNotices.vue index d5437a7..31c63d1 100644 --- a/src/views/dorm/ParentNotices.vue +++ b/src/views/dorm/ParentNotices.vue @@ -220,6 +220,36 @@ function resetFillKeys() { }); } +/* ---------- 日期字段的类型归一 ---------- */ +/** + * 日期字段两端类型不一致,必须归一,否则校验必然打架: + * - 控件侧:`DatePicker` 的 v-model 拿到的是 **Date 对象**(picker.vue 的 publicVModelValue 原样 emit); + * - 后端侧:`fillData` 是 `Map`,回填给的是 `"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 = { guardianName: [{ required: true, message: "请输入家长姓名", trigger: "blur" }], @@ -255,6 +287,24 @@ const fillRules: Record = { }; /** 选择类控件(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) + // 后端 fillData 为 Map,逐字段按控件类型序列化: + // 日期字段转 yyyy-MM-dd / yyyy-MM-dd HH:mm(不能直接 String(Date)),其余原样转串 const fillData: Record = {}; 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({