feat(client): 录单日期格支持直接键入(DsDateCell 可输入 + 滚轮并存)

- DsDateCell 由纯点选改为可键入:支持 2024 / 2024-5-12 / 20240512 等,
  失焦或回车归一为 yyyy-MM-dd;回车提交并推进下一格(键盘流保留 Tab/Esc);
  日历图标仍打开滚轮面板,滚轮确定回填输入框
- 归一词法抽为共享 normalizeYmd(core/utils/date_util.dart),
  DatePickerField 改为复用同一实现(登记收支等处词法一致)
- 出入库表单 golden 基准随日期格形态更新(桌面+移动 ×3 主题)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 17:20:03 +08:00
parent 6d58ace4a7
commit 0425939d7e
15 changed files with 151 additions and 92 deletions
+38
View File
@@ -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')}';
}
+109 -52
View File
@@ -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<String?> onChanged;
@@ -793,9 +795,43 @@ class _DsDateCellState extends State<DsDateCell> {
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<DsDateCell> {
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<DsDateCell> {
_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<DsDateCell> {
}
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),
),
),
]),
),
),
);
}
+4 -40
View File
@@ -38,7 +38,7 @@ class _DatePickerFieldState extends State<DatePickerField> {
_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<DatePickerField> {
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<void> _pickFromCalendar(FormFieldState<String> 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<DatePickerField> {
return FormField<String>(
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<DatePickerField> {
),
),
onChanged: (v) {
final n = _normalize(v);
final n = normalizeYmd(v);
field.didChange(n);
widget.onChanged(n);
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 85 KiB