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:
@@ -0,0 +1,226 @@
|
||||
// widgets/ds/ds_menu.dart — 原型 .menu/.menu-item 下拉菜单(镜像 atoms.css + shell.js openMenu)。
|
||||
// 定位规则照抄 openMenu:宽 = max(锚宽, minW=168),左缘夹在视口内(留 8px),
|
||||
// 锚下 6px 展开、下方放不下且上方够高则向上翻。
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
|
||||
/// 菜单项:sel → 前导 ✓(primary) + 文字 primary fw600;icon → 前导 16px 图标;
|
||||
/// 都没有 → 16px 占位(对齐 openMenu 的 lead 槽)。
|
||||
class DsMenuItem<T> {
|
||||
final T value;
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final bool selected;
|
||||
const DsMenuItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.selected = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。
|
||||
Future<T?> showDsMenu<T>(
|
||||
BuildContext anchorContext, {
|
||||
required List<DsMenuItem<T>> items,
|
||||
double minWidth = 168,
|
||||
}) {
|
||||
final rect = _anchorRect(anchorContext);
|
||||
return Navigator.of(anchorContext).push<T>(_DsMenuRoute<T>(
|
||||
anchorRect: rect,
|
||||
minWidth: minWidth,
|
||||
builder: (ctx) => _DsMenuPanel(
|
||||
children: [
|
||||
for (final it in items)
|
||||
_DsMenuItemTile(
|
||||
item: it,
|
||||
onTap: () => Navigator.of(ctx).pop(it.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。
|
||||
/// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。
|
||||
Future<void> showDsMultiMenu<T>(
|
||||
BuildContext anchorContext, {
|
||||
required List<DsMenuItem<T>> Function() itemsBuilder,
|
||||
required ValueChanged<T> onToggle,
|
||||
double minWidth = 168,
|
||||
}) {
|
||||
final rect = _anchorRect(anchorContext);
|
||||
return Navigator.of(anchorContext).push<void>(_DsMenuRoute<void>(
|
||||
anchorRect: rect,
|
||||
minWidth: minWidth,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setState) => _DsMenuPanel(
|
||||
children: [
|
||||
for (final it in itemsBuilder())
|
||||
_DsMenuItemTile(
|
||||
item: it,
|
||||
onTap: () {
|
||||
onToggle(it.value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Rect _anchorRect(BuildContext anchorContext) {
|
||||
// 锚点矩形必须和菜单路由同坐标系:路由推到 Navigator.of(anchorContext),
|
||||
// 该导航器不一定占满屏幕(shell 分支导航器原点在侧栏右/顶栏下;
|
||||
// useRootNavigator:false 的抽屉同理),用全局坐标会整体漂移。
|
||||
final box = anchorContext.findRenderObject() as RenderBox;
|
||||
final navBox =
|
||||
Navigator.of(anchorContext).context.findRenderObject() as RenderBox?;
|
||||
final origin = box.localToGlobal(Offset.zero, ancestor: navBox);
|
||||
return origin & box.size;
|
||||
}
|
||||
|
||||
class _DsMenuRoute<T> extends PopupRoute<T> {
|
||||
final Rect anchorRect;
|
||||
final double minWidth;
|
||||
final WidgetBuilder builder;
|
||||
_DsMenuRoute({
|
||||
required this.anchorRect,
|
||||
required this.minWidth,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
Color? get barrierColor => Colors.transparent;
|
||||
@override
|
||||
bool get barrierDismissible => true;
|
||||
@override
|
||||
String? get barrierLabel => 'menu';
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
return CustomSingleChildLayout(
|
||||
delegate: _DsMenuLayout(anchorRect, minWidth),
|
||||
child: builder(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DsMenuLayout extends SingleChildLayoutDelegate {
|
||||
final Rect anchor;
|
||||
final double minWidth;
|
||||
_DsMenuLayout(this.anchor, this.minWidth);
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
final maxW = math.max(minWidth, constraints.maxWidth - 16);
|
||||
return BoxConstraints(
|
||||
minWidth: math.min(math.max(anchor.width, minWidth), maxW),
|
||||
maxWidth: maxW,
|
||||
maxHeight: math.max(0, constraints.maxHeight - 16),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final left = anchor.left
|
||||
.clamp(8.0, math.max(8.0, size.width - childSize.width - 8))
|
||||
.toDouble();
|
||||
final below = anchor.bottom + 6;
|
||||
final above = anchor.top - childSize.height - 6;
|
||||
final top =
|
||||
(below + childSize.height > size.height && above > 6) ? above : below;
|
||||
return Offset(left, top);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_DsMenuLayout old) =>
|
||||
old.anchor != anchor || old.minWidth != minWidth;
|
||||
}
|
||||
|
||||
/// .menu:surface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。
|
||||
class _DsMenuPanel extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _DsMenuPanel({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: t.shadow, offset: const Offset(0, 4), blurRadius: 14),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: IntrinsicWidth(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// .menu-item:gap9 / pad 8 10 / r-sm / fs-body,hover→bg;
|
||||
/// sel → 前导 ✓ + primary fw600;icon 颜色随文字(.mi-ic 继承 currentColor)。
|
||||
class _DsMenuItemTile extends StatelessWidget {
|
||||
final DsMenuItem<dynamic> item;
|
||||
final VoidCallback onTap;
|
||||
const _DsMenuItemTile({required this.item, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final fg = item.selected ? t.primary : t.text;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
hoverColor: t.bg,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: item.selected
|
||||
? Icon(LucideIcons.check, size: 16, color: t.primary)
|
||||
: (item.icon != null
|
||||
? Icon(item.icon, size: 16, color: fg)
|
||||
: null),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(item.label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: fg,
|
||||
fontWeight:
|
||||
item.selected ? FontWeight.w600 : FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user