diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index a0af7c5..9491ac0 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -208,43 +208,43 @@ class _InventoryListScreenState extends ConsumerState { if (v != null && mounted) _applyStatusFilter(v); } - // ── 工具栏多选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移) ── - Widget _multiChip(String label, List options, Set selected, - ValueChanged> onChanged) { + // ── 工具栏单选 chip(规格/系列/仓库,2026-08-28 从多选改单选,用户口径) ── + // 状态仍存 Set(沿用后端 setSpec/setSeries 列表口径 + 仓库客户端 contains), + // 单选即「至多一项」:选中 → {v},× → {}。 + Widget _singleChip(String label, List options, Set selected, + ValueChanged> onChanged, + {String? searchHint}) { return Builder( builder: (chipCtx) => DsChip( label: label, - value: selected.isEmpty - ? null - : selected.length == 1 - ? selected.first - : '${selected.length} 项', - onTap: () => _openMultiFilter(chipCtx, - options: options, selected: selected, onChanged: onChanged), + value: selected.isEmpty ? null : selected.first, + onTap: () => _openSingleFilter(chipCtx, + options: options, + selected: selected, + onChanged: onChanged, + searchHint: searchHint), onClear: () => onChanged({}), ), ); } - // ── 多选菜单(规格/系列/仓库,chip 锚定) ── - void _openMultiFilter( + // ── 单选菜单(规格/系列可搜索、仓库普通,chip 锚定) ── + Future _openSingleFilter( BuildContext anchorContext, { required List options, required Set selected, required ValueChanged> onChanged, - }) { - var cur = Set.of(selected); - showDsMultiMenu( + String? searchHint, // 非空 → 可搜索单选下拉(规格/系列长名录) + }) async { + final v = await showDsMenu( anchorContext, - itemsBuilder: () => [ + searchHint: searchHint, + items: [ for (final o in options) - DsMenuItem(value: o, label: o, selected: cur.contains(o)), + DsMenuItem(value: o, label: o, selected: selected.contains(o)), ], - onToggle: (v) { - cur.contains(v) ? cur.remove(v) : cur.add(v); - onChanged(Set.of(cur)); - }, ); + if (v != null && mounted) onChanged({v}); } // ── 窄屏筛选 sheet(原型移动无列头漏斗 → 详搜钮开底部 sheet)── @@ -937,21 +937,21 @@ class _InventoryListScreenState extends ConsumerState { children: [ SizedBox(width: 260, child: searchField), const SizedBox(width: AppDims.sp2), - _multiChip('规格', specOptions, _filterSpec, (v) { + _singleChip('规格', specOptions, _filterSpec, (v) { setState(() => _filterSpec = v); ref .read(inventoryListProvider.notifier) .setSpec(v.toList()); - }), + }, searchHint: '搜索规格…'), const SizedBox(width: AppDims.sp2), - _multiChip('系列', seriesOptions, _filterSeries, (v) { + _singleChip('系列', seriesOptions, _filterSeries, (v) { setState(() => _filterSeries = v); ref .read(inventoryListProvider.notifier) .setSeries(v.toList()); - }), + }, searchHint: '搜索系列…'), const SizedBox(width: AppDims.sp2), - _multiChip('仓库', warehouseOptions, _filterWarehouse, + _singleChip('仓库', warehouseOptions, _filterWarehouse, (v) => setState(() => _filterWarehouse = v)), const SizedBox(width: AppDims.sp2), Builder( diff --git a/client/lib/widgets/ds/ds_menu.dart b/client/lib/widgets/ds/ds_menu.dart index 145fbb4..053874c 100644 --- a/client/lib/widgets/ds/ds_menu.dart +++ b/client/lib/widgets/ds/ds_menu.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../core/theme/app_tokens.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; @@ -25,28 +26,41 @@ class DsMenuItem { } /// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。 +/// +/// [searchHint] 非空 → 升级为「可搜索单选」下拉(镜像原型 openSearchMenu / .combo-pop, +/// 照 stock-out 客户筛选写法):顶部持久搜索框 + 边打边过滤 + 命中高亮 + 底部结果统计, +/// 点选即返回并关闭。宽度锚定触发器宽(`max(触发器宽, 220)` 固定,对齐原型 openSearchMenu +/// `w = max(r.width, 220)`)、列表 264 封顶,供规格/系列等长名录单选筛选复用。 Future showDsMenu( BuildContext anchorContext, { required List> items, double minWidth = 168, + String? searchHint, }) { final rect = _anchorRect(anchorContext); return Navigator.of(anchorContext).push(_DsMenuRoute( anchorRect: rect, - minWidth: minWidth, - builder: (ctx) => _DsMenuPanel( - children: [ - for (final it in items) - _DsMenuItemTile( - item: it, - onTap: () => Navigator.of(ctx).pop(it.value), + minWidth: searchHint != null ? math.max(minWidth, 220) : minWidth, + search: searchHint != null, + builder: (ctx) => searchHint != null + ? _DsSearchSinglePanel( + items: items, + hint: searchHint, + onPick: (v) => Navigator.of(ctx).pop(v), + ) + : _DsMenuPanel( + children: [ + for (final it in items) + _DsMenuItemTile( + item: it, + onTap: () => Navigator.of(ctx).pop(it.value), + ), + ], ), - ], - ), )); } -/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。 +/// 多选菜单(列设置):点击项切换选中、菜单保持打开;点外部关闭。 /// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。 Future showDsMultiMenu( BuildContext anchorContext, { @@ -89,10 +103,12 @@ Rect _anchorRect(BuildContext anchorContext) { class _DsMenuRoute extends PopupRoute { final Rect anchorRect; final double minWidth; + final bool search; final WidgetBuilder builder; _DsMenuRoute({ required this.anchorRect, required this.minWidth, + this.search = false, required this.builder, }); @@ -109,7 +125,7 @@ class _DsMenuRoute extends PopupRoute { Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return CustomSingleChildLayout( - delegate: _DsMenuLayout(anchorRect, minWidth), + delegate: _DsMenuLayout(anchorRect, minWidth, search), child: builder(context), ); } @@ -118,15 +134,23 @@ class _DsMenuRoute extends PopupRoute { class _DsMenuLayout extends SingleChildLayoutDelegate { final Rect anchor; final double minWidth; - _DsMenuLayout(this.anchor, this.minWidth); + final bool search; + _DsMenuLayout(this.anchor, this.minWidth, this.search); @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - final maxW = math.max(minWidth, constraints.maxWidth - 16); + final avail = constraints.maxWidth - 16; + final maxH = math.max(0.0, constraints.maxHeight - 16); + if (search) { + // 可搜索下拉:宽度锚定触发器宽(对齐原型 openSearchMenu `w = max(r.width, 220)`), + // tight 固定,避免 Expanded 搜索框撑满视口。触发器宽 → 弹层宽;窄 pill → 220。 + final w = math.min(math.max(anchor.width, minWidth), avail); + return BoxConstraints(minWidth: w, maxWidth: w, maxHeight: maxH); + } return BoxConstraints( - minWidth: math.min(math.max(anchor.width, minWidth), maxW), - maxWidth: maxW, - maxHeight: math.max(0, constraints.maxHeight - 16), + minWidth: math.min(math.max(anchor.width, minWidth), avail), + maxWidth: avail, + maxHeight: maxH, ); } @@ -144,7 +168,9 @@ class _DsMenuLayout extends SingleChildLayoutDelegate { @override bool shouldRelayout(_DsMenuLayout old) => - old.anchor != anchor || old.minWidth != minWidth; + old.anchor != anchor || + old.minWidth != minWidth || + old.search != search; } /// .menu:surface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。 @@ -224,3 +250,203 @@ class _DsMenuItemTile extends StatelessWidget { ); } } + +/// 可搜索单选面板(镜像原型 .combo-pop + openSearchMenu,照 stock-out 客户筛选): +/// 顶部 .cp-search 持久搜索框 → .cp-list 列表(命中子串高亮、选中项尾部 ✓、点选即返回并关闭) +/// → .cp-foot 结果统计(匹配 X 条 · 共 N 条)。供工具栏规格/系列等长名录单选筛选复用。 +class _DsSearchSinglePanel extends StatefulWidget { + final List> items; + final ValueChanged onPick; + final String hint; + const _DsSearchSinglePanel({ + required this.items, + required this.onPick, + required this.hint, + }); + + @override + State<_DsSearchSinglePanel> createState() => + _DsSearchSinglePanelState(); +} + +class _DsSearchSinglePanelState extends State<_DsSearchSinglePanel> { + final _ctrl = TextEditingController(); + final _focus = FocusNode(); + String _kw = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance + .addPostFrameCallback((_) => _focus.requestFocus()); + } + + @override + void dispose() { + _ctrl.dispose(); + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final all = widget.items; + final q = _kw.trim().toLowerCase(); + final hits = + q.isEmpty ? all : all.where((it) => it.label.toLowerCase().contains(q)).toList(); + return Material( + color: Colors.transparent, + child: Container( + clipBehavior: Clip.antiAlias, + 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: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // .cp-search + 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: TextField( + controller: _ctrl, + focusNode: _focus, + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + cursorColor: t.primary, + onChanged: (v) => setState(() => _kw = v), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: widget.hint, + hintStyle: TextStyle( + fontSize: AppDims.fsBody, color: t.faint), + ), + ), + ), + ], + ), + ), + // .cp-list(overflow auto,max-height:264 → 超出滚动、foot 常驻可见;空 → .cp-empty) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 264), + child: hits.isEmpty + ? Padding( + padding: const EdgeInsets.all(20), + child: Text('无匹配结果', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: AppDims.fsSm, color: t.muted)), + ) + : ListView.builder( + padding: const EdgeInsets.all(5), + shrinkWrap: true, + itemCount: hits.length, + itemBuilder: (c, i) => _item(t, hits[i]), + ), + ), + // .cp-foot + Container( + padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), + decoration: BoxDecoration( + color: t.bg, + border: Border(top: BorderSide(color: t.borderSubtle)), + ), + child: _foot(t, hits.length, all.length, q.isNotEmpty), + ), + ], + ), + ), + ); + } + + // .cp-item:点选即回调 onPick(其内 Navigator.pop 关闭菜单)→ 单选。 + Widget _item(AppTokens t, DsMenuItem it) { + final fg = it.selected ? t.primary : t.text; + return InkWell( + onTap: () => widget.onPick(it.value), + hoverColor: t.rowHover, + borderRadius: BorderRadius.circular(AppDims.rSm), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Expanded(child: _highlight(t, it.label, fg, it.selected)), + if (it.selected) ...[ + const SizedBox(width: 9), + Icon(LucideIcons.check, size: 15, color: t.primary), + ], + ], + ), + ), + ); + } + + // 命中子串高亮(.hl:primary fw700) + Widget _highlight(AppTokens t, String name, Color fg, bool sel) { + final base = TextStyle( + fontSize: AppDims.fsBody, + color: fg, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400); + final kw = _kw.trim(); + final i = + kw.isEmpty ? -1 : name.toLowerCase().indexOf(kw.toLowerCase()); + if (i < 0) { + return Text(name, + maxLines: 1, overflow: TextOverflow.ellipsis, style: base); + } + return RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan(style: base, children: [ + TextSpan(text: name.substring(0, i)), + TextSpan( + text: name.substring(i, i + kw.length), + style: base.copyWith( + color: t.primary, fontWeight: FontWeight.w700)), + TextSpan(text: name.substring(i + kw.length)), + ]), + ); + } + + // .cp-foot:q 空 → 共 N 条;有关键字 → 匹配 X 条 · 共 N 条。数字 mono 加粗。 + Widget _foot(AppTokens t, int hits, int total, bool filtered) { + final base = TextStyle(fontSize: AppDims.fsXs, color: t.muted); + final b = base.copyWith( + color: t.text, + fontWeight: FontWeight.w600, + fontFamily: 'JetBrainsMono'); + return RichText( + text: TextSpan(style: base, children: [ + if (filtered) ...[ + const TextSpan(text: '匹配 '), + TextSpan(text: '$hits', style: b), + const TextSpan(text: ' 条 · 共 '), + TextSpan(text: '$total', style: b), + const TextSpan(text: ' 条'), + ] else ...[ + const TextSpan(text: '共 '), + TextSpan(text: '$total', style: b), + const TextSpan(text: ' 条'), + ], + ]), + ); + } +} diff --git a/design/prototype/screens/inventory.html b/design/prototype/screens/inventory.html index 7688dd0..aef9a40 100644 --- a/design/prototype/screens/inventory.html +++ b/design/prototype/screens/inventory.html @@ -126,17 +126,17 @@ const ITEMS=[ const STATUS=['全部','在售','库存','已售']; // 成本价/总价列 = 成本口径,仅管理员可见(非管理员两列隐藏、列设置菜单不出现,2026-07-06);原型=管理员形态 const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'prodDate',label:'生产日期',sort:true},{key:'qty',label:'库存',num:true,sort:true},{key:'cost',label:'成本价',num:true,sort:true},{key:'price',label:'总价',num:true,sort:true},{key:'batch',label:'批次号'},{key:'inDate',label:'入库时间',sort:true},{key:'supplier',label:'供应商'},{key:'status',label:'状态'},{key:'act',label:'操作',fixed:true}]; -const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true,spec:new Set(),series:new Set(),wh:new Set()}; +const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true,spec:'',series:'',wh:''}; function setActive(k){ document.querySelectorAll('.nav[data-k]').forEach(e=>e.classList.toggle('active',e.dataset.k===k)); } function navTo(k){ setActive(k); const inv=document.querySelector('[data-view="inventory"]'), ph=document.querySelector('[data-view="placeholder"]'); if(k==='inventory'){inv.classList.add('active');ph.classList.remove('active');return;} inv.classList.remove('active');ph.classList.add('active'); const nav=NAVS.find(n=>n.k===k); document.getElementById('phTitle').textContent=nav.label; document.getElementById('phIcon').innerHTML=navSvg(nav.d); } function visibleCols(){ return COLS.filter(c=>!state.hidden.has(c.key)); } -function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec.size&&!state.spec.has(it.spec))return false; if(state.series.size&&!state.series.has(it.series))return false; if(state.wh.size&&!state.wh.has(it.supplier))return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (xy?1:0)*(state.sortAsc?1:-1);});} return rows; } +function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec&&it.spec!==state.spec)return false; if(state.series&&it.series!==state.series)return false; if(state.wh&&it.supplier!==state.wh)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (xy?1:0)*(state.sortAsc?1:-1);});} return rows; } function render(){ document.getElementById('statusVal').textContent=state.status==='全部'?'':state.status; - // 多选摘要(镜像 DsChip value):1 项=值本身,多项=「N 项」 - [['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ const s=state[k]; document.getElementById(vid).textContent=!s.size?'':(s.size===1?[...s][0]:`${s.size} 项`); document.getElementById(cid).classList.toggle('on',!!s.size); }); + // 单选摘要(镜像 DsChip value):选中值本身,未选为空 + [['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ const s=state[k]; document.getElementById(vid).textContent=s||''; document.getElementById(cid).classList.toggle('on',!!s); }); document.getElementById('chipStatus').classList.toggle('on',state.status!=='全部'); const cols=visibleCols(); document.getElementById('thead').innerHTML=''+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `${c.label} `;} if(c.sort){const on=state.sortBy===c.key; const ic=!on?'i-chevron-down':(state.sortAsc?'i-ic51':'i-chevron-down'); return `${c.label} `;} return `${c.label}`; }).join('')+''; @@ -155,14 +155,19 @@ function filterStatus(s){ state.status=s; state.page=1; navTo('inventory'); rend // 列头排序:无→升→降→无 循环(真实实现为服务端全局排序) function toggleSort(k){ if(state.sortBy!==k){state.sortBy=k;state.sortAsc=true;} else if(state.sortAsc){state.sortAsc=false;} else {state.sortBy=null;state.sortAsc=true;} state.page=1; render(); } const SORT_VAL={qty:it=>it.qty,cost:it=>parseFloat(String(it.cost).replace(/[¥,]/g,''))||0,price:it=>parseFloat(String(it.price).replace(/[¥,]/g,''))||0,prodDate:it=>it.prodDate||'',inDate:it=>it.inDate||''}; -function clearFilters(){ state.q='';state.status='全部';state.spec.clear();state.series.clear();state.wh.clear();state.page=1; document.getElementById('searchInput').value=''; render(); } +function clearFilters(){ state.q='';state.status='全部';state.spec='';state.series='';state.wh='';state.page=1; document.getElementById('searchInput').value=''; render(); } function openFilter(kind,t){ openMenu(t,STATUS.map(v=>({v,label:v,sel:v===state.status})),v=>{ state.status=v; state.page=1; closeMenus(); render(); }); } -// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移):多选,点选即生效、菜单保持打开(镜像 showDsMultiMenu) +// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移):均为单选(2026-08-28 用户口径)。 +// 规格/系列名录长 → 可搜索单选下拉(openSearchMenu,照 stock-out 客户筛选写法); +// 仓库名录短 → 普通单选下拉(openMenu,无搜索框)。 const DIM_KEY={spec:'spec',series:'series',wh:'supplier'}; -function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=[...new Set(ITEMS.map(it=>it[key]).filter(Boolean))]; openMenu(t,opts.map(v=>({v,label:v,sel:state[dim].has(v)})),v=>{ state[dim].has(v)?state[dim].delete(v):state[dim].add(v); state.page=1; render(); openDimFilter(dim,t); }); } +const DIM_PH={spec:'搜索规格…',series:'搜索系列…'}; +function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=[...new Set(ITEMS.map(it=>it[key]).filter(Boolean))]; + if(dim==='wh'){ openMenu(t,opts.map(v=>({v,label:v,sel:v===state.wh})),v=>{ state.wh=v; state.page=1; closeMenus(); render(); }); return; } + openSearchMenu(t,opts.map(v=>({v,label:v,sel:v===state[dim]})),v=>{ state[dim]=v; state.page=1; closeMenus(); render(); },{placeholder:DIM_PH[dim]}); } // chip 尾部 × 单清(镜像 DsChip onClear:只清本维度,不展开菜单) -function clearDim(dim,e){ e.stopPropagation(); state[dim].clear(); state.page=1; render(); } +function clearDim(dim,e){ e.stopPropagation(); state[dim]=''; state.page=1; render(); } function clearStatus(e){ e.stopPropagation(); state.status='全部'; state.page=1; render(); } document.getElementById('chipStatus').onclick=e=>openFilter('status',e.currentTarget); const COL_KEY='jiu-inv-hidden'; diff --git a/design/prototype/screens/shell.js b/design/prototype/screens/shell.js index 0de8383..59e4df1 100644 --- a/design/prototype/screens/shell.js +++ b/design/prototype/screens/shell.js @@ -56,7 +56,7 @@ // 可搜索菜单:视觉承袭 .combo-pop(顶部 .cp-search 显式搜索框 + 边打边过滤 + 结果统计), // 供列表工具栏「供应商 / 客户」等长名录筛选复用(复用 atoms.css .combo-pop 族,零新增样式)。 - // items: [{ v, label, sel, small? }];opts: { placeholder, minW, cls }。 + // items: [{ v, label, sel, small? }];opts: { placeholder, minW, cls }。单选:点选后由 onPick 内自行 closeMenus。 function openSearchMenu(t, items, onPick, opts) { closeMenus(); opts = opts || {};