Files
workspace/code/fms/.trae/documents/日期组件手动输入实现方案.md
T
2026-09-27 21:59:13 +08:00

376 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 日期组件手动输入实现方案
## 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 关不掉,属净负收益。