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;
|
final d = day > maxDay ? maxDay : day;
|
||||||
return formatYmd(DateTime(year, month, d));
|
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 {
|
class DsDateCell extends StatefulWidget {
|
||||||
final String? value; // yyyy-MM-dd
|
final String? value; // yyyy-MM-dd
|
||||||
final ValueChanged<String?> onChanged;
|
final ValueChanged<String?> onChanged;
|
||||||
@@ -793,9 +795,43 @@ class _DsDateCellState extends State<DsDateCell> {
|
|||||||
OverlayEntry? _entry;
|
OverlayEntry? _entry;
|
||||||
bool get _open => _entry != null;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_removeOverlay();
|
_removeOverlay();
|
||||||
|
_focus.removeListener(_onFocusChange);
|
||||||
|
_ownFocus?.dispose();
|
||||||
|
_ctrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +860,9 @@ class _DsDateCellState extends State<DsDateCell> {
|
|||||||
child: WheelDatePanel(
|
child: WheelDatePanel(
|
||||||
initial: init,
|
initial: init,
|
||||||
onCommit: (d) {
|
onCommit: (d) {
|
||||||
widget.onChanged(formatYmd(d));
|
final v = formatYmd(d);
|
||||||
|
_ctrl.text = v;
|
||||||
|
widget.onChanged(v);
|
||||||
_close();
|
_close();
|
||||||
widget.onPicked?.call();
|
widget.onPicked?.call();
|
||||||
},
|
},
|
||||||
@@ -847,24 +885,27 @@ class _DsDateCellState extends State<DsDateCell> {
|
|||||||
_entry = null;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.tokens;
|
final t = context.tokens;
|
||||||
final has = widget.value != null && widget.value!.isNotEmpty;
|
|
||||||
final height = widget.cell ? 32.0 : 38.0;
|
final height = widget.cell ? 32.0 : 38.0;
|
||||||
|
final active = _focus.hasFocus || _open;
|
||||||
return CompositedTransformTarget(
|
return CompositedTransformTarget(
|
||||||
link: _link,
|
link: _link,
|
||||||
child: Focus(
|
child: Focus(
|
||||||
focusNode: widget.focusNode,
|
// Tab/Esc 仍拦在外层(键盘流推进 / 收滚轮);Enter 交给 TextField.onSubmitted
|
||||||
onKeyEvent: (node, e) {
|
onKeyEvent: (node, e) {
|
||||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
final k = e.logicalKey;
|
final k = e.logicalKey;
|
||||||
if (k == LogicalKeyboardKey.enter ||
|
|
||||||
k == LogicalKeyboardKey.numpadEnter ||
|
|
||||||
k == LogicalKeyboardKey.space) {
|
|
||||||
_toggle();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (k == LogicalKeyboardKey.escape) {
|
if (k == LogicalKeyboardKey.escape) {
|
||||||
if (_open) {
|
if (_open) {
|
||||||
_close();
|
_close();
|
||||||
@@ -880,49 +921,65 @@ class _DsDateCellState extends State<DsDateCell> {
|
|||||||
}
|
}
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
},
|
},
|
||||||
child: Builder(builder: (context) {
|
child: Container(
|
||||||
final active = Focus.of(context).hasFocus || _open;
|
height: height,
|
||||||
return GestureDetector(
|
padding: const EdgeInsets.only(left: 10, right: 6),
|
||||||
behavior: HitTestBehavior.opaque,
|
decoration: BoxDecoration(
|
||||||
onTap: widget.enabled
|
color: widget.cell
|
||||||
? () {
|
? (active ? t.surface : Colors.transparent)
|
||||||
widget.focusNode?.requestFocus();
|
: t.surface,
|
||||||
_toggle();
|
border: Border.all(
|
||||||
}
|
color: widget.hasError
|
||||||
: null,
|
? t.danger
|
||||||
child: Container(
|
: (active
|
||||||
height: height,
|
? t.primary
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
: (widget.cell ? Colors.transparent : t.border)),
|
||||||
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),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
);
|
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(() {
|
_focus.addListener(() {
|
||||||
// 失焦时把输入归一化显示(如 2024 → 2024-01-01)
|
// 失焦时把输入归一化显示(如 2024 → 2024-01-01)
|
||||||
if (!_focus.hasFocus) {
|
if (!_focus.hasFocus) {
|
||||||
final n = _normalize(_ctrl.text);
|
final n = normalizeYmd(_ctrl.text);
|
||||||
if (n != null && n != _ctrl.text) {
|
if (n != null && n != _ctrl.text) {
|
||||||
_ctrl.text = n;
|
_ctrl.text = n;
|
||||||
}
|
}
|
||||||
@@ -64,45 +64,9 @@ class _DatePickerFieldState extends State<DatePickerField> {
|
|||||||
super.dispose();
|
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 {
|
Future<void> _pickFromCalendar(FormFieldState<String> field) async {
|
||||||
final init =
|
final init =
|
||||||
parseYmd(_normalize(_ctrl.text) ?? widget.value) ?? DateTime.now();
|
parseYmd(normalizeYmd(_ctrl.text) ?? widget.value) ?? DateTime.now();
|
||||||
final picked = await showDatePicker(
|
final picked = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: init,
|
initialDate: init,
|
||||||
@@ -123,7 +87,7 @@ class _DatePickerFieldState extends State<DatePickerField> {
|
|||||||
return FormField<String>(
|
return FormField<String>(
|
||||||
initialValue: widget.value,
|
initialValue: widget.value,
|
||||||
validator: widget.isRequired
|
validator: widget.isRequired
|
||||||
? (_) => _normalize(_ctrl.text) == null ? '请输入日期' : null
|
? (_) => normalizeYmd(_ctrl.text) == null ? '请输入日期' : null
|
||||||
: null,
|
: null,
|
||||||
builder: (field) {
|
builder: (field) {
|
||||||
return TextField(
|
return TextField(
|
||||||
@@ -148,7 +112,7 @@ class _DatePickerFieldState extends State<DatePickerField> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
final n = _normalize(v);
|
final n = normalizeYmd(v);
|
||||||
field.didChange(n);
|
field.didChange(n);
|
||||||
widget.onChanged(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 |