// 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), ), ), ); } }