Files
jiu/client/lib/widgets/ds/ds_table.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

495 lines
17 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;
// 内嵌收缩模式(多区块滚动页里的表格卡,如 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.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;
return Column(
mainAxisSize: widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_maybeExpand(
Container(
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
clipBehavior: 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!;
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 _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 .fh 漏斗列头:label + funnel(12, sw1.6),有筛选值时整体 primary。
/// 点击回调携带列头自身 BuildContext(供 showDsMenu 锚定)。
class DsFilterHeader extends StatelessWidget {
final String label;
final bool filtered;
final void Function(BuildContext anchorContext) onOpen;
const DsFilterHeader(
{super.key,
required this.label,
required this.filtered,
required this.onOpen});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final color = filtered ? t.primary : t.muted;
return Builder(
builder: (bctx) => InkWell(
onTap: () => onOpen(bctx),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: color)),
const SizedBox(width: 5),
Icon(LucideIcons.funnel, size: 12, color: color),
],
),
),
);
}
}