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

21 KiB
Raw Permalink Blame History

日期组件手动输入实现方案

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. 按段步进

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. 文本 → 待提交值

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 之后)

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

提交 / 回退

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)

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> 隐式提交。

↑/↓ 微调

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
  • 提交单端:
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 后:
.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),无需新增变量。

  1. 加 ::selection(本文件是脚本 import 的全局样式,非 scoped):输入框背景透明,默认选区色对比偏弱, 手打整串时选区可见性重要
  2. 不加 :focus-visible 额外轮廓:control-field 已 outline: none,聚焦可见性由 shell 的 :focus-within 3px 光环承担,再加会双重描边
  3. 不动 .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 关不掉,属净负收益。