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

738 lines
24 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.
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../searchable_option_field.dart' show OptionItem;
import '../../core/theme/app_fonts.dart';
/// 内联可键盘操作的下拉选择格(对齐原型 `.combo` / `.combo.cell` + `.combo-pop`)。
///
/// - 聚焦即打开浮层;输入即过滤(名称 / 全拼 / 首字母 / 编码)。
/// - ↑↓ 移动高亮,Enter 选中并回调 [onPickedByKeyboard](供上层推进到下一格),
/// Esc 关闭,Tab 交由上层 [onTab] 决定前进/后退。
/// - [allowCreate] 时,输入无匹配可「新增到基础数据」,走 [onCreate]。
/// - [commonIds] 命中的选项在无关键字时置顶为「常用」组(对齐系列/规格默认)。
class GridComboCell extends StatefulWidget {
final FocusNode focusNode;
final List<OptionItem> options;
final int? selectedId;
final String hint;
/// true=网格单元格样式(透明、32 高);false=独立表单 combo(描边、38 高)。
final bool cell;
/// 浮层是否置顶持久搜索框(对齐原型 .combo-pop > .cp-search,触发框只读展示)。
/// null=沿用默认(单据头 combo 用弹层搜索、网格 combo 用单元格内联输入);
/// 传 true 可让网格单元格也走弹层搜索(如入库明细的商品名/系列/规格)。
final bool? popupSearch;
final bool hasError;
final bool enabled;
final bool allowCreate;
/// 无关键字时置顶显示的「常用」选项 id(如按商品名带出的默认系列/规格)。
final Set<int> commonIds;
/// 选项数、供应商/客户等大字典时置底显示计数(对齐原型单据头 combo 的 cp-foot)。
final bool showCount;
/// 单据头 combocell=false)浮层顶部 .cp-search 的占位文案;空则回退到 [hint]。
final String? searchHint;
/// 服务端搜索(供供应商/客户/库存商品等);提供时输入 debounce 调它取结果。
final Future<List<OptionItem>> Function(String keyword)? onSearch;
/// 新增到基础数据;返回新选项 id(自动选中)。
final Future<int?> Function(String keyword)? onCreate;
final void Function(int? id) onChanged;
/// 通过键盘 Enter 选中后触发(上层据此推进到下一格 / 加行)。
final VoidCallback? onPickedByKeyboard;
/// Tabshift=后退);返回 true 表示已处理。
final bool Function({required bool backward})? onTab;
const GridComboCell({
super.key,
required this.focusNode,
required this.options,
required this.selectedId,
required this.hint,
required this.onChanged,
this.cell = false,
this.popupSearch,
this.hasError = false,
this.enabled = true,
this.allowCreate = false,
this.commonIds = const {},
this.showCount = false,
this.searchHint,
this.onSearch,
this.onCreate,
this.onPickedByKeyboard,
this.onTab,
});
@override
State<GridComboCell> createState() => _GridComboCellState();
}
/// 浮层里可高亮/选中的一项:既有选项,或「新增」行。
class _PopEntry {
final OptionItem? opt; // null → 新增行
final String? createKw;
const _PopEntry.opt(this.opt) : createKw = null;
const _PopEntry.create(this.createKw) : opt = null;
bool get isCreate => opt == null;
}
class _GridComboCellState extends State<GridComboCell> {
final _ctrl = TextEditingController();
final _link = LayerLink();
final _listScroll = ScrollController();
// 单据头 combo:浮层顶部持久搜索框的焦点(网格 combo 不用,走单元格内联输入)。
final _searchFocus = FocusNode();
OverlayEntry? _entry;
Timer? _debounce;
/// 弹层搜索模式:浮层置顶显式搜索框,触发框保持只读展示(对齐原型 scope==='doc');
/// 反之走网格单元格内联输入过滤。默认单据头(cell=false)开、网格(cell=true)关,
/// 可由 [GridComboCell.popupSearch] 显式覆盖。
bool get _docHead => widget.popupSearch ?? !widget.cell;
bool _open = false;
String _kw = '';
int _cursor = -1;
List<_PopEntry> _entries = const [];
List<OptionItem>? _remote; // onSearch 结果;null=用本地 options
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocusChange);
}
@override
void didUpdateWidget(GridComboCell old) {
super.didUpdateWidget(old);
if (old.focusNode != widget.focusNode) {
old.focusNode.removeListener(_onFocusChange);
widget.focusNode.addListener(_onFocusChange);
}
if (_open) _rebuildEntries();
}
@override
void dispose() {
_debounce?.cancel();
widget.focusNode.removeListener(_onFocusChange);
_removeOverlay();
_ctrl.dispose();
_searchFocus.dispose();
_listScroll.dispose();
super.dispose();
}
String get _selectedName =>
widget.options
.where((o) => o.id == widget.selectedId)
.firstOrNull
?.name ??
'';
void _onFocusChange() {
if (widget.focusNode.hasFocus) {
_openPop();
if (_docHead) {
// 触发框获焦即打开浮层,随后把输入焦点移进浮层顶部搜索框。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_open && mounted) _searchFocus.requestFocus();
});
}
} else if (!_docHead) {
// 网格 combo:单元格失焦即收起。弹层搜索 combo 的收起改由 选中 / Esc / Tab /
// 点浮层外(TapRegion.onTapOutside)显式驱动——不再监听搜索框失焦,
// 避免点选项时「按下即失焦→微任务提前拆浮层→onTap 落空」的竞态(选中不填充)。
_closePop();
}
}
void _dismiss() {
_searchFocus.unfocus();
widget.focusNode.unfocus();
_closePop();
}
// ── 浮层开合 ────────────────────────────────────────────────────────────
void _openPop() {
if (_open || !widget.enabled) return;
_open = true;
_kw = '';
_remote = null;
_ctrl.text = '';
_rebuildEntries();
_entry = OverlayEntry(builder: _buildPop);
Overlay.of(context).insert(_entry!);
}
void _closePop() {
if (!_open) return;
_open = false;
_debounce?.cancel();
_ctrl.text = '';
_removeOverlay();
if (mounted) setState(() {});
}
void _removeOverlay() {
_entry?.remove();
_entry = null;
}
List<OptionItem> get _sourceOptions => _remote ?? widget.options;
void _rebuildEntries() {
final kw = _kw.trim();
final matched = kw.isEmpty
? _sourceOptions
: _sourceOptions.where((o) => o.matches(kw)).toList();
final list = <_PopEntry>[];
for (final o in matched) {
list.add(_PopEntry.opt(o));
}
final canCreate = widget.allowCreate &&
widget.onCreate != null &&
kw.isNotEmpty &&
!_sourceOptions.any((o) => o.name == kw);
if (canCreate) list.add(_PopEntry.create(kw));
_entries = list;
// 当前高亮:选中项优先,否则首项
_cursor =
list.indexWhere((e) => !e.isCreate && e.opt!.id == widget.selectedId);
if (_cursor < 0) _cursor = list.isEmpty ? -1 : 0;
_entry?.markNeedsBuild();
}
void _onInput(String v) {
_kw = v;
if (widget.onSearch != null) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 250), () async {
final res = await widget.onSearch!(v.trim());
if (!mounted || !_open) return;
_remote = res;
_rebuildEntries();
});
// 本地即时反馈(用已有 options 过滤)
_rebuildEntries();
} else {
_rebuildEntries();
}
}
void _move(int d) {
if (_entries.isEmpty) return;
_cursor = (_cursor + d + _entries.length) % _entries.length;
_entry?.markNeedsBuild();
_scrollToCursor();
}
void _scrollToCursor() {
if (!_listScroll.hasClients || _cursor < 0) return;
const itemH = 36.0;
final target = _cursor * itemH;
final vp = _listScroll.position.viewportDimension;
final off = _listScroll.offset;
if (target < off) {
_listScroll.jumpTo(target);
} else if (target + itemH > off + vp) {
_listScroll.jumpTo(
(target + itemH - vp).clamp(0, _listScroll.position.maxScrollExtent));
}
}
Future<void> _pickCursor({required bool byKeyboard}) async {
if (_cursor < 0 || _cursor >= _entries.length) return;
final e = _entries[_cursor];
if (e.isCreate) {
final id = await widget.onCreate!(e.createKw!);
if (id != null) {
widget.onChanged(id);
if (byKeyboard) widget.onPickedByKeyboard?.call();
}
_dismiss();
return;
}
widget.onChanged(e.opt!.id);
_dismiss();
if (byKeyboard) widget.onPickedByKeyboard?.call();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
if (!_open) {
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.arrowDown) {
_openPop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
if (key == LogicalKeyboardKey.arrowDown) {
_move(1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowUp) {
_move(-1);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) {
_pickCursor(byKeyboard: true);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.escape) {
_dismiss();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.tab) {
final backward = HardwareKeyboard.instance.isShiftPressed;
// 先按当前高亮选中(若有),再收起本浮层并交给上层移动焦点。
// 收起必须显式做:已移除搜索框失焦监听,Tab 走后不再自动关闭。
if (_cursor >= 0 && !_entries[_cursor].isCreate) {
widget.onChanged(_entries[_cursor].opt!.id);
}
_closePop();
final handled = widget.onTab?.call(backward: backward) ?? false;
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
}
void _clear() {
widget.onChanged(null);
}
// ── 浮层内容 ───────────────────────────────────────────────────────────
Widget _buildPop(BuildContext ctx) {
final t = context.tokens;
final field = context.findRenderObject() as RenderBox?;
final width = (field?.size.width ?? 200).clamp(200.0, 420.0);
return Positioned(
width: width,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 4),
child: TapRegion(
onTapOutside: (_) => _dismiss(),
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(AppDims.rMd),
color: t.surface,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_docHead) _buildSearch(t),
Flexible(child: _buildList(t)),
if (widget.showCount) _buildCount(t),
],
),
),
),
),
),
);
}
// 浮层顶部持久搜索框(对齐原型 .combo-pop > .cp-search,仅单据头 combo)。
Widget _buildSearch(AppTokens t) {
final hint = widget.searchHint ?? widget.hint;
return Container(
padding: const EdgeInsets.fromLTRB(11, 9, 11, 9),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
Icon(LucideIcons.search, size: 14, color: t.faint),
const SizedBox(width: 8),
Expanded(
child: Focus(
canRequestFocus: false,
onKeyEvent: _onKey,
child: TextField(
controller: _ctrl,
focusNode: _searchFocus,
autofocus: true,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
cursorColor: t.primary,
onChanged: _onInput,
onSubmitted: (_) => _pickCursor(byKeyboard: true),
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),
),
),
),
),
],
),
);
}
Widget _buildList(AppTokens t) {
if (_entries.isEmpty) {
return Padding(
padding: const EdgeInsets.all(20),
child: Text('无匹配结果',
textAlign: TextAlign.center,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
);
}
final kw = _kw.trim();
final commonMode = kw.isEmpty && widget.commonIds.isNotEmpty;
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 264),
child: ListView.builder(
controller: _listScroll,
padding: const EdgeInsets.all(5),
shrinkWrap: true,
itemCount: _entries.length + (commonMode ? _groupHeaderCount() : 0),
itemBuilder: (c, i) => _rowAt(t, i, commonMode, kw),
),
);
}
// 「常用 / 全部」分组时插入两个 header,索引需换算。
int _commonCount() => _entries
.where((e) => !e.isCreate && widget.commonIds.contains(e.opt!.id))
.length;
int _groupHeaderCount() {
final c = _commonCount();
final r = _entries.where((e) => !e.isCreate).length - c;
return (c > 0 ? 1 : 0) + (r > 0 ? 1 : 0);
}
Widget _rowAt(AppTokens t, int i, bool commonMode, String kw) {
if (!commonMode) return _entryRow(t, i, kw);
// 分组渲染:常用组 header + 常用项,全部组 header + 其余项
final common = <int>[];
final rest = <int>[];
for (var k = 0; k < _entries.length; k++) {
final e = _entries[k];
if (e.isCreate) {
rest.add(k);
} else if (widget.commonIds.contains(e.opt!.id)) {
common.add(k);
} else {
rest.add(k);
}
}
final seq = <Widget Function()>[];
if (common.isNotEmpty) {
seq.add(() => _groupHeader(t, '常用'));
for (final k in common) {
seq.add(() => _entryRow(t, k, kw));
}
}
if (rest.isNotEmpty) {
seq.add(() => _groupHeader(t, '全部'));
for (final k in rest) {
seq.add(() => _entryRow(t, k, kw));
}
}
return seq[i]();
}
Widget _groupHeader(AppTokens t, String label) => Padding(
padding: const EdgeInsets.fromLTRB(9, 8, 9, 4),
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
fontWeight: FontWeight.w600,
letterSpacing: 0.5)),
);
Widget _entryRow(AppTokens t, int idx, String kw) {
final e = _entries[idx];
final cur = idx == _cursor;
if (e.isCreate) {
return _PopRow(
cur: cur,
onTap: () => _pickCursor(byKeyboard: false),
onHover: () => _setCursor(idx),
topBorder: t.borderSubtle,
child: Row(children: [
Icon(LucideIcons.plus, size: 14, color: t.primary),
const SizedBox(width: 8),
Expanded(
child: Text('新增「${e.createKw}」到基础数据',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.primary,
fontWeight: FontWeight.w600)),
),
]),
);
}
final o = e.opt!;
final sel = o.id == widget.selectedId;
return _PopRow(
cur: cur,
onTap: () {
_setCursor(idx);
_pickCursor(byKeyboard: false);
},
onHover: () => _setCursor(idx),
child: Row(children: [
Expanded(
child: _highlight(o.name, kw, t, sel ? t.primary : t.text,
sel ? FontWeight.w600 : FontWeight.w400),
),
if (o.code != null && o.code!.isNotEmpty) ...[
const SizedBox(width: 12),
Text(o.code!,
style: TextStyle(
fontSize: AppDims.fsXs,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: t.faint)),
],
if (sel) ...[
const SizedBox(width: 8),
Icon(LucideIcons.check, size: 15, color: t.primary),
],
]),
);
}
void _setCursor(int i) {
if (_cursor == i) return;
_cursor = i;
_entry?.markNeedsBuild();
}
Widget _highlight(
String name, String kw, AppTokens t, Color base, FontWeight w) {
if (kw.isEmpty) {
return Text(name,
overflow: TextOverflow.ellipsis,
style:
TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w));
}
final lower = name.toLowerCase();
final i = lower.indexOf(kw.toLowerCase());
if (i < 0) {
return Text(name,
overflow: TextOverflow.ellipsis,
style:
TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w));
}
return RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: TextStyle(fontSize: AppDims.fsBody, color: base, fontWeight: w),
children: [
TextSpan(text: name.substring(0, i)),
TextSpan(
text: name.substring(i, i + kw.length),
style: TextStyle(color: t.primary, fontWeight: FontWeight.w700)),
TextSpan(text: name.substring(i + kw.length)),
],
),
);
}
Widget _buildCount(AppTokens t) {
final total = _sourceOptions.length;
final kw = _kw.trim();
final matched = _entries.where((e) => !e.isCreate).length;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: t.bg,
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Text(
kw.isEmpty ? '$total 条结果' : '匹配 $matched 条 · 共 $total',
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted),
),
);
}
// ── 输入框本体 ─────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final t = context.tokens;
final has = widget.selectedId != null;
final height = widget.cell ? 32.0 : 38.0;
final borderColor = widget.hasError
? t.danger
: (_open ? t.primary : (widget.cell ? Colors.transparent : t.border));
return CompositedTransformTarget(
link: _link,
child: Container(
height: height,
decoration: BoxDecoration(
color: widget.cell
? (_open ? t.surface : Colors.transparent)
: t.surface,
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(widget.cell ? 5 : AppDims.rMd),
boxShadow: _open
? [
BoxShadow(
color: t.brand50,
blurRadius: 0,
spreadRadius: widget.cell ? 2 : 3)
]
: null,
),
child: Row(
children: [
Expanded(
child: Focus(
focusNode: widget.focusNode,
onKeyEvent: _onKey,
child: Builder(builder: (context) {
// 单据头 combo:触发框始终只读展示(输入在浮层顶部搜索框);
// 网格 combo:聚焦时就地变为可输入过滤框。
if (!_open || _docHead) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.enabled
? () => widget.focusNode.requestFocus()
: null,
child: Padding(
padding: EdgeInsets.only(
left: widget.cell ? 8 : 11, right: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
has ? _selectedName : widget.hint,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
color: has ? t.text : t.faint),
),
),
),
);
}
return TextField(
controller: _ctrl,
autofocus: true,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
cursorColor: t.primary,
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
EdgeInsets.only(left: widget.cell ? 8 : 11, right: 4),
border: InputBorder.none,
filled: false,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: has ? _selectedName : widget.hint,
hintStyle:
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
),
onChanged: _onInput,
);
}),
),
),
// caf:有值→X 清除;无值→chevron
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.enabled
? () {
if (has) {
_clear();
} else {
widget.focusNode.requestFocus();
}
}
: null,
child: Padding(
padding: EdgeInsets.only(right: widget.cell ? 6 : 8, left: 2),
child: Icon(
has ? LucideIcons.x : LucideIcons.chevronDown,
size: 14,
color: t.faint,
),
),
),
],
),
),
);
}
}
class _PopRow extends StatelessWidget {
final bool cur;
final Widget child;
final VoidCallback onTap;
final VoidCallback onHover;
final Color? topBorder;
const _PopRow({
required this.cur,
required this.child,
required this.onTap,
required this.onHover,
this.topBorder,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return MouseRegion(
onEnter: (_) => onHover(),
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: cur ? t.rowHover : Colors.transparent,
borderRadius: BorderRadius.circular(AppDims.rSm),
border: topBorder != null
? Border(top: BorderSide(color: topBorder!))
: null,
),
child: child,
),
),
);
}
}