Files
jiu/client/lib/widgets/ds/ds_table.dart
T
wangjia a3a722689e feat(client): 出入库列表按原型重建(KPI/详情抽屉/详细搜索/工具栏/选中复制/窗口尺寸)
- 列表重建:KPI 卡、状态+时间列、操作改眼睛开右侧详情抽屉、重置右置
- 详情抽屉(order_detail_drawer) 100% 还原原型 + SelectionArea 可选中复制
- 详细搜索弹窗:ComboSearchField/滚轮日期、字段重构、白底盒子按钮
- 工具栏:搜索框×清除+占位精简、供应商/客户下拉取全部、状态菜单收窄
- 加载不白屏(半透明遮罩)、输入框与下拉等高、windows/macOS 默认窗口尺寸调大

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:48:01 +08:00

300 lines
9.7 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 '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(
// 整宽拉伸:toolbar/表格/分页都填满卡片宽度(否则 toolbar 按内容宽居中→左侧留白)。
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),
],
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),
),
),
);
}
}