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>
@@ -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')}';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 85 KiB |