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
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 162 KiB |
Reference in New Issue
Block a user