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,737 @@
|
||||
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;
|
||||
|
||||
/// 单据头 combo(cell=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;
|
||||
|
||||
/// Tab(shift=后退);返回 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user