Files
jiu/client/lib/widgets/ds/ds_table.dart
wangjia 00d9410bd3 fix(client): 补提交 ds_table.dart(库存屏依赖)+ 库存移动端 golden
ds_table.dart 在 ds 地基提交后新建、库存屏提交时漏 add,补回避免仓库缺文件。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-06-25 21:59:07 +08:00

298 lines
9.6 KiB
Dart
Raw Permalink 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 '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<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;
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<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;
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<int>(
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<int>(
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),
),
),
);
}
}