Files
jiu/client/lib/widgets/ds/ds_table.dart
T
wangjia d1b7e8e2e9 refactor(client): 前端体系治理——漏斗退役/老卡片标 legacy/fidelity 收阈/文档口径修正
- 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
2026-07-07 18:24:48 +08:00

528 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 2gap12):
// 「每页 [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-btnh30 / pad 0 8 0 10 / border r-sm / surface / fs-sm mono + caret 12(muted)
// 点击 openMenuminW 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),
],
),
),
),
);
}
// .pggap4;‹ 数字页码 ›;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 buttonminW30 h30 pad 0 6 border r-sm mono fs-smcur→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 暗示可排;激活整体 primaryasc=↑ / 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 排序。