很多人看到 Odoo 的日期时间控件,第一反应会是:
- 一个文本输入框;
- 点开后弹出日期面板;
- 选完关闭。
如果只把它理解成“输入框 + 日历弹层”,你会错过几个非常关键的问题:
- 为什么一个普通
<input>能自动接上完整 picker 行为? - 为什么分钟粒度、秒显示与时间输入是一套联动配置?
- 日期范围模式下,焦点日期为什么不会乱跳?
- 为什么确认时既要看选中的日期,又要看当前 time picker 状态?
这些问题的答案分散在 datetime_input.js、datetime_picker_hook.js、datetime_picker.js 和 datetime_picker_popover.js 里,合起来才是完整机制。
一、DateTimeInput 自己几乎不做事,它只是把输入框交给 picker service 托管
DateTimeInput 这段源码最值得注意的不是复杂,而是克制。
组件本身只做两件事:
- 把与输入框自身相关的 props 拆出来;
- 调用
useDateTimePicker()把其余配置交给服务层。
关键代码是:
getPickerProps = () => omit(this.props, ...Object.keys(dateTimeInputOwnProps))useDateTimePicker({...})
这说明官方设计不是“每个输入组件自己实现打开/关闭/同步逻辑”,而是:
输入框只是入口,真正的交互生命周期交给
datetime_pickerservice 统一管理。
这种拆法很聪明,因为日期时间输入本来就很容易在不同视图里出现:表单、搜索、过滤器、弹窗、范围组件。若每个入口都自己维护弹层状态,迟早会散掉。
二、useDateTimePicker() 做的核心工作,是把原始 input ref 和 service 绑定起来
datetime_picker_hook.js 不长,但它决定了整条交互链的边界。
它先通过 useRef() 准备输入框引用,再构造 serviceParams:
getInputs()返回真实 DOM input;- 保留原始 params 的 getter;
- 增加
useOwlHooks: true。
随后调用:
useService("datetime_picker").create(serviceParams)
最后在组件销毁时执行 picker.disable()。
这几步连起来,实际表达的是:
- 输入框组件只提供 DOM 挂点;
- picker service 负责监听、打开 popover、同步值;
- 组件被卸载时,必须主动拆掉服务绑定,避免残留事件和悬挂弹层。
所以它不是一个“hook 帮你少写代码”的糖,而是输入框与全局 picker 服务之间的生命周期桥梁。
三、rounding 不是小参数,它决定时间输入的精度契约
DateTimeInput 在调用 hook 时会传:
showSeconds: this.props.rounding <= 0pickerProps.rounding
而 datetime_picker.xml 里的 TimePicker 又会使用:
minutesRounding="props.rounding"showSeconds="props.rounding === 0"
这说明 Odoo 对时间精度的设计不是零散判断,而是一份完整契约:
rounding > 1:分钟按粒度跳;rounding === 1:分钟不强制跳步,但不展示秒;rounding === 0:开启秒级输入。
也就是说,rounding 同时影响:
- 时间选择 UI 怎么显示;
- 用户可以精确到什么粒度;
onTimeChange()写回结果时会落在哪种精度级别。
这比很多业务项目里“前端随便取整、后端再纠错”的做法稳得多。
四、onPropsUpdated() 真正维护的是 picker 的内部状态机
datetime_picker.js 里最重要的方法之一是 onPropsUpdated()。
它在 props 变化时集中完成这些事情:
- 把传入值整理成
this.values; - 根据
minPrecision/maxPrecision生成允许缩放层级; - 解析
minDate/maxDate,并在type === "date"时扩展到整天边界; - 根据当前值生成
timeValues; - 决定是否需要重新调整焦点日期。
这一层特别关键,因为用户看到的是“一个 picker”,源码里实际上维护着多组状态:
- 当前选中值;
- 当前焦点月份;
- 当前 hover 日期;
- 当前时间输入值;
- 当前 precision(天 / 月 / 年 / decade)。
没有这层统一重算,范围选择、外部值更新和弹层重开时都很容易乱套。
五、日期与时间不是两条平行线,最后会在 validateAndSelect() 合并
很多二开控件的问题,是把日期选择和时间输入分成两套互不相干的状态。
Odoo 没这么做。
validateAndSelect() 里很明确:
- 先取当前日期值;
- 若类型是
datetime,再把state.timeValues[valueIndex]的hour/minute/second写回; - 再检查是否落在
minDate/maxDate范围内; - 最后统一触发
onSelect()。
这个顺序极其关键,因为它告诉你:
对 picker 来说,真正提交的不是“选中的日历格子”,而是“日期部分 + 当前时间部分”合并后的最终值。
所以如果你只盯着日期面板,不看 timeValues,很多边界都解释不通:
- 为什么改时间也会触发选择校验;
- 为什么范围模式里第二个时间可能默认比第一个晚一小时;
- 为什么某些日期格子能点,但最终仍可能因为边界限制被拒绝。
六、焦点日期稳定的关键,是 adjustFocus() 和 shouldAdjustFocusDate
日期范围控件最容易让用户烦躁的,是界面总自己跳月份。
Odoo 在这里做得很谨慎。
adjustFocus() 会优先选择:
- 当前 focus index 对应的值;
- 否则退回另一端值;
- 再不行就退回
today()。
而 shouldAdjustFocusDate 则控制“这次更新要不要重新对焦”。
这意味着 picker 不会因为每次状态变化都强行重置焦点。只有在真正需要的时候,才会把可视月份拉回更合理的位置。
这正是体验稳定的关键:
- 值更新要响应;
- 视图焦点又不能总跳。
七、popover 自身非常薄,但关闭时机被 hotkey 明确接管
datetime_picker_popover.js 看起来几乎没逻辑,只在 setup() 里做了一件事:
useHotkey("enter", () => this.props.close())
这很像小细节,但其实说明了两个设计判断:
- popover 不自行决定选值规则,它只是承载 picker;
- 关闭时机可以由统一热键系统接管,而不是写死在某个 input 事件上。
这样做的好处是,用户在键盘路径下也能完成一套可预测的提交动作,而不是只能依赖鼠标点击外部区域关闭。
八、二开最容易误解的几个点
误区 1:以为日期输入框本身负责大部分逻辑
实际上输入框只提供入口,picker service 才是主角。
误区 2:把 rounding 当视觉参数
它影响的不只是 UI,还影响可输入精度和最终值合并。
误区 3:范围模式只管两个日期,不管两个时间
源码里 timeValues 明确是按值索引维护的,范围模式下时间也是双份状态。
误区 4:外部 props 一变就该强制重置焦点月份
这样最容易造成“用户正在看 6 月,界面突然跳回今天”。
九、结论
Odoo 的日期时间输入之所以稳定,不是因为它“弹了一个好看的日历”,而是因为它把输入框、service、picker 状态和提交边界拆得非常清楚:
DateTimeInput负责把入口交给 service;useDateTimePicker()负责 DOM ref 与生命周期绑定;DateTimePicker负责精度、焦点、范围、时间值和最终校验;DateTimePickerPopover只做弹层承载和关闭交互。
所以更准确的理解应该是:
Odoo 的日期时间控件,本质上是一套“输入入口 + 状态机 + 提交合并”的前端协议,而不是一个单独的日历组件。
DISCUSSION
评论区