diff --git a/CHANGELOG-client.md b/CHANGELOG-client.md index da46217..d535232 100644 --- a/CHANGELOG-client.md +++ b/CHANGELOG-client.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.66] - 2026-06-21 + +### 改进 +- 入库/出库列表「日期」列改名为「入库时间/出库时间」,移除恒为空的旧「审核时间」列 +- 日期选择改为可直接键入的输入框 + 日历图标:能直接打年份(选老年份方便),支持只填到年或年月(自动补为月初) + ## [1.0.65] - 2026-06-21 ### 改进 diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index abd9299..3b910fa 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -49,8 +49,7 @@ class _StockInListScreenState extends ConsumerState { ColDef('warehouse', '仓库'), ColDef('amount', '金额', minWidth: 800), ColDef('status', '状态'), - ColDef('date', '日期', minWidth: 900), - ColDef('reviewed_at', '入库时间', minWidth: 900), + ColDef('date', '入库时间', minWidth: 900), ColDef('operator', '入库员', minWidth: 1100), ColDef('reviewer', '审核员', minWidth: 1100), ColDef('actions', '操作', required: true), @@ -270,12 +269,6 @@ class _StockInListScreenState extends ConsumerState { return DataCell(StatusBadge(_apiStatusToEnum(o.status))); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); - case 'reviewed_at': - return DataCell(Text(o.reviewedAt != null - ? o.reviewedAt!.length >= 16 - ? o.reviewedAt!.substring(0, 16) - : o.reviewedAt!.substring(0, 10) - : '-')); case 'operator': return DataCell(Text(o.operatorName ?? '-', style: const TextStyle(fontSize: 13))); @@ -550,7 +543,7 @@ class _StockInListScreenState extends ConsumerState { o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), - MobileCardField('日期', o.orderDate?.substring(0, 10) ?? '-'), + MobileCardField('入库时间', o.orderDate?.substring(0, 10) ?? '-'), ], actions: _orderActions(context, o), ); diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 56aa160..6887586 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -64,8 +64,7 @@ class _StockOutListScreenState extends ConsumerState { ColDef('warehouse', '仓库'), ColDef('amount', '金额', minWidth: 800), ColDef('status', '状态'), - ColDef('date', '日期', minWidth: 900), - ColDef('reviewed_at', '出库时间', minWidth: 900), + ColDef('date', '出库时间', minWidth: 900), ColDef('created_at', '创建时间', minWidth: 900), ColDef('operator', '出库员', minWidth: 1100), ColDef('reviewer', '审核员', minWidth: 1100), @@ -271,12 +270,6 @@ class _StockOutListScreenState extends ConsumerState { return DataCell(StatusBadge(_apiStatusToEnum(o.status))); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); - case 'reviewed_at': - return DataCell(Text(o.reviewedAt != null - ? o.reviewedAt!.length >= 16 - ? o.reviewedAt!.substring(0, 16) - : o.reviewedAt!.substring(0, 10) - : '-')); case 'created_at': return DataCell(Text(o.createdAt != null ? o.createdAt!.length >= 16 @@ -524,7 +517,7 @@ class _StockOutListScreenState extends ConsumerState { o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), - MobileCardField('日期', o.orderDate?.substring(0, 10) ?? '-'), + MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'), ], actions: _orderActions(context, o), ); diff --git a/client/lib/widgets/date_picker_field.dart b/client/lib/widgets/date_picker_field.dart index b0f8ebb..6dee86e 100644 --- a/client/lib/widgets/date_picker_field.dart +++ b/client/lib/widgets/date_picker_field.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import '../core/theme/app_theme.dart'; import '../core/utils/date_util.dart'; -/// 年/月/日 三个可编辑下拉框的日期选择组件,替代原生 showDatePicker。 +/// 可键入日期框 + 日历图标(替代旧的「年/月/日」三下拉)。 /// -/// - 每个下拉是 Material 3 `DropdownMenu`:点开可选,键入数字即过滤定位 -/// (年/月/日均为有界域,条目覆盖全范围,等价于「手动输入数字」)。 -/// - 改月/年时把超出当月的「日」自动 clamp 回当月最大值。 -/// - 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged]);三者未填齐时回调 null。 +/// - **直接键入**:支持「2024」「2024-5」「2024-5-12」「20240512」等,归一为 `yyyy-MM-dd`, +/// 缺的月/日补 `01`(老酒只知道年份,填 2024 即 2024-01-01)。失焦时把显示归一化。 +/// - **日历图标**:弹 Material `showDatePicker`(input 模式可直接键入年份,选老年份方便)。 +/// - 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged]);无有效年时回调 null。 class DatePickerField extends StatefulWidget { final String? value; // yyyy-MM-dd final ValueChanged onChanged; @@ -27,151 +27,130 @@ class DatePickerField extends StatefulWidget { } class _DatePickerFieldState extends State { - int? _year; - int? _month; - int? _day; + late final TextEditingController _ctrl; + final FocusNode _focus = FocusNode(); @override void initState() { super.initState(); - _parse(widget.value); + _ctrl = TextEditingController(text: widget.value ?? ''); + _focus.addListener(() { + // 失焦时把输入归一化显示(如 2024 → 2024-01-01) + if (!_focus.hasFocus) { + final n = _normalize(_ctrl.text); + if (n != null && n != _ctrl.text) { + _ctrl.text = n; + } + } + }); } @override void didUpdateWidget(DatePickerField old) { super.didUpdateWidget(old); - if (old.value != widget.value) { - _parse(widget.value); + // 仅在无焦点(非用户正在输入)时同步外部值,避免回流打断输入 + if (!_focus.hasFocus && + old.value != widget.value && + (widget.value ?? '') != _ctrl.text) { + _ctrl.text = widget.value ?? ''; } } - void _parse(String? v) { - final d = parseYmd(v); - _year = d?.year; - _month = d?.month; - _day = d?.day; + @override + void dispose() { + _ctrl.dispose(); + _focus.dispose(); + super.dispose(); } - int get _daysInMonth { - final y = _year ?? DateTime.now().year; - final m = _month ?? 1; - return DateTime(y, m + 1, 0).day; // 下月第 0 天 = 当月最后一天 - } - - String? get _composed => composeYmd(_year, _month, _day); - - void _emit(FormFieldState field) { - // 改月/年后把超界的日 clamp 回当月最大值(与 composeYmd 一致,State 同步显示) - if (_day != null && _day! > _daysInMonth) { - _day = _daysInMonth; + /// 归一用户输入为 `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(); + final picked = await showDatePicker( + context: context, + initialDate: init, + firstDate: DateTime(1900), + lastDate: DateTime(2200), + initialEntryMode: DatePickerEntryMode.input, + ); + if (picked != null) { + final v = formatYmd(picked); + _ctrl.text = v; + field.didChange(v); + widget.onChanged(v); } - final v = _composed; - field.didChange(v); - widget.onChanged(v); - setState(() {}); } @override Widget build(BuildContext context) { - final now = DateTime.now(); - final years = [for (var y = now.year - 20; y <= now.year + 5; y++) y]; - final months = [for (var m = 1; m <= 12; m++) m]; - final days = [for (var d = 1; d <= _daysInMonth; d++) d]; - - // 每个下拉用 Expanded 撑满分得的宽度(expandedInsets:zero 让 DropdownMenu 填满父级), - // trailingIcon 用紧凑小箭头,避免默认大图标按钮挤掉数字(曾导致「2026」被裁成「007」)。 - Widget menu({ - required String label, - required int? value, - required List items, - required ValueChanged onSel, - required FormFieldState field, - }) { - return DropdownMenu( - initialSelection: value, - label: Text(label, style: const TextStyle(fontSize: 11)), - enableFilter: true, - requestFocusOnTap: true, - textStyle: const TextStyle(fontSize: 13), - menuHeight: 280, - expandedInsets: EdgeInsets.zero, - trailingIcon: const Icon(Icons.arrow_drop_down, size: 18), - selectedTrailingIcon: const Icon(Icons.arrow_drop_up, size: 18), - inputDecorationTheme: const InputDecorationTheme( - isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 8), - ), - dropdownMenuEntries: [ - for (final i in items) DropdownMenuEntry(value: i, label: '$i'), - ], - onSelected: onSel, - ); - } - return FormField( initialValue: widget.value, validator: widget.isRequired - ? (v) => (_composed == null ? '请选择日期' : null) + ? (_) => _normalize(_ctrl.text) == null ? '请输入日期' : null : null, builder: (field) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - flex: 5, - child: menu( - label: '年', - value: _year, - items: years, - field: field, - onSel: (v) { - _year = v; - _emit(field); - }, - ), - ), - const SizedBox(width: 6), - Expanded( - flex: 4, - child: menu( - label: '月', - value: _month, - items: months, - field: field, - onSel: (v) { - _month = v; - _emit(field); - }, - ), - ), - const SizedBox(width: 6), - Expanded( - flex: 4, - child: menu( - label: '日', - value: _day, - items: days, - field: field, - onSel: (v) { - _day = v; - _emit(field); - }, - ), - ), - ], + return TextField( + controller: _ctrl, + focusNode: _focus, + keyboardType: TextInputType.datetime, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + isDense: true, + hintText: '年-月-日', + hintStyle: const TextStyle(fontSize: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + errorText: field.errorText, + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_today, + size: 16, color: AppTheme.textSecondary), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 34, minHeight: 34), + tooltip: '选择日期', + onPressed: () => _pickFromCalendar(field), ), - if (field.errorText != null) - Padding( - padding: const EdgeInsets.only(left: 12, top: 4), - child: Text( - field.errorText!, - style: const TextStyle(fontSize: 11, color: AppTheme.danger), - ), - ), - ], + ), + onChanged: (v) { + final n = _normalize(v); + field.didChange(n); + widget.onChanged(n); + }, ); }, ); diff --git a/client/test/date_picker_field_test.dart b/client/test/date_picker_field_test.dart index 691f4b4..52d4d1a 100644 --- a/client/test/date_picker_field_test.dart +++ b/client/test/date_picker_field_test.dart @@ -31,26 +31,47 @@ void main() { }); group('DatePickerField', () { - testWidgets('渲染三个年/月/日下拉框', (tester) async { + testWidgets('渲染可键入框 + 日历图标', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: DatePickerField(value: '2026-06-19', onChanged: (_) {}), ), )); await tester.pumpAndSettle(); - expect(find.byType(DropdownMenu), findsNWidgets(3)); + expect(find.byType(TextField), findsOneWidget); + expect(find.byIcon(Icons.calendar_today), findsOneWidget); }); - testWidgets('初始值回显到三个下拉框', (tester) async { + testWidgets('初始值回显到输入框', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: DatePickerField(value: '2026-06-19', onChanged: (_) {}), ), )); await tester.pumpAndSettle(); - expect(find.widgetWithText(DropdownMenu, '2026'), findsOneWidget); - expect(find.widgetWithText(DropdownMenu, '6'), findsOneWidget); - expect(find.widgetWithText(DropdownMenu, '19'), findsOneWidget); + expect(find.text('2026-06-19'), findsOneWidget); + }); + + testWidgets('只输年份归一为 yyyy-01-01', (tester) async { + String? out; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: DatePickerField(onChanged: (v) => out = v), + ), + )); + await tester.enterText(find.byType(TextField), '2024'); + expect(out, '2024-01-01'); + }); + + testWidgets('年月归一为 yyyy-MM-01', (tester) async { + String? out; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: DatePickerField(onChanged: (v) => out = v), + ), + )); + await tester.enterText(find.byType(TextField), '2024-5'); + expect(out, '2024-05-01'); }); }); }