# 日期组件手动输入实现方案 ## Context `src/components/ui/date` 下的 `date-picker.vue`(单选)与 `range-picker.vue`(区间)目前触发框是 `type="text" readonly`,值只能来自日历面板 `calendar-panel.vue` 的 `@pick`,用户无法键入。 目标:两个组件都支持手动键入,**始终开启**(不加 prop 开关)。 用户对「保持浏览器关于日期的语义」的说明是「主要是希望键盘操作流畅」,因此: - **保留 `type="text"`**,不改用原生 ``,以保住宅现有的 dayjs token 格式体系 (`YYYY年MM月DD日`、`YYYY/MM/DD`、`hh:mm A`、`format` 数组等,这些原生控件无法无损表达)。 - 用「自由整串输入 + ↑/↓ 微调当前段」模拟浏览器日期输入框的键盘手感: 整串任意编辑不被分段规则打断,↑/↓ 对光标所在段 ±1。 - 非法输入在失焦/回车时**回退到上次已提交的合法值**并短暂标记错误态,`modelValue` 永不出现非法值。 ## 键盘语义对照(本次核心契约) | 按键 | 现在 | 改后 | | --- | --- | --- | | 可打印字符 / Space | 无效(readonly);Space 被用于开面板 | 正常输入,不做任何拦截 | | `ArrowUp` / `ArrowDown` | ↓ 打开面板 | 光标所在段 ±1(年/月/日/时/分/秒/AM-PM);不打开面板 | | `Alt + ArrowDown` | — | 打开面板并把焦点交给日历网格(保留键盘可达性) | | `Enter` | 切换面板开合 | 提交当前文本并关闭面板 | | `Escape` | 关面板 | 还原为已提交文本;面板开着则一并关闭 | | `←/→/Home/End/Tab/Backspace` | 原生 | 不变 | ## 改动清单 ### 1. `src/components/ui/date/date-utils.js`(新增 3 组纯函数) **a. 光标 → 日期段** 把 `format` 编译成「全串锚定 + 具名捕获 + `d` 标志」的正则,捕获组按 `FORMAT_TOKENS` 顺序生成, 每个组记录自己的 `field`;非可编辑 token(`dddd`/`ZZ`/`Z` 等)编译为不透明片段 `.+?`, `[...]` 字面量按 dayjs 规则原样转义,空格放宽为 `\s+`(容忍手打多个空格)。 正则按 format 字符串做 `Map` 缓存。 段模式表(`SEGMENT_PATTERNS`): | token | field | 正则片段 | | --- | --- | --- | | `YYYY` | year | `\d{4}` | | `YY` | year | `\d{1,2}` | | `MM` `M` | month | `\d{1,2}` | | `MMM` `MMMM` | month | `[A-Za-z]+` | | `DD` `D` | day | `\d{1,2}` | | `Do` | day | `\d{1,2}(?:st\|nd\|rd\|th)?` | | `HH`/`H`/`hh`/`h` | hour | `\d{2}` / `\d{1,2}` | | `mm` `m` | minute | `\d{1,2}` | | `ss` `s` | second | `\d{1,2}` | | `SSS` | millisecond | `\d{1,3}` | | `A` `a` | meridiem | `[AaPp][Mm]` | > meridiem 用 `[AaPp][Mm]` 而非 `AM\|PM`,避免用户打成小写导致**整串**不匹配、连带失去所有段的微调能力。 导出: - `listDateSegments(text, format)` → `[{ field, start, end }]`(按 `start` 升序;不匹配返回 `[]`) - `resolveDateSegment(text, caret, format)` → `{ field, start, end } | null` 规则:取**最靠左的、闭区间 `[start, end]` 包含光标**的段。 闭区间 + 左优先 ⇒ 光标停在分隔符(`-`、`年`)上归左侧段;光标在文本末尾归最后一段;`caret` 越界时 clamp 到文本长度。 **b. 按段步进** ```js const SEGMENT_UNITS = { year:'year', month:'month', day:'date', hour:'hour', minute:'minute', second:'second', millisecond:'millisecond' } const WRAP_SIZES = { hour:24, minute:60, second:60 } export function stepDateSegment(value, field, delta = 1) { const base = toDayjs(value); if (!base) return null if (field === 'meridiem') return base.add(delta * 12, 'hour').toDate() const unit = SEGMENT_UNITS[field]; if (!unit) return base.toDate() const size = WRAP_SIZES[field] if (size) { const cur = base[field]() // hour() / minute() / second() return base[field]((((cur + delta) % size) + size) % size).toDate() } return base.add(delta, unit).toDate() } ``` 溢出语义(有意为之): - `year` / `month` / `day` 走 dayjs `add`:月末自动夹取(`2026-01-31 +1m → 2026-02-28`)、 闰日夹取(`2024-02-29 +1y → 2025-02-28`)、日跨月进位(`09-30 +1d → 10-01`)。 - `hour`/`minute`/`second` **回绕**而非进位(`14:59 +1min → 14:00`),避免微调时间时日期被悄悄推进到次日。 若产品后续要进位语义,删掉 `WRAP_SIZES` 分支即可。 **c. 文本 → 待提交值** ```js export function canonicalizeDateText(text) // 年月→'-'、日→' '、[./]→'-'、压缩空白、trim export function parseDateInput(text, format) // → { date, isEmpty, isValid } ``` - `''` / 纯空白 → `{ date:null, isEmpty:true, isValid:true }`(组件按「清空值」处理,不进错误态) - 无任何数字 → `isValid:false` - 解析两轮:`parseDate(raw, format)` → 失败再 `parseDate(canonicalizeDateText(raw), format)` (复用既有 `parseDate`,它已含严格多格式匹配 + 宽松 ISO 兜底) - `format` 传 `props.format` **原文**(不是 `primaryFormat`),让 `format` 数组的多格式容错也生效 | 输入 | format | 结果 | | --- | --- | --- | | `2026-09-26` | `YYYY-MM-DD` | 严格命中 | | `2026/09/26`、`2026.09.26` | `YYYY-MM-DD` | 宽松/归一化后命中 | | `2026年09月26日` | `YYYY-MM-DD` | canonicalize 后命中 | | `2026-09-26 14:30` | `YYYY-MM-DD HH:mm` | 严格命中 | | `2026-09` | `YYYY-MM-DD` | 命中,日补 1(与面板选月的归一化一致) | | `abc`、`2026-13-45` | 任意 | `isValid:false` → 回退 + 错误态 | 提交前的精度归一化仍由组件现有的 `normalizeDraft`(`normalizeDateForFormat`)+ `toOutgoing`(`formatDate(resolvedValueFormat)`)负责, `date-utils.js` 不再新增其它封装,保持单一职责。 ### 2. `src/components/ui/date/date-picker.vue` **状态**(加在 `panelRef` 之后) ```js const INVALID_STATE_DURATION = 1500 let invalidTimer = 0 /** null = 非编辑态(显示 displayText);字符串 = 用户正在编辑的原始文本 */ const editingText = ref(null) const invalid = ref(false) const inputDisplayText = computed(() => editingText.value ?? displayText.value) ``` `inputDisplayText` 是纯响应式派生 ⇒ **非编辑态**自动跟随外部 `modelValue`,**编辑态**自动忽略外部变化, 天然实现「打字过程中不被覆盖」,无需额外 `watch(modelValue)`。 **编辑态与错误态** - `beginEditing()`:`editingText ??= displayText`,并 `clearInvalid()` - `resetEditing()`:只把 `editingText` 置 null(**不清**错误态,保证回退后的红框能被看到) - `markInvalid()` / `clearInvalid()`:`invalid` 置真并起 1500ms 定时器自动清除;`clearInvalid` 清定时器 - `handleInput(e)`:只写 `editingText = e.target.value`,**绝不 emit**,并 `clearInvalid()` - `onBeforeUnmount` 清理 `invalidTimer` **提交 / 回退** ```js function submitInputText() { if (editingText.value === null) return const { date, isEmpty, isValid } = parseDateInput(editingText.value, props.format) if (isEmpty) { if (committedValue.value) commit(null); return } if (!isValid || !date) { markInvalid(); return } // 回退:不改 editingText const normalized = normalizeDraft(date) if (committedValue.value && compareDate(normalized, committedValue.value) === 0) return // 判等,避免重复 emit commit(normalized) } ``` **blur 的三种走向**(`relatedTarget` 用 `closest` 判断,Popover 浮层容器类名是 `.popover`,已确认) - 焦点落入面板(`closest('.popover')`)→ 让给 `handlePick` / `handleConfirm`,只 `resetEditing()` 不提交 - 焦点落入清除按钮(`closest('.fms-date-clear')`)→ 让给 `handleClear`,只 `resetEditing()` 不提交 - 其余(含点外部、Tab 离开)→ `submitInputText()` + `resetEditing()`(保留错误态) > Safari 点击面板按钮不会让输入框失焦,因此 `handlePick` / `handleConfirm` / `handleClear` 末尾也要显式 `resetEditing()` + `clearInvalid()`。 **`handleTriggerKeydown` 重写**(替换 `date-picker.vue#L104-L118`) ```js if (key === 'Escape') { preventDefault(); resetEditing(); clearInvalid(); open.value = false; return } if (key === 'Enter') { preventDefault(); submitInputText(); resetEditing(); open.value = false; return } if (key === 'ArrowUp' || key === 'ArrowDown') { if (event.metaKey || event.ctrlKey) return if (event.altKey) { preventDefault(); openAndFocusPanel(); return } // 保留日历键盘可达性 stepSegment(event, key === 'ArrowUp' ? 1 : -1) return } // 其余(含 Space、所有可打印字符)完全不拦截 ``` `Enter` 必须 `preventDefault()`,阻止 `