diff --git a/client/lib/core/utils/date_util.dart b/client/lib/core/utils/date_util.dart index de6b6d2..cfeeb49 100644 --- a/client/lib/core/utils/date_util.dart +++ b/client/lib/core/utils/date_util.dart @@ -20,3 +20,41 @@ String? composeYmd(int? year, int? month, int? day) { final d = day > maxDay ? maxDay : day; return formatYmd(DateTime(year, month, d)); } + +/// 归一用户键入的日期为 `yyyy-MM-dd`。支持「2024」「2024-5」「2024-5-12」 +/// 「20240512」「2024/05/12」等;缺月/日补 01(老酒只知年份,填 2024 即 +/// 2024-01-01),超出当月的日 clamp 回当月最大值。无有效年(1900–2200)返回 null。 +String? normalizeYmd(String raw) { + final s = raw.trim(); + if (s.isEmpty) return null; + int? y, mo, d; + if (RegExp(r'^[0-9]+$').hasMatch(s)) { + // 纯数字串:yyyymmdd / yyyymm / yyyy + if (s.length >= 8) { + y = int.tryParse(s.substring(0, 4)); + mo = int.tryParse(s.substring(4, 6)); + d = int.tryParse(s.substring(6, 8)); + } else if (s.length == 6) { + y = int.tryParse(s.substring(0, 4)); + mo = int.tryParse(s.substring(4, 6)); + } else { + y = int.tryParse(s); + } + } else { + final parts = + s.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList(); + if (parts.isNotEmpty) y = int.tryParse(parts[0]); + if (parts.length >= 2) mo = int.tryParse(parts[1]); + if (parts.length >= 3) d = int.tryParse(parts[2]); + } + if (y == null || y < 1900 || y > 2200) return null; + mo ??= 1; + d ??= 1; + if (mo < 1 || mo > 12) return null; + final maxDay = DateTime(y, mo + 1, 0).day; + if (d < 1) d = 1; + if (d > maxDay) d = maxDay; + return '${y.toString().padLeft(4, '0')}-' + '${mo.toString().padLeft(2, '0')}-' + '${d.toString().padLeft(2, '0')}'; +} diff --git a/client/lib/screens/shared/order_form_shell.dart b/client/lib/screens/shared/order_form_shell.dart index 2a2c988..3e5ef85 100644 --- a/client/lib/screens/shared/order_form_shell.dart +++ b/client/lib/screens/shared/order_form_shell.dart @@ -760,7 +760,9 @@ class RowActions extends StatelessWidget { ); } -/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开日历,值等宽显示。 +/// 内联日期格(.datefield / .gci-date):可直接键入(「2024」「2024-5-12」 +/// 「20240512」等,失焦/回车归一为 yyyy-MM-dd,同 DatePickerField 词法), +/// 日历图标打开滚轮面板;值等宽显示。 class DsDateCell extends StatefulWidget { final String? value; // yyyy-MM-dd final ValueChanged onChanged; @@ -793,9 +795,43 @@ class _DsDateCellState extends State { OverlayEntry? _entry; bool get _open => _entry != null; + late final TextEditingController _ctrl = + TextEditingController(text: widget.value ?? ''); + FocusNode? _ownFocus; // 调用方没给 focusNode 时自建(自建才由本组件 dispose) + FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode()); + + @override + void initState() { + super.initState(); + _focus.addListener(_onFocusChange); + } + + void _onFocusChange() { + // 失焦归一显示(2024 → 2024-01-01);顺带刷新激活描边 + if (!_focus.hasFocus) { + final n = normalizeYmd(_ctrl.text); + if (n != null && n != _ctrl.text) _ctrl.text = n; + } + if (mounted) setState(() {}); + } + + @override + void didUpdateWidget(DsDateCell old) { + super.didUpdateWidget(old); + // 仅在无焦点(非用户正在输入)时同步外部值,避免回流打断输入 + if (!_focus.hasFocus && + old.value != widget.value && + (widget.value ?? '') != _ctrl.text) { + _ctrl.text = widget.value ?? ''; + } + } + @override void dispose() { _removeOverlay(); + _focus.removeListener(_onFocusChange); + _ownFocus?.dispose(); + _ctrl.dispose(); super.dispose(); } @@ -824,7 +860,9 @@ class _DsDateCellState extends State { child: WheelDatePanel( initial: init, onCommit: (d) { - widget.onChanged(formatYmd(d)); + final v = formatYmd(d); + _ctrl.text = v; + widget.onChanged(v); _close(); widget.onPicked?.call(); }, @@ -847,24 +885,27 @@ class _DsDateCellState extends State { _entry = null; } + /// 提交键入值:归一 + 回写 + 推进下一格(Enter / 失焦走这里)。 + void _commitTyped(String v) { + final n = normalizeYmd(v); + if (n != null) _ctrl.text = n; + widget.onChanged(n); + if (_open) _close(); + widget.onPicked?.call(); + } + @override Widget build(BuildContext context) { final t = context.tokens; - final has = widget.value != null && widget.value!.isNotEmpty; final height = widget.cell ? 32.0 : 38.0; + final active = _focus.hasFocus || _open; return CompositedTransformTarget( link: _link, child: Focus( - focusNode: widget.focusNode, + // Tab/Esc 仍拦在外层(键盘流推进 / 收滚轮);Enter 交给 TextField.onSubmitted onKeyEvent: (node, e) { if (e is! KeyDownEvent) return KeyEventResult.ignored; final k = e.logicalKey; - if (k == LogicalKeyboardKey.enter || - k == LogicalKeyboardKey.numpadEnter || - k == LogicalKeyboardKey.space) { - _toggle(); - return KeyEventResult.handled; - } if (k == LogicalKeyboardKey.escape) { if (_open) { _close(); @@ -880,49 +921,65 @@ class _DsDateCellState extends State { } return KeyEventResult.ignored; }, - child: Builder(builder: (context) { - final active = Focus.of(context).hasFocus || _open; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.enabled - ? () { - widget.focusNode?.requestFocus(); - _toggle(); - } - : null, - child: Container( - height: height, - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: widget.cell - ? (active ? t.surface : Colors.transparent) - : t.surface, - border: Border.all( - color: widget.hasError - ? t.danger - : (active - ? t.primary - : (widget.cell ? Colors.transparent : t.border)), - ), - borderRadius: - BorderRadius.circular(widget.cell ? 5 : AppDims.rMd), - ), - child: Row(children: [ - Expanded( - child: Text( - has ? widget.value! : '选择日期', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppDims.fsBody, - color: has ? t.text : t.faint, - fontFamily: has ? 'monospace' : null), - ), - ), - Icon(LucideIcons.calendar, size: 15, color: t.faint), - ]), + child: Container( + height: height, + padding: const EdgeInsets.only(left: 10, right: 6), + decoration: BoxDecoration( + color: widget.cell + ? (active ? t.surface : Colors.transparent) + : t.surface, + border: Border.all( + color: widget.hasError + ? t.danger + : (active + ? t.primary + : (widget.cell ? Colors.transparent : t.border)), ), - ); - }), + borderRadius: BorderRadius.circular(widget.cell ? 5 : AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: TextField( + controller: _ctrl, + focusNode: _focus, + enabled: widget.enabled, + keyboardType: TextInputType.datetime, + style: TextStyle( + fontSize: AppDims.fsBody, + color: t.text, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback), + decoration: InputDecoration( + isCollapsed: true, + isDense: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + filled: false, + hintText: '年-月-日', + hintStyle: + TextStyle(fontSize: AppDims.fsBody, color: t.faint), + ), + onChanged: (v) => widget.onChanged(normalizeYmd(v)), + onSubmitted: _commitTyped, + ), + ), + InkWell( + onTap: widget.enabled + ? () { + _focus.requestFocus(); + _toggle(); + } + : null, + borderRadius: BorderRadius.circular(AppDims.rSm), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon(LucideIcons.calendar, size: 15, color: t.faint), + ), + ), + ]), + ), ), ); } diff --git a/client/lib/widgets/date_picker_field.dart b/client/lib/widgets/date_picker_field.dart index 1a60e10..784e127 100644 --- a/client/lib/widgets/date_picker_field.dart +++ b/client/lib/widgets/date_picker_field.dart @@ -38,7 +38,7 @@ class _DatePickerFieldState extends State { _focus.addListener(() { // 失焦时把输入归一化显示(如 2024 → 2024-01-01) if (!_focus.hasFocus) { - final n = _normalize(_ctrl.text); + final n = normalizeYmd(_ctrl.text); if (n != null && n != _ctrl.text) { _ctrl.text = n; } @@ -64,45 +64,9 @@ class _DatePickerFieldState extends State { super.dispose(); } - /// 归一用户输入为 `yyyy-MM-dd`(缺月/日补 01);无有效年返回 null。 - static String? _normalize(String raw) { - final s = raw.trim(); - if (s.isEmpty) return null; - int? y, mo, d; - if (RegExp(r'^[0-9]+$').hasMatch(s)) { - // 纯数字串:yyyymmdd / yyyymm / yyyy - if (s.length >= 8) { - y = int.tryParse(s.substring(0, 4)); - mo = int.tryParse(s.substring(4, 6)); - d = int.tryParse(s.substring(6, 8)); - } else if (s.length == 6) { - y = int.tryParse(s.substring(0, 4)); - mo = int.tryParse(s.substring(4, 6)); - } else { - y = int.tryParse(s); - } - } else { - final parts = - s.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList(); - if (parts.isNotEmpty) y = int.tryParse(parts[0]); - if (parts.length >= 2) mo = int.tryParse(parts[1]); - if (parts.length >= 3) d = int.tryParse(parts[2]); - } - if (y == null || y < 1900 || y > 2200) return null; - mo ??= 1; - d ??= 1; - if (mo < 1 || mo > 12) return null; - final maxDay = DateTime(y, mo + 1, 0).day; - if (d < 1) d = 1; - if (d > maxDay) d = maxDay; - return '${y.toString().padLeft(4, '0')}-' - '${mo.toString().padLeft(2, '0')}-' - '${d.toString().padLeft(2, '0')}'; - } - Future _pickFromCalendar(FormFieldState field) async { final init = - parseYmd(_normalize(_ctrl.text) ?? widget.value) ?? DateTime.now(); + parseYmd(normalizeYmd(_ctrl.text) ?? widget.value) ?? DateTime.now(); final picked = await showDatePicker( context: context, initialDate: init, @@ -123,7 +87,7 @@ class _DatePickerFieldState extends State { return FormField( initialValue: widget.value, validator: widget.isRequired - ? (_) => _normalize(_ctrl.text) == null ? '请输入日期' : null + ? (_) => normalizeYmd(_ctrl.text) == null ? '请输入日期' : null : null, builder: (field) { return TextField( @@ -148,7 +112,7 @@ class _DatePickerFieldState extends State { ), ), onChanged: (v) { - final n = _normalize(v); + final n = normalizeYmd(v); field.didChange(n); widget.onChanged(n); }, diff --git a/client/test/golden/goldens/stock_in_form_a.png b/client/test/golden/goldens/stock_in_form_a.png index fcf85b7..c0473d1 100644 Binary files a/client/test/golden/goldens/stock_in_form_a.png and b/client/test/golden/goldens/stock_in_form_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_b.png b/client/test/golden/goldens/stock_in_form_b.png index c1c1b35..1393513 100644 Binary files a/client/test/golden/goldens/stock_in_form_b.png and b/client/test/golden/goldens/stock_in_form_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_c.png b/client/test/golden/goldens/stock_in_form_c.png index ed54b52..c3450c9 100644 Binary files a/client/test/golden/goldens/stock_in_form_c.png and b/client/test/golden/goldens/stock_in_form_c.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_a.png b/client/test/golden/goldens/stock_in_form_mobile_a.png index d6ee821..ddff4dd 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_a.png and b/client/test/golden/goldens/stock_in_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_b.png b/client/test/golden/goldens/stock_in_form_mobile_b.png index bee4192..38a5660 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_b.png and b/client/test/golden/goldens/stock_in_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_c.png b/client/test/golden/goldens/stock_in_form_mobile_c.png index f6cc8b5..f3d6b3c 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_c.png and b/client/test/golden/goldens/stock_in_form_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_a.png b/client/test/golden/goldens/stock_out_form_a.png index 342778b..f71346f 100644 Binary files a/client/test/golden/goldens/stock_out_form_a.png and b/client/test/golden/goldens/stock_out_form_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_b.png b/client/test/golden/goldens/stock_out_form_b.png index 2f39ee9..6a2188f 100644 Binary files a/client/test/golden/goldens/stock_out_form_b.png and b/client/test/golden/goldens/stock_out_form_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_c.png b/client/test/golden/goldens/stock_out_form_c.png index 630f143..05e8fac 100644 Binary files a/client/test/golden/goldens/stock_out_form_c.png and b/client/test/golden/goldens/stock_out_form_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_a.png b/client/test/golden/goldens/stock_out_form_mobile_a.png index 3b51648..2f91ae0 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_a.png and b/client/test/golden/goldens/stock_out_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_b.png b/client/test/golden/goldens/stock_out_form_mobile_b.png index 9c15ad9..90ad7c0 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_b.png and b/client/test/golden/goldens/stock_out_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_c.png b/client/test/golden/goldens/stock_out_form_mobile_c.png index 7094a23..a0197b2 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_c.png and b/client/test/golden/goldens/stock_out_form_mobile_c.png differ