376 lines
21 KiB
Markdown
376 lines
21 KiB
Markdown
# 日期组件手动输入实现方案
|
||
|
||
## Context
|
||
|
||
`src/components/ui/date` 下的 `date-picker.vue`(单选)与 `range-picker.vue`(区间)目前触发框是
|
||
`type="text" readonly`,值只能来自日历面板 `calendar-panel.vue` 的 `@pick`,用户无法键入。
|
||
|
||
目标:两个组件都支持手动键入,**始终开启**(不加 prop 开关)。
|
||
|
||
用户对「保持浏览器关于日期的语义」的说明是「主要是希望键盘操作流畅」,因此:
|
||
|
||
- **保留 `type="text"`**,不改用原生 `<input type="date">`,以保住宅现有的 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()`,阻止 `<form>` 隐式提交。
|
||
|
||
**↑/↓ 微调**
|
||
|
||
```js
|
||
function stepSegment(event, delta) {
|
||
const el = event.currentTarget
|
||
const format = pickerConfig.value.primaryFormat
|
||
const text = editingText.value ?? displayText.value
|
||
const caret = typeof el?.selectionStart === 'number' ? el.selectionStart : text.length
|
||
const segment = resolveDateSegment(text, caret, format)
|
||
if (!segment) return // 光标不在任何段上:不拦截,走原生行为
|
||
|
||
event.preventDefault()
|
||
const parsed = parseDateInput(text, props.format)
|
||
const base = (parsed.isValid && parsed.date) || committedValue.value || normalizeDraft(new Date())
|
||
const next = stepDateSegment(base, segment.field, delta)
|
||
if (!next) return
|
||
|
||
const nextText = formatDate(normalizeDraft(next), format)
|
||
if (nextText === text) return
|
||
|
||
const target = listDateSegments(nextText, format).find((s) => s.field === segment.field)
|
||
const pos = target ? target.end : Math.min(caret, nextText.length)
|
||
editingText.value = nextText
|
||
clearInvalid()
|
||
if (open.value) draftValue.value = normalizeDraft(next) // 面板同步预览
|
||
nextTick(() => el?.setSelectionRange(pos, pos)) // 光标停在改动段末尾
|
||
}
|
||
```
|
||
|
||
用 `nextTick` 恢复光标,不依赖 Vue 的 DOM patch 细节(`:value` 变短会重置光标到末尾,`nextTick` 里再修正)。
|
||
|
||
**watch 调整**
|
||
|
||
- `watch(open)` 不动(只维护 `draftValue`,与 `editingText` 解耦)
|
||
- `watch(() => props.disabled)` 追加 `resetEditing()` + `clearInvalid()`
|
||
- `watch(pickerConfig)` 开头追加 `resetEditing()` + `clearInvalid()`
|
||
|
||
**模板**
|
||
|
||
- 根节点 class 增加 `'is-invalid': invalid`
|
||
- 输入框:去掉 `readonly`,保留 `type="text"`;加 `autocomplete="off"` `spellcheck="false"`
|
||
`:aria-invalid="invalid || undefined"`,`:value="inputDisplayText"`(**受控 text,不用 v-model**——`v-model` 会把非法文本写回 `modelValue`)
|
||
- 加 `@input="handleInput"` `@focus="beginEditing"` `@blur="handleBlur"` `@click.stop="handleInputClick"`
|
||
- `handleInputClick()`:`beginEditing()` + 面板未开则 `open.value = true`
|
||
⇒ 「点输入框 = 打开面板 + 光标落位 + 可继续打字」,第二次点击只移动光标不关面板
|
||
- `@click.stop` 不影响 `focusout`(不同事件),`form-item.vue#L157-L174` 的失焦校验链路不变
|
||
- 外层 `.fms-date-trigger` 的 `@click="toggle"` 保留(点外壳空白/后缀仍可开合面板)
|
||
|
||
### 3. `src/components/ui/date/range-picker.vue`
|
||
|
||
结构对称,差异点:
|
||
|
||
- 状态:`editingStart` / `editingEnd`(各自 `null` = 非编辑态)、`invalidSide`(`'' | 'start' | 'end'`)、
|
||
共享一个 `invalidTimer`;`invalid = computed(() => invalidSide.value !== '')`
|
||
⇒ 错误**只标在出错的那一端**(`:aria-invalid` 分端判断),但红框画在共享外壳上
|
||
- 辅助函数 `editorOf(side)` / `committedOf(side)`;`beginEditing(side)` / `resetEditing(side)` /
|
||
`submitSide(side)` 都以 `side` 为参数,避免两套重复代码
|
||
- 每个输入框独立绑定 `@click.stop` / `@input` / `@focus` / `@blur` / `@keydown`,都带 `side`
|
||
- 提交单端:
|
||
|
||
```js
|
||
function submitSide(side) {
|
||
const editor = editorOf(side)
|
||
if (editor.value === null) return
|
||
const { date, isEmpty, isValid } = parseDateInput(editor.value, props.format)
|
||
let next = null
|
||
if (!isEmpty) {
|
||
if (!isValid || !date) { markInvalid(side); return } // 回退:不改 editor
|
||
next = normalizeDraft(date)
|
||
}
|
||
let start = side === 'start' ? next : committedStart.value
|
||
let end = side === 'end' ? next : committedEnd.value
|
||
if (start && end && compareDate(start, end) > 0) [start, end] = [end, start] // 与 orderDraftRange 同语义
|
||
if (sameDatePoint(start, committedStart.value) && sameDatePoint(end, committedEnd.value)) return
|
||
commit(start, end) // 一次 emit 完整字符串数组
|
||
if (open.value) { draftStart.value = start; draftEnd.value = end } // 面板高亮跟随
|
||
}
|
||
```
|
||
|
||
`orderDraftRange()`(`range-picker.vue#L75-L81`)保持原样专供面板 `handlePick`/`handleConfirm` 使用。
|
||
|
||
- `commit(start, end)` 里的 `toOutgoing(null)` 已返回 `''`,因此「两端都清空」自然 emit `['', '']`,
|
||
与 `handleClear` 产出完全一致,`hasValue` 变 false、清除按钮的 `v-if` 自然消失,无需额外特判
|
||
- `handlePick` / `handleConfirm` / `handleClear` 末尾追加 `resetEditing('start')` + `resetEditing('end')` + `clearInvalid()`
|
||
- `handleStartChange` / `handleEndChange`(面板时间选择回调)保持不动
|
||
|
||
**Tab 链路契约**:点开始框 → 输入 → Tab 到结束框
|
||
① 输入只写 `editingStart`,不 emit;② 开始框 blur,`relatedTarget` 是结束框 ⇒ 两个 defer 判断都为 false ⇒
|
||
`submitSide('start')` 提交 `['2026-09-26', '']` ⇒ `resetEditing('start')`;
|
||
③ 结束框 focus ⇒ `beginEditing('end')` 预填已提交文本;④ 结束框 blur 时读已回流的 `committedStart`,排序后一次 emit。
|
||
**注意不要把「焦点转移到同组件的兄弟输入框」误判为 defer**,否则 Tab 链路不会提交。
|
||
|
||
### 4. `src/components/ui/date/index.scss`
|
||
|
||
1. `.fms-date-input, .fms-range-input`(`#L38-L42`)的 `cursor: pointer` 改 `cursor: text`
|
||
(外层 `.fms-date-trigger` 的 `cursor: pointer` 保留;禁用态 `#L111-L114` 权重更高,仍是 `not-allowed`)
|
||
2. 错误态红框画在 **shell** 上(input 是透明内嵌、无边框),插在 `#L36` 后:
|
||
|
||
```scss
|
||
.fms-date-picker.is-invalid .fms-date-trigger,
|
||
.fms-range-picker.is-invalid .fms-range-trigger {
|
||
border-color: var(--fms-danger);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-danger) 20%, transparent), var(--fms-shadow);
|
||
}
|
||
```
|
||
|
||
权重 `(0,3,0)` 高于 `:focus-within` 的 `(0,2,0)`,编辑中的聚焦光环不会盖住错误提示。
|
||
`--fms-danger` 已存在于 `src/theme/tokens.css`(亮 `#ef4444` / 暗 `#f87171`),无需新增变量。
|
||
|
||
3. 加 `::selection`(本文件是脚本 import 的全局样式,非 scoped):输入框背景透明,默认选区色对比偏弱,
|
||
手打整串时选区可见性重要
|
||
4. **不加** `:focus-visible` 额外轮廓:`control-field` 已 `outline: none`,聚焦可见性由 shell 的
|
||
`:focus-within` 3px 光环承担,再加会双重描边
|
||
5. **不动** `.fms-range-input { text-align: center }`(避免视觉回归,如需改左右对齐另行确认)
|
||
|
||
### 5. 测试
|
||
|
||
新建以下 spec(现有范式照抄 `tests/components/select.spec.js`:Popover 不 mock、内容 Teleport 到 body、
|
||
`attachTo: document.body`、`document.body.querySelector('.popover-content')` 取浮层、末尾 `wrapper.unmount()`):
|
||
|
||
**`tests/unit/date-utils.spec.js`**(纯函数,无需 DOM)
|
||
|
||
- `resolveDateSegment`:`2026-09-26` / `2026/09/26` / `2026年09月26日` 的段区间;
|
||
含时分的 `2026-09-26 14:30:00`;`hh:mm A` 下 `02:30 PM` 的 meridiem 段覆盖整段 `PM`;
|
||
`Do` 的 `26th`;`MMM` 的 `Sep`;`YYYYMMDD` 相邻段相接时边界归左段;
|
||
文本与 format 不匹配 / 空文本返回 `null`;`caret` 越界被 clamp
|
||
- `stepDateSegment`:`day +1` 跨月、`month +1` 月末夹取、`year +1` 闰日夹取、
|
||
`hour/minute/second` 回绕且日期不变、`hour 00 -1 → 23`、`meridiem ±1` 只翻转上下午、非法 field / 非法日期
|
||
- `parseDateInput`:空串/纯空白 → `isEmpty`;`2026-09-26`、`2026/09/26`、`2026.09.26`、
|
||
`2026年09月26日`、`2026-09`、`abc`、`2026-13-45`、`format` 数组多格式
|
||
|
||
**`tests/components/date-picker.spec.js`**(宿主用真 `v-model`,才能验证 emit 出字符串并回流)
|
||
|
||
- 输入框不再 `readonly`,`type` 仍为 `text`
|
||
- 键入合法整串 + blur → emit `'2026-09-26'`
|
||
- Enter 提交并关闭面板
|
||
- `format='YYYY-MM-DD HH:mm'` + `valueFormat='YYYY-MM-DD HH:mm:ss'`,输入 `2026-09-26 14:30`
|
||
→ emit 秒级字符串 `'2026-09-26 14:30:00'`
|
||
- 非法输入 blur → **不 emit**,文本回到已提交值,外壳 `is-invalid` + `aria-invalid="true"`
|
||
(`vi.useFakeTimers()` + `advanceTimersByTime(1500)` 断言自动清除)
|
||
- 文本等同已提交值时 blur 不重复 emit(`emitted('update:modelValue')` 长度为 0)
|
||
- `ArrowUp` 在年份段 → `2027-09-26`;`ArrowDown` 在月份段/日段(跨月);时分秒段只改当前段且日期不变
|
||
- 光标落在分隔符上按 ↑/↓ 不改文本
|
||
- Escape 还原并关面板(面板开 / 关两种)
|
||
- Space 作为普通字符输入,不打开面板
|
||
- 点击 trigger 打开面板且可继续输入;面板点选后输入框显示新值、错误态清空;清除按钮仍 emit `''`
|
||
- `disabled` 下不进入错误态、keydown/input 均不改变状态
|
||
- `format` 数组下任一写法都能提交
|
||
|
||
**`tests/components/range-picker.spec.js`**
|
||
|
||
- 两端都不再 `readonly`;只编辑开始端 → `['2026-09-26', '']`;只编辑结束端 → `['', '2026-09-26']`
|
||
- 两端成序:输入 `start > end` 后 emit 为有序区间
|
||
- 开始端非法 → 不 emit,`aria-invalid` 只出现在开始端
|
||
- Tab 链路:开始端输入 → blur(`relatedTarget` = 结束端)提交 → 结束端 focus 预填已提交文本
|
||
- 两端清空后 emit `['','']`,清除按钮消失;Escape 只还原当前聚焦端
|
||
- 两端各自独立的 ↑/↓ 微调;datetime 端点提交出秒级字符串
|
||
- 回归:面板选值 + 确定仍提交数组
|
||
|
||
跑测试:`npx vitest run tests/components/date-picker.spec.js`(或 `npm run test:file -- <path>`),
|
||
最后跑一次 `npm run test` 确认无回归。
|
||
|
||
### 6. 验证(端到端)
|
||
|
||
1. `npm run dev`,打开 `/demo` 的 DatePicker 区块(`src/components/ui/demo/date.vue`),逐个验证:
|
||
`YYYY-MM-DD`、`YYYY/MM/DD`、`format` 数组、`YYYY-MM-DD HH:mm` + `valueFormat` 秒级、RangePicker 两个例子
|
||
2. 手测键盘:整串输入、↑/↓ 微调各段、Enter 提交、Esc 还原、Alt+↓ 打开面板后方向键在日历中移动
|
||
3. 手测业务路径:模块列表查询区(`FmsQueryControl`)日期区间手打开始日期,确认结束端日历禁用范围立刻收窄;
|
||
编辑弹窗(`FmsModuleEditModal`)datetime 字段手打后提交,确认存的是 `YYYY-MM-DD HH:mm:ss`
|
||
4. 手测表单:必填日期留空失焦仍报红(`form-item.vue` 的 `@focusout` 链路未变);日期框内 Enter 不触发整表提交
|
||
5. 手测弹窗内日期框按 Esc 仍能关闭外层 Modal(我们**不**对 Escape 调 `stopPropagation`)
|
||
|
||
## 已知边界(写进代码注释,不修)
|
||
|
||
1. **整串不匹配则全串无段**:如 format `YYYY年MM月DD日` 而用户敲了 `2026-09-26`,`resolveDateSegment`
|
||
返回 `null` ⇒ ↑/↓ 走原生光标行为。Enter/blur 仍能提交(`parseDateInput` 走宽松解析)。
|
||
同理缺失尾部字段(`2026-09-26 14` 对带秒 format)也拿不到段;但 `2026-09-2`、`2026-09-26 14:3` 这类
|
||
「位数不足但字段齐全」的中间态可以正常匹配。
|
||
2. **无字面量分隔的相邻段**(`YYYYMMDD` → `20260926`):段边界相接,按左优先归属,边界位归左段。
|
||
3. `MMM/MMMM` 用 `[A-Za-z]+` 而非枚举月名,不使用 locale(英文匹配)。
|
||
4. **受控组件契约**:父级必须监听 `update:modelValue`。若只传 `:model-value` 而不回写,提交后输入框会
|
||
回落到 props 旧值 —— 与现有 `handleClear` 的行为一致,属既有契约。
|
||
5. `hour/minute/second` 采用**回绕**而非进位(见 `stepDateSegment`)。
|
||
6. `2026-09-26 14:30:00` 提交到 `format='YYYY-MM-DD HH:mm'` 时秒会被 `normalizeDateForFormat` 抹掉,
|
||
这是既有归一化契约(面板选值同样如此);要保留秒须把 `format` 设为带秒格式。
|
||
|
||
## Escape 与 Popover 全局 Escape
|
||
|
||
`popover.vue#L293-L295` 在**打开期间**于 `document` 上监听 `keydown`,按 Escape 直接 `setOpen(false)`。
|
||
|
||
**结论:不需要 `stopPropagation`,两条路径天然收敛。** 事件顺序:输入框 `@keydown`(target 阶段)先跑,
|
||
我们 `preventDefault()` + 还原文本 + `open.value = false`;事件继续冒泡到 `document` 时 Popover 再执行
|
||
`setOpen(false)`,父级 `open` 本就是 `false`,结果幂等。面板未打开时 Popover 根本没注册监听。
|
||
即使未来 Popover 改成捕获阶段先执行,行为也不变。
|
||
|
||
**不要**对 Escape 调 `stopPropagation()`:它会截断祖先链路(外层 Modal 的 Escape 关闭、表格编辑单元格的
|
||
Escape 取消),在日期框内按 Esc 会导致 Modal 关不掉,属净负收益。
|