Files
jiu/client/lib/widgets/ds/ds_atoms.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

590 lines
21 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_atoms.dart
// Flutter 组件库(ds=design-system)——一对一镜像原型 atoms.css 的原子。
// 单一组件源:尺寸/间距/圆角/字号全引 AppDims,颜色全引 context.tokens(禁硬编码)。
// 屏只组合这些组件,不再自己堆样式(见代码端真相源闸 check_ds_code)。
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';
import 'ds_menu.dart';
// ── .btn ──────────────────────────────────────────────────────────────────
enum DsBtnVariant { ghost, primary, danger, success, accent }
/// 原型 .btnh38 / pad 0 16 / r-md / fs-body / fw600 / gap7,图标 16。
/// ghost=surface+border / primary=primary+on-primary / danger=danger+白。
class DsButton extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final DsBtnVariant variant;
final bool small; // .btn.smh32 / pad 0 12 / fs-sm
final bool large; // .btn.lgh44 / pad 0 22 / fs14(登录/注册主按钮)
const DsButton(
this.label, {
super.key,
this.icon,
this.onPressed,
this.variant = DsBtnVariant.ghost,
this.small = false,
this.large = false,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final (bg, fg, border) = switch (variant) {
DsBtnVariant.ghost => (t.surface, t.text, t.border),
DsBtnVariant.primary => (t.primary, t.onPrimary, t.primary),
DsBtnVariant.danger => (t.danger, t.onPrimary, t.danger),
// 语义确认键(审核通过/撤回等),色取 token
DsBtnVariant.success => (t.success, t.onPrimary, t.success),
DsBtnVariant.accent => (t.accent, t.onPrimary, t.accent),
};
// disabledonPressed=null)置灰 55%:批量替换 Material 按钮后不能丢禁用反馈
return Opacity(
opacity: onPressed == null ? 0.55 : 1,
child: Material(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: small ? 32 : (large ? 44 : 38),
padding: EdgeInsets.symmetric(
horizontal: small ? 12 : (large ? 22 : 16)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppDims.rMd),
border: Border.all(color: border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 16, color: fg),
const SizedBox(width: 7),
],
// Flexible+ellipsis:宿主给紧约束(如 dev-card 底部 flex:1)时不溢出
Flexible(
child: Text(label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontSize: small
? AppDims.fsSm
: (large ? 14 : AppDims.fsBody),
fontWeight: FontWeight.w600)),
),
],
),
),
),
));
}
}
// ── .badge ────────────────────────────────────────────────────────────────
enum DsBadgeTone { ok, danger, warn, info, accent, muted }
/// 原型 .badgeh22 / pad 0 9 / r-pill / fs-sm fw600 + 前导圆点(6px, currentColor)。
class DsBadge extends StatelessWidget {
final String label;
final DsBadgeTone tone;
const DsBadge(this.label, {super.key, this.tone = DsBadgeTone.ok});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final (bg, fg) = switch (tone) {
DsBadgeTone.ok => (t.successBg, t.success),
DsBadgeTone.danger => (t.dangerBg, t.danger),
DsBadgeTone.warn => (t.warnBg, t.warn),
DsBadgeTone.info => (t.infoSoft, t.primary),
DsBadgeTone.accent => (t.accentSoft, t.accent),
DsBadgeTone.muted => (t.borderSubtle, t.muted),
};
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: fg, shape: BoxShape.circle)),
const SizedBox(width: 5),
Text(label,
style: TextStyle(
color: fg,
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600)),
],
),
);
}
}
// ── .chip ─────────────────────────────────────────────────────────────────
/// 原型 .chiph34 / pad 0 12 / border r-md / surface / fs-sm / gap7。
/// 选中值用 .cvprimary fw600);激活(on)边框 primary。配 PopupMenuButton 用。
class DsChip extends StatelessWidget {
final String label;
final String? value; // 选中值(.cv);非空即 on 态
final VoidCallback? onTap;
final VoidCallback? onClear; // 选中态下点 × 单独清除该筛选(不触发展开菜单)
// 纯切换 chip(原型 .chip.on,如 partners 类型筛选):selected 直接给 on 态,
// caret=false 去掉下拉箭头。
final bool selected;
final bool caret;
const DsChip(
{super.key,
required this.label,
this.value,
this.onTap,
this.onClear,
this.selected = false,
this.caret = true});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final on = selected || (value != null && value!.isNotEmpty);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 34,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: on ? t.primary : t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
if (value != null && value!.isNotEmpty) ...[
const SizedBox(width: 7),
Text(value!,
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.primary,
fontWeight: FontWeight.w600)),
],
// 选中且提供 onClear → 展示可点 ×(清该筛选);否则展示下拉箭头;
// 纯切换 chipcaret=false)无尾部图标。
if (on && onClear != null) ...[
const SizedBox(width: 7),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onClear,
child: Icon(LucideIcons.x, size: 14, color: t.muted),
),
] else if (caret) ...[
const SizedBox(width: 7),
Icon(LucideIcons.chevronDown, size: 14, color: t.faint),
],
],
),
),
);
}
}
// ── .seg ─────────────────────────────────────────────────────────────────
/// 原型 .seg:分段 tabinline-flex / bg / border r-md / pad 3 gap 3)。
/// button h30 / pad 0 16 / fs-sm fw600 muted;选中=surface 底 + primary 字 + sh-1。
class DsSeg extends StatelessWidget {
final List<String> items;
final int index;
final ValueChanged<int> onChanged;
const DsSeg(
{super.key,
required this.items,
required this.index,
required this.onChanged});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) const SizedBox(width: 3),
InkWell(
onTap: () => onChanged(i),
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.center,
decoration: BoxDecoration(
color: i == index ? t.surface : null,
borderRadius: BorderRadius.circular(AppDims.rSm),
// 原型 --sh-1: 0 1px 3px var(--shadow)
boxShadow: i == index
? [
BoxShadow(
color: t.shadow,
blurRadius: 3,
offset: const Offset(0, 1)),
]
: null,
),
child: Text(items[i],
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: i == index ? t.primary : t.muted)),
),
),
],
],
),
);
}
}
// ── .searchbox ──────────────────────────────────────────────────────────────
/// 原型 .searchboxh34 / bg / border r-md / gap8 / pad 0 11,前导放大镜 14(faint)。
/// 原型 .fieldlabelfs-sm muted)在控件上方、间距 6required 缀红星。
/// 对话框/面板表单统一用它排字段,禁止 Material labelText 浮动标签。
class DsField extends StatelessWidget {
final String label;
final bool required;
final Widget child;
const DsField(this.label,
{super.key, this.required = false, required Widget input})
: child = input;
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
if (required)
Text(' *',
style: TextStyle(fontSize: AppDims.fsSm, color: t.danger)),
]),
const SizedBox(height: 6),
child,
],
);
}
}
/// 原型 .check / .agreelogin.html / register.html):18×18 盒 + 1.5px 边 +
/// r-sm;选中 → primary 底/边 + 白勾(13, stroke2.4);文案 fs-body,间距 8。
/// [label] 用 Widget 以承载富文本(如注册页协议链接);[alignTop] 用于多行文案
/// register .agreealign-items:flex-start + 盒 margin-top 1)。
class DsCheck extends StatelessWidget {
final bool value;
final ValueChanged<bool> onChanged;
final Widget label;
final bool alignTop;
const DsCheck({
super.key,
required this.value,
required this.onChanged,
required this.label,
this.alignTop = false,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(!value),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
alignTop ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Container(
width: 18,
height: 18,
margin:
alignTop ? const EdgeInsets.only(top: 1) : EdgeInsets.zero,
alignment: Alignment.center,
decoration: BoxDecoration(
color: value ? t.primary : t.surface,
border:
Border.all(color: value ? t.primary : t.border, width: 1.5),
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: value
? Icon(LucideIcons.check, size: 13, color: t.onPrimary)
: null,
),
SizedBox(width: alignTop ? 9 : 8),
Flexible(child: label),
],
),
),
);
}
}
/// 原型 .inputh38 / surface / 1px border / r-md / pad 0 11 / fs-body
/// focus → primary 边;readonly/disabled → bg 底 + muted 字。
class DsInput extends StatelessWidget {
final TextEditingController? controller;
final bool enabled;
final String? hintText;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final TextInputType? keyboardType;
final FocusNode? focusNode;
final bool obscureText; // 密码框
final Widget? suffix; // 行内后缀(历史下拉箭头 / 密码可见切换)
const DsInput({
super.key,
this.controller,
this.enabled = true,
this.hintText,
this.onChanged,
this.onSubmitted,
this.keyboardType,
this.focusNode,
this.obscureText = false,
this.suffix,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
OutlineInputBorder line(Color c) => OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: c),
);
return SizedBox(
height: 38,
child: TextField(
controller: controller,
enabled: enabled,
onChanged: onChanged,
onSubmitted: onSubmitted,
keyboardType: keyboardType,
focusNode: focusNode,
obscureText: obscureText,
// letterSpacing 0M3 默认字距比原型(无字距)宽,数字/拉丁明显
style: TextStyle(
fontSize: AppDims.fsBody,
letterSpacing: 0,
color: enabled ? t.text : t.muted),
decoration: InputDecoration(
isDense: true,
// 装饰盒锁死 .input 的 38 高(不随 visualDensity 漂移)
constraints: const BoxConstraints.tightFor(height: 38),
filled: true,
fillColor: enabled ? t.surface : t.bg,
hintText: hintText,
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
contentPadding:
const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
suffixIcon: suffix == null
? null
: Padding(
padding: const EdgeInsets.only(right: 9), child: suffix),
suffixIconConstraints:
const BoxConstraints(minWidth: 0, minHeight: 0),
enabledBorder: line(t.border),
disabledBorder: line(t.border),
focusedBorder: line(t.primary),
border: line(t.border),
),
),
);
}
}
/// 原型 select.input 盒式外观(h38/surface/border/r-md/pad 0 11 + 右侧
/// chevron),点击弹 DsMenu.menu 弹层,含 ✓ 选中态、锚定坐标已对齐)。
/// 统一替代 Material DropdownButton——其默认灰底方角弹层不属 ds 体系。
class DsSelect<T> extends StatelessWidget {
final T? value;
final List<(T, String)> options;
final ValueChanged<T>? onChanged;
final String hint;
const DsSelect({
super.key,
required this.value,
required this.options,
required this.onChanged,
this.hint = '请选择…',
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final label =
options.where((o) => o.$1 == value).map((o) => o.$2).firstOrNull;
final enabled = onChanged != null;
return Builder(
builder: (bctx) => InkWell(
onTap: enabled
? () async {
final picked = await showDsMenu<T>(bctx, items: [
for (final o in options)
DsMenuItem(
value: o.$1, label: o.$2, selected: o.$1 == value),
]);
if (picked != null) onChanged!(picked);
}
: null,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: enabled ? t.surface : t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(label ?? hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
color: label == null
? t.faint
: (enabled ? t.text : t.muted))),
),
const SizedBox(width: 8),
Icon(LucideIcons.chevronDown, size: 14, color: t.muted),
]),
),
),
);
}
}
class DsSearchBox extends StatelessWidget {
final TextEditingController? controller;
final String hint;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final double? width;
const DsSearchBox({
super.key,
this.controller,
this.hint = '',
this.onChanged,
this.onSubmitted,
this.width,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return SizedBox(
width: width,
height: 34,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
children: [
Icon(LucideIcons.search, size: 14, color: t.faint),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
onSubmitted: onSubmitted,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
filled: false,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: hint,
hintStyle:
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
),
),
),
// 有输入时显示 × 清除(清空并触发一次空关键词搜索)。
if (controller != null)
ValueListenableBuilder<TextEditingValue>(
valueListenable: controller!,
builder: (context, value, _) {
if (value.text.isEmpty) return const SizedBox.shrink();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
controller!.clear();
onChanged?.call('');
onSubmitted?.call('');
},
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
),
);
},
),
],
),
),
);
}
}
/// 加载遮罩(reload 时叠在旧内容之上):轻微半透明底 + 屏幕中心进度圈。
/// 搜索/筛选刷新时用它替代整屏白屏(见列表屏 skipLoadingOnReload)。
class DsLoadingScrim extends StatelessWidget {
const DsLoadingScrim({super.key});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return GestureDetector(
behavior: HitTestBehavior.opaque, // 加载期间拦截点击,避免误触
onTap: () {},
child: ColoredBox(
color: t.bg.withValues(alpha: 0.55),
child: Center(
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(strokeWidth: 3, color: t.primary),
),
),
),
);
}
}