// widgets/ds/ds_table.dart — 原型 .table + .toolbar + .pager(镜像 atoms.css)。 // .toolbar(圆角上,无下边) 接 .table.flush-top(圆角下) 连成一卡;.pager 透明、在卡外。 import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; import 'ds_menu.dart'; import '../../core/theme/app_fonts.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; // 仅文案分页(原型 partners/products 的 .pager 只有一行 span 文案,无翻页控件); // 与 total 互斥,total 为空且此值非空时生效。 final String? pagerInfoText; // 内嵌收缩模式(多区块滚动页里的表格卡,如 devices/settings): // 不用 Expanded 撑满、表格不带内部滚动,高度随内容。 final bool shrinkWrap; 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, this.pagerInfoText, this.shrinkWrap = false, }); @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 Column( mainAxisSize: widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _maybeExpand( Container( decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), clipBehavior: Clip.antiAlias, child: Column( // 整宽拉伸:toolbar/表格都填满卡片宽度(否则 toolbar 按内容宽居中→左侧留白)。 mainAxisSize: widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.toolbar != null) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12), child: widget.toolbar!, ), Divider(height: 1, color: t.border), ], _maybeExpand( (context.isMobile && widget.mobileCards != null) ? (widget.shrinkWrap ? _buildMobileShrink(t) : _buildMobile(t)) : (widget.shrinkWrap ? _buildTableShrink(t) : _buildTable(t)), ), ], ), ), ), if (widget.total != null) _buildPager(t) else if (widget.pagerInfoText != null) Padding( padding: const EdgeInsets.fromLTRB(4, 13, 4, 2), child: Text(widget.pagerInfoText!, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), ], ); } /// shrinkWrap 模式下的 Expanded 兼容:收缩时原样返回,撑满时包 Expanded。 Widget _maybeExpand(Widget child) => widget.shrinkWrap ? child : Expanded(child: child); 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 _buildMobileShrink(dynamic t) { final cards = widget.mobileCards!; if (cards.isEmpty) return _empty(t); return Padding( padding: const EdgeInsets.all(12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(height: 10), cards[i], ], ], ), ); } /// 收缩版表格:不带内部滚动,高度随内容(多区块滚动页用)。 Widget _buildTableShrink(dynamic t) { if (widget.rows.isEmpty) return _empty(t); return ValueListenableBuilder( valueListenable: _hovered, builder: (context, hov, __) => Table( defaultColumnWidth: const IntrinsicColumnWidth(), columnWidths: const {0: FlexColumnWidth()}, // 原型 td vertical-align: middle——混高单元格(名称双行等)垂直居中 defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ _headerRow(t), for (int i = 0; i < widget.rows.length; i++) _dataRow(t, i, widget.rows[i], hov), ], ), ); } 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: ValueListenableBuilder( valueListenable: _hovered, builder: (context, hov, __) => Table( defaultColumnWidth: const IntrinsicColumnWidth(), // 原型 td vertical-align: middle defaultVerticalAlignment: TableCellVerticalAlignment.middle, children: [ _headerRow(t), for (int i = 0; i < widget.rows.length; i++) _dataRow(t, i, widget.rows[i], hov), ], ), ), ), ), ), ), ), ); } 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, int hovered) { // 背景 + 下边框挂在行级 decoration:单元格高度随内容各不相同(如名称双行 vs // 单行徽章),若把边框画在格子上,矮格子的下边框会浮在行中间「断线」, // hover 背景也会条纹化。行级画一次,整行齐平(原型 tbody td 的 border 视觉)。 final bg = hovered == index ? t.rowHover : (row.highlight); return TableRow( decoration: BoxDecoration( color: bg, border: Border(bottom: BorderSide(color: t.borderSubtle)), ), 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: GestureDetector( onTap: row.onTap, behavior: HitTestBehavior.opaque, child: Container( // 原型 tbody td: pad 12 14 constraints: const BoxConstraints(minHeight: 44), 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], ), ), ), ); }), ); } static const _pageSizes = [10, 20, 50, 100]; // 原型 .pager(卡外透明,pad 13 4 2,gap12): // 「每页 [pgsize-btn ▾] 条」 · 「显示 X–Y,共 N」 · 右侧 .pg 数字页码 ‹ 1 2 ›。 Widget _buildPager(dynamic t) { final total = widget.total!; final pages = math.max(1, (total / widget.pageSize).ceil()); 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 Padding( padding: const EdgeInsets.fromLTRB(4, 13, 4, 2), child: Row( children: [ if (widget.onPageSizeChanged != null && !context.isMobile) ...[ // .pg-size:每页 [btn] 条(组内 gap6) Text('每页', style: labelStyle), const SizedBox(width: 6), _pgSizeBtn(t), const SizedBox(width: 6), Text('条', style: labelStyle), const SizedBox(width: 12), ], Text('显示 $start–$end,共 $total', style: labelStyle), const Spacer(), _pgGroup(t, pages), ], ), ); } // .pgsize-btn:h30 / pad 0 8 0 10 / border r-sm / surface / fs-sm mono + caret 12(muted), // 点击 openMenu(minW 88)单选每页条数。 Widget _pgSizeBtn(dynamic t) { return Builder( builder: (bctx) => InkWell( onTap: () async { final v = await showDsMenu( bctx, minWidth: 88, items: [ for (final n in _pageSizes) DsMenuItem( value: n, label: '$n', selected: n == widget.pageSize), ], ); if (v != null) widget.onPageSizeChanged!(v); }, borderRadius: BorderRadius.circular(AppDims.rSm), child: Container( height: 30, padding: const EdgeInsets.fromLTRB(10, 0, 8, 0), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rSm), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text('${widget.pageSize}', style: TextStyle( fontSize: AppDims.fsSm, color: t.text, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), const SizedBox(width: 6), Icon(LucideIcons.chevronDown, size: 12, color: t.muted), ], ), ), ), ); } // .pg:gap4;‹ 数字页码 ›;cur=primary。页数多时开窗(1 … 邻域 … 末页)。 Widget _pgGroup(dynamic t, int pages) { final nums = _pageWindow(pages); return Row( mainAxisSize: MainAxisSize.min, children: [ _pgBtn(t, '‹', onTap: widget.page > 1 ? () => widget.onPageChanged?.call(widget.page - 1) : null), for (final n in nums) ...[ const SizedBox(width: 4), if (n == -1) Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Text('…', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ) else _pgBtn(t, '$n', cur: n == widget.page, onTap: n == widget.page ? null : () => widget.onPageChanged?.call(n)), ], const SizedBox(width: 4), _pgBtn(t, '›', onTap: widget.page < pages ? () => widget.onPageChanged?.call(widget.page + 1) : null), ], ); } // 页码开窗:≤7 页全显(同原型全量循环);更多时 1 … cur±1 … last,-1 表示省略号。 List _pageWindow(int pages) { if (pages <= 7) return [for (var p = 1; p <= pages; p++) p]; final cur = widget.page.clamp(1, pages); final set = {1, pages, cur - 1, cur, cur + 1} ..removeWhere((p) => p < 1 || p > pages); final sorted = set.toList()..sort(); final out = []; for (var i = 0; i < sorted.length; i++) { if (i > 0 && sorted[i] - sorted[i - 1] > 1) out.add(-1); out.add(sorted[i]); } return out; } // .pg button:minW30 h30 pad 0 6 border r-sm mono fs-sm;cur→primary 底反白;disabled→faint。 Widget _pgBtn(dynamic t, String label, {bool cur = false, VoidCallback? onTap}) { final fg = cur ? t.onPrimary : (onTap == null && !cur ? t.faint : t.text); return Material( color: cur ? t.primary : t.surface, shape: RoundedRectangleBorder( side: BorderSide(color: cur ? t.primary : t.border), borderRadius: BorderRadius.circular(AppDims.rSm), ), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppDims.rSm), child: Container( constraints: const BoxConstraints(minWidth: 30), height: 30, padding: const EdgeInsets.symmetric(horizontal: 6), alignment: Alignment.center, child: Text(label, style: TextStyle( fontSize: AppDims.fsSm, color: fg, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ), ), ); } } /// 原型 thead th .fh 漏斗列头:label + funnel(12, sw1.6),有筛选值时整体 primary。 /// 点击回调携带列头自身 BuildContext(供 showDsMenu 锚定)。 class DsFilterHeader extends StatelessWidget { final String label; final bool filtered; final void Function(BuildContext anchorContext) onOpen; const DsFilterHeader( {super.key, required this.label, required this.filtered, required this.onOpen}); @override Widget build(BuildContext context) { final t = context.tokens; final color = filtered ? t.primary : t.muted; return Builder( builder: (bctx) => InkWell( onTap: () => onOpen(bctx), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: color)), const SizedBox(width: 5), Icon(LucideIcons.funnel, size: 12, color: color), ], ), ), ); } }