d1b7e8e2e9
- DsFilterHeader 删除(筛选已全部上移工具栏 chips,屏内零引用) - mobile_list_card 标 LEGACY 禁新用(6 屏迁移 MCard 登记 todo #13) - fidelity 阈值按 2026-07-07 实测收紧:一刀切 8% → 各屏实测最大残差+2pp (4%~7%),新阈下 36 屏×主题全过;proto 基准同步重摄(含库存改版) - CLAUDE.md 修正:表格筛选口径(工具栏 chips+列头仅排序)、响应式导航 (Drawer 退役→底部 5 tab,修文字滞后) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
528 lines
18 KiB
Dart
528 lines
18 KiB
Dart
// 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<Widget> 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<DsColumn> columns;
|
||
final List<DsRow> rows;
|
||
final String emptyText;
|
||
// 分页(.pager)
|
||
final int? total;
|
||
final int page;
|
||
final int pageSize;
|
||
final ValueChanged<int>? onPageChanged;
|
||
final ValueChanged<int>? onPageSizeChanged;
|
||
// 窄屏卡片流(与 rows 同序);为空时窄屏也走表格横滚
|
||
final List<Widget>? mobileCards;
|
||
// 仅文案分页(原型 partners/products 的 .pager 只有一行 span 文案,无翻页控件);
|
||
// 与 total 互斥,total 为空且此值非空时生效。
|
||
final String? pagerInfoText;
|
||
|
||
/// 窄屏卡片流下拉刷新(等价桌面工具栏「刷新」,2026-07-04 拍板);null 不启用。
|
||
final Future<void> Function()? onRefresh;
|
||
// 内嵌收缩模式(多区块滚动页里的表格卡,如 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.onRefresh,
|
||
this.shrinkWrap = false,
|
||
});
|
||
|
||
@override
|
||
State<DsTable> createState() => _DsTableState();
|
||
}
|
||
|
||
class _DsTableState extends State<DsTable> {
|
||
final _hovered = ValueNotifier<int>(-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;
|
||
// 窄屏卡片流对齐原型 m-scroll:卡直接铺页面底色,无外层大卡容器
|
||
final mobileCardsMode = context.isMobile && widget.mobileCards != null;
|
||
return Column(
|
||
mainAxisSize: widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_maybeExpand(
|
||
Container(
|
||
decoration: mobileCardsMode
|
||
? null
|
||
: BoxDecoration(
|
||
color: t.surface,
|
||
border: Border.all(color: t.border),
|
||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||
),
|
||
clipBehavior: mobileCardsMode ? Clip.none : 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!;
|
||
final Widget list = cards.isEmpty
|
||
? (widget.onRefresh == null
|
||
? _empty(t)
|
||
// 空态也要可下拉:套一个总是可滚的 ListView
|
||
: ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 60),
|
||
child: _empty(t)),
|
||
],
|
||
))
|
||
: ListView.separated(
|
||
padding: const EdgeInsets.all(12),
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
itemCount: cards.length,
|
||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||
itemBuilder: (_, i) => cards[i],
|
||
);
|
||
if (widget.onRefresh == null) return list;
|
||
return RefreshIndicator(onRefresh: widget.onRefresh!, child: list);
|
||
}
|
||
|
||
/// 收缩版卡片流(外层页面自带滚动)。
|
||
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<int>(
|
||
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<int>(
|
||
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<int>(
|
||
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<int> _pageWindow(int pages) {
|
||
if (pages <= 7) return [for (var p = 1; p <= pages; p++) p];
|
||
final cur = widget.page.clamp(1, pages);
|
||
final set = <int>{1, pages, cur - 1, cur, cur + 1}
|
||
..removeWhere((p) => p < 1 || p > pages);
|
||
final sorted = set.toList()..sort();
|
||
final out = <int>[];
|
||
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 .sh 排序列头(2026-07-07 登记):label + 方向箭头。
|
||
/// 未排序 = 淡 chevron-down 暗示可排;激活整体 primary,asc=↑ / desc=↓。
|
||
/// 点击由调用方循环 无→升→降→无(服务端全局排序)。
|
||
class DsSortHeader extends StatelessWidget {
|
||
final String label;
|
||
|
||
/// null = 未按本列排序;true = 升序;false = 降序
|
||
final bool? ascending;
|
||
final VoidCallback onTap;
|
||
const DsSortHeader(
|
||
{super.key, required this.label, this.ascending, required this.onTap});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final on = ascending != null;
|
||
final color = on ? t.primary : t.muted;
|
||
return InkWell(
|
||
onTap: onTap,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(label,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsSm,
|
||
fontWeight: FontWeight.w600,
|
||
color: color)),
|
||
const SizedBox(width: 4),
|
||
Opacity(
|
||
opacity: on ? 1 : .45,
|
||
child: Icon(
|
||
ascending == true
|
||
? LucideIcons.chevronUp
|
||
: LucideIcons.chevronDown,
|
||
size: 12,
|
||
color: color,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// DsFilterHeader(列头漏斗筛选)已于 2026-07-07 退役删除:筛选统一上移工具栏
|
||
// chips(用户口径,形式参照出库管理),列头仅保留 DsSortHeader 排序。
|