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
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
+310 -115
View File
@@ -1,9 +1,15 @@
// widgets/ds/ds_table.dart — 原型 .table + .toolbar + .pager(镜像 atoms.css)。
// .toolbar(圆角上,无下边) 接 .table.flush-top(圆角下) .pager,视觉连成一卡
// .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;
@@ -36,6 +42,12 @@ class DsTable extends StatefulWidget {
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,
@@ -49,6 +61,8 @@ class DsTable extends StatefulWidget {
this.onPageChanged,
this.onPageSizeChanged,
this.mobileCards,
this.pagerInfoText,
this.shrinkWrap = false,
});
@override
@@ -71,35 +85,61 @@ class _DsTableState extends State<DsTable> {
@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!,
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)),
),
],
),
Divider(height: 1, color: t.border),
],
Expanded(
child: (context.isMobile && widget.mobileCards != null)
? _buildMobile(t)
: _buildTable(t),
),
if (widget.total != null) _buildPager(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);
@@ -111,6 +151,44 @@ class _DsTableState extends State<DsTable> {
);
}
/// 收缩版卡片流(外层页面自带滚动)。
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),
@@ -136,13 +214,18 @@ class _DsTableState extends State<DsTable> {
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]),
],
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),
],
),
),
),
),
@@ -169,8 +252,7 @@ class _DsTableState extends State<DsTable> {
// 原型 thead th: pad 11 14
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Align(
alignment:
c.numeric ? Alignment.centerRight : Alignment.centerLeft,
alignment: c.numeric ? Alignment.centerRight : Alignment.centerLeft,
child: child,
),
);
@@ -178,120 +260,233 @@ class _DsTableState extends State<DsTable> {
);
}
TableRow _dataRow(dynamic t, int index, DsRow row) {
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,
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],
),
),
);
},
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],
),
),
),
);
}),
);
}
// 原型 .pager:每页 N 条 + 显示范围 + 翻页
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 = (total / widget.pageSize).ceil().clamp(1, 99999);
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 Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
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),
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),
_pgSizeBtn(t),
const SizedBox(width: 6),
Text('', style: labelStyle),
const SizedBox(width: 12),
],
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),
_pgGroup(t, pages),
],
),
);
}
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),
// .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),
],
),
),
child: InkWell(
onTap: onTap,
child: Icon(icon,
size: 16, color: onTap == null ? t.faint : t.text),
),
);
}
// .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),
],
),
),
);