diff --git a/client/lib/widgets/ds/ds_table.dart b/client/lib/widgets/ds/ds_table.dart new file mode 100644 index 0000000..148f292 --- /dev/null +++ b/client/lib/widgets/ds/ds_table.dart @@ -0,0 +1,297 @@ +// widgets/ds/ds_table.dart — 原型 .table + .toolbar + .pager(镜像 atoms.css)。 +// .toolbar(圆角上,无下边) 接 .table.flush-top(圆角下) 接 .pager,视觉连成一卡。 +import 'package:flutter/material.dart'; +import '../../core/responsive/responsive.dart'; +import '../../core/theme/context_tokens.dart'; +import '../../core/theme/app_dims.g.dart'; + +class DsColumn { + final String key; + final String label; + final bool numeric; // th.num/td.num 右对齐 + mono + final bool action; // th.act/td.act 固定窄列 + final Widget? header; // 自定义列头(如漏斗筛选);缺省用 label + const DsColumn(this.key, this.label, + {this.numeric = false, this.action = false, this.header}); +} + +class DsRow { + final List cells; + final VoidCallback? onTap; + final Color? highlight; // 行底色(如缺货/预警淡染) + const DsRow({required this.cells, this.onTap, this.highlight}); +} + +/// 原型 .table 卡:可选 toolbar + 表格(列头漏斗/行 hover) + 分页。 +class DsTable extends StatefulWidget { + final Widget? toolbar; + final List columns; + final List rows; + final String emptyText; + // 分页(.pager) + final int? total; + final int page; + final int pageSize; + final ValueChanged? onPageChanged; + final ValueChanged? onPageSizeChanged; + // 窄屏卡片流(与 rows 同序);为空时窄屏也走表格横滚 + final List? mobileCards; + + const DsTable({ + super.key, + this.toolbar, + required this.columns, + required this.rows, + this.emptyText = '暂无数据', + this.total, + this.page = 1, + this.pageSize = 20, + this.onPageChanged, + this.onPageSizeChanged, + this.mobileCards, + }); + + @override + State createState() => _DsTableState(); +} + +class _DsTableState extends State { + final _hovered = ValueNotifier(-1); + final _vCtrl = ScrollController(); + final _hCtrl = ScrollController(); + + @override + void dispose() { + _hovered.dispose(); + _vCtrl.dispose(); + _hCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Container( + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + if (widget.toolbar != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: widget.toolbar!, + ), + Divider(height: 1, color: t.border), + ], + Expanded( + child: (context.isMobile && widget.mobileCards != null) + ? _buildMobile(t) + : _buildTable(t), + ), + if (widget.total != null) _buildPager(t), + ], + ), + ); + } + + Widget _buildMobile(dynamic t) { + final cards = widget.mobileCards!; + if (cards.isEmpty) return _empty(t); + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: cards.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (_, i) => cards[i], + ); + } + + Widget _empty(dynamic t) => Center( + child: Padding( + padding: const EdgeInsets.all(48), + child: Text(widget.emptyText, + style: TextStyle(color: t.muted, fontSize: AppDims.fsBody)), + ), + ); + + Widget _buildTable(dynamic t) { + if (widget.rows.isEmpty) return _empty(t); + return LayoutBuilder( + builder: (context, constraints) => Scrollbar( + controller: _vCtrl, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _vCtrl, + child: Scrollbar( + controller: _hCtrl, + thumbVisibility: true, + scrollbarOrientation: ScrollbarOrientation.bottom, + child: SingleChildScrollView( + controller: _hCtrl, + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + children: [ + _headerRow(t), + for (int i = 0; i < widget.rows.length; i++) + _dataRow(t, i, widget.rows[i]), + ], + ), + ), + ), + ), + ), + ), + ); + } + + TableRow _headerRow(dynamic t) { + return TableRow( + decoration: BoxDecoration( + color: t.thBg, + border: Border(bottom: BorderSide(color: t.border)), + ), + children: widget.columns.map((c) { + final child = c.header ?? + Text(c.label, + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600, + color: t.muted)); + return Padding( + // 原型 thead th: pad 11 14 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Align( + alignment: + c.numeric ? Alignment.centerRight : Alignment.centerLeft, + child: child, + ), + ); + }).toList(), + ); + } + + TableRow _dataRow(dynamic t, int index, DsRow row) { + return TableRow( + children: List.generate(row.cells.length, (col) { + final c = widget.columns[col]; + return MouseRegion( + cursor: row.onTap != null + ? SystemMouseCursors.click + : MouseCursor.defer, + onEnter: (_) => _hovered.value = index, + onExit: (_) { + if (_hovered.value == index) _hovered.value = -1; + }, + child: ValueListenableBuilder( + valueListenable: _hovered, + builder: (context, hov, __) { + final bg = hov == index ? t.rowHover : (row.highlight); + return GestureDetector( + onTap: row.onTap, + behavior: HitTestBehavior.opaque, + child: Container( + // 原型 tbody td: pad 12 14,下边 border-subtle + constraints: const BoxConstraints(minHeight: 44), + decoration: BoxDecoration( + color: bg, + border: Border( + bottom: BorderSide(color: t.borderSubtle)), + ), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + alignment: c.numeric + ? Alignment.centerRight + : Alignment.centerLeft, + child: DefaultTextStyle.merge( + style: TextStyle( + fontSize: AppDims.fsBody, color: t.text), + child: row.cells[col], + ), + ), + ); + }, + ), + ); + }), + ); + } + + // 原型 .pager:每页 N 条 + 显示范围 + 翻页 + Widget _buildPager(dynamic t) { + final total = widget.total!; + final pages = (total / widget.pageSize).ceil().clamp(1, 99999); + final start = total == 0 ? 0 : (widget.page - 1) * widget.pageSize + 1; + final end = (widget.page * widget.pageSize).clamp(0, total); + final labelStyle = TextStyle(fontSize: AppDims.fsSm, color: t.muted); + return Container( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), + child: Row( + children: [ + if (widget.onPageSizeChanged != null && !context.isMobile) ...[ + Text('每页', style: labelStyle), + const SizedBox(width: 6), + DropdownButtonHideUnderline( + child: DropdownButton( + value: const [10, 20, 50, 100].contains(widget.pageSize) + ? widget.pageSize + : 20, + isDense: true, + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.text, + fontFamily: 'monospace'), + items: const [10, 20, 50, 100] + .map((n) => + DropdownMenuItem(value: n, child: Text('$n 条'))) + .toList(), + onChanged: (v) { + if (v != null) widget.onPageSizeChanged!(v); + }, + ), + ), + const SizedBox(width: 16), + ], + Text('显示 $start–$end,共 $total', style: labelStyle), + const Spacer(), + _pgBtn(t, Icons.chevron_left, + widget.page > 1 ? () => widget.onPageChanged?.call(widget.page - 1) : null), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text('${widget.page} / $pages', + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.text, + fontFamily: 'monospace')), + ), + _pgBtn(t, Icons.chevron_right, + widget.page < pages ? () => widget.onPageChanged?.call(widget.page + 1) : null), + ], + ), + ); + } + + Widget _pgBtn(dynamic t, IconData icon, VoidCallback? onTap) { + return SizedBox( + width: 30, + height: 30, + child: Material( + color: t.surface, + shape: RoundedRectangleBorder( + side: BorderSide(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rSm), + ), + child: InkWell( + onTap: onTap, + child: Icon(icon, + size: 16, color: onTap == null ? t.faint : t.text), + ), + ), + ); + } +} diff --git a/client/test/golden/goldens/inventory_list_mobile_a.png b/client/test/golden/goldens/inventory_list_mobile_a.png index dbfa797..634e495 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_a.png and b/client/test/golden/goldens/inventory_list_mobile_a.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_b.png b/client/test/golden/goldens/inventory_list_mobile_b.png index 6979b41..0598438 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_b.png and b/client/test/golden/goldens/inventory_list_mobile_b.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_c.png b/client/test/golden/goldens/inventory_list_mobile_c.png index 7d5b2d0..6437c03 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_c.png and b/client/test/golden/goldens/inventory_list_mobile_c.png differ