feat(client): 库存/盘点/财务窄屏对齐移动原型(KPI 网格/卡片流/图标徽章/sheet)

- 库存列表:窄屏隐藏页内大标题头;KPI 2×2 MKpiGrid(缺货卡 warn+点击筛选);
  MSearchRow(详搜钮开筛选 sheet:状态/规格/系列);卡片徽章换图标变体
- 库存盘点:窄屏卡片流(单号/范围·项数/进行中徽章带图标/差异/日期)+
  详情 sheet(drow 键值行)+ 底部操作条;DateTime.now→appNow 可注入时钟
- 财务:窄屏隐藏大标题头;KPI 2×2(收入 up 绿/支出 down 红/应收 warn);
  DsSeg 应收/应付/流水三分段 + 卡片流(类型徽章带图标、金额正负色)
- golden:新增 m_inventory / m_inventory_check / m_finance 三屏×三主题
  (390×844@2x);重生成既有 inventory_list_mobile / finance_mobile 基准
  (桌面 golden 零改动)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 12:18:17 +08:00
parent ff7b4194ec
commit dde87a32b6
21 changed files with 1055 additions and 117 deletions
@@ -22,6 +22,8 @@ import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_bar_chart.dart';
import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/finance_entry_dialog.dart';
import '../../widgets/finance_partner_drawer.dart';
import '../../widgets/mobile_list_card.dart';
@@ -42,6 +44,8 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
String _rangeLabel = '本月';
// 流水类型筛选(原型 3 chips 扩到 5)
String _flowChip = '全部';
// 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水)
int _mSeg = 0;
static const _flowChips = ['全部', '应收', '应付', '收款', '付款'];
static const _chipToType = {
@@ -139,6 +143,40 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
final flowsAsync = ref.watch(financeListProvider);
final flows = flowsAsync.valueOrNull?.data ?? const <FinanceRecord>[];
// 窄屏(原型 m-finance):隐藏页内大标题头,KPI 2×2 网格 + 三分段 + 卡片流。
if (mobile) {
final content = Container(
color: t.bg,
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDims.sp4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_timeRow(t, true),
_mKpis(t),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DsSeg(
items: const ['应收', '应付', '流水'],
index: _mSeg,
onChanged: (i) => setState(() => _mSeg = i)),
),
),
..._mSegBody(t, flows),
],
),
),
);
return Stack(children: [
content,
if (flowsAsync.isLoading)
const Positioned.fill(child: DsLoadingScrim()),
]);
}
final content = Container(
color: t.bg,
child: SingleChildScrollView(
@@ -645,4 +683,184 @@ class _FinanceScreenState extends ConsumerState<FinanceScreen> {
: null,
);
}
// ── 窄屏形态(原型 m-finance)──────────────────────────────────────
/// delta 箭头/色调映射(与 DsKpi 同规则:▲/▼ 文本字符)。
String _mDeltaText(String text, DsKpiDelta tone) => switch (tone) {
DsKpiDelta.up => '$text',
DsKpiDelta.down => '$text',
DsKpiDelta.neutral => text,
};
MKpiDeltaTone _mTone(DsKpiDelta tone) => switch (tone) {
DsKpiDelta.up => MKpiDeltaTone.up,
DsKpiDelta.down => MKpiDeltaTone.down,
DsKpiDelta.neutral => MKpiDeltaTone.normal,
};
/// 窄屏 KPI 2×2(原型 .m-kpi:本月收入 / 本月支出 / 应收合计 / 应付合计)。
Widget _mKpis(dynamic t) {
final outSum = ref.watch(stockOutSummaryProvider).valueOrNull;
final inSum = ref.watch(stockInSummaryProvider).valueOrNull;
final rows = ref.watch(financePartnerRowsProvider).valueOrNull ??
const <PartnerFinanceRow>[];
var recv = 0.0, pay = 0.0, openCount = 0;
for (final r in rows) {
recv += r.recv;
pay += r.pay;
openCount += r.openCount;
}
final (saleDelta, saleTone) = outSum != null
? _momDelta(outSum.monthAmount, outSum.lastMonthAmount)
: ('较上月 —', DsKpiDelta.neutral);
final (buyDelta, buyTone) = inSum != null
? _momDelta(inSum.monthAmount, inSum.lastMonthAmount)
: ('较上月 —', DsKpiDelta.neutral);
return MKpiGrid(items: [
MKpiItem(
label: '本月收入',
value: outSum != null ? _yuanWan(outSum.monthAmount) : '',
delta: _mDeltaText(saleDelta, saleTone),
deltaTone: _mTone(saleTone),
),
MKpiItem(
label: '本月支出',
value: inSum != null ? _yuanWan(inSum.monthAmount) : '',
delta: _mDeltaText(buyDelta, buyTone),
deltaTone: _mTone(buyTone),
),
// 原型 delta「12 笔未结」:应收/应付分侧笔数无数据源 → 用未结清总笔数(已知差异)
MKpiItem(
label: '应收合计',
value: _yuanWan(recv),
delta: '未结清 $openCount',
deltaTone: MKpiDeltaTone.warn,
),
MKpiItem(
label: '应付合计',
value: _yuanWan(pay),
delta: '按到期及时结清',
deltaTone: MKpiDeltaTone.normal,
),
]);
}
/// 三分段列表体:m-section 计数标题 + 卡片流(应收/应付=往来汇总,流水=收支记录)。
List<Widget> _mSegBody(dynamic t, List<FinanceRecord> flows) {
Widget section(String title, int count) => Padding(
padding: const EdgeInsets.fromLTRB(2, 16, 2, 8),
child: Text('$title · 共 $count',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: t.muted)),
);
Widget empty() => Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text('暂无数据',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
);
List<Widget> stacked(List<Widget> cards) => [
for (var i = 0; i < cards.length; i++) ...[
if (i > 0) const SizedBox(height: 10),
cards[i],
],
];
if (_mSeg == 2) {
return [
section('资金流水', flows.length),
if (flows.isEmpty)
empty()
else
...stacked([for (final r in flows) _mFlowCard(t, r)]),
];
}
final async = ref.watch(financePartnerRowsProvider);
return async.when(
loading: () => [
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
],
error: (e, _) => [
Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text('汇总加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
),
],
data: (rows) {
final ar = _mSeg == 0;
final list = rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList();
return [
section(ar ? '应收账款' : '应付账款', list.length),
if (list.isEmpty)
empty()
else
...stacked(
[for (final r in list) _mPartnerCard(t, r, ar: ar)]),
];
},
);
}
/// 应收/应付卡(原型 .m-card:往来单位 + 金额 + 徽章带图标)。
Widget _mPartnerCard(dynamic t, PartnerFinanceRow r, {required bool ar}) {
return MobileListCard(
onTap: () => showFinancePartnerDrawer(context, row: r),
title: Text(r.name),
subtitle: Text('未结清 ${r.openCount}'),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
DsBadge('未结清',
tone: DsBadgeTone.warn, icon: statusIcon('未结清')),
const SizedBox(height: 6),
Text(_yuan(ar ? r.recv : r.pay),
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600,
fontSize: 13,
color: t.heading)),
],
),
);
}
/// 流水卡(原型 .m-card:类型徽章带图标 收款/付款 + 金额正负色)。
Widget _mFlowCard(dynamic t, FinanceRecord r) {
final isIn = r.type == 'receipt' || r.type == 'receivable';
return MobileListCard(
title: Text(
(r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel),
subtitle: Text((r.recordDate ?? '').split('T').first),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
DsBadge(r.typeLabel,
tone: switch (r.type) {
'receipt' => DsBadgeTone.ok,
'payment' => DsBadgeTone.danger,
'receivable' => DsBadgeTone.warn,
_ => DsBadgeTone.info,
},
icon: statusIcon(r.typeLabel)),
const SizedBox(height: 6),
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600,
fontSize: 13,
color: isIn ? t.success : t.danger)),
],
),
);
}
}
@@ -3,7 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/clock.dart';
import '../../models/inventory.dart';
import '../../models/warehouse.dart';
import '../../core/config/app_constants.dart';
@@ -12,6 +15,9 @@ import '../../providers/warehouse_provider.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
class InventoryCheckScreen extends ConsumerStatefulWidget {
const InventoryCheckScreen({super.key});
@@ -31,12 +37,18 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
final List<_CheckItem> _checkItems = [];
String get _checkNo {
final now = DateTime.now();
final now = appNow();
final date =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}';
}
/// 盘点日期(appNow 可注入时钟,golden 冻结确定化)。
String get _dateStr {
final now = appNow();
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
}
@override
void dispose() {
for (final item in _checkItems) {
@@ -81,9 +93,7 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
}
setState(() => _submitting = true);
try {
final now = DateTime.now();
final dateStr =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final dateStr = _dateStr;
final items = _checkItems.map((item) {
final actual =
@@ -125,6 +135,10 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
Widget build(BuildContext context) {
final asyncWarehouses = ref.watch(warehouseListProvider);
// 窄屏(原型 m-inventory-check):壳顶栏带标题/返回 → 隐藏页内大标题头,
// 盘点单以卡片流呈现(点卡开详情 sheet),底部操作条提交。
if (context.isMobile) return _buildMobile(asyncWarehouses);
return Scaffold(
backgroundColor: context.tokens.bg,
body: Column(
@@ -173,90 +187,7 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
child: Column(
children: [
// Basic info
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('盘点基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.tokens.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
_InfoField(
label: '盘点单号',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
_checkNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback:
AppFonts.monoFallback,
fontSize: 13),
),
),
),
_InfoField(
label: '盘点仓库',
child: asyncWarehouses.when(
loading: () =>
const LinearProgressIndicator(),
error: (e, _) => Text('$e',
style: TextStyle(
color: context.tokens.danger,
fontSize: 12)),
data: (warehouses) => DsSelect<Warehouse>(
value: _selectedWarehouse,
hint: '请选择仓库',
options: [
for (final w in warehouses) (w, w.name),
],
onChanged: (w) {
setState(() => _selectedWarehouse = w);
_loadInventory(w);
},
),
),
),
_InfoField(
label: '盘点类型',
child: DsSelect<String>(
value: _checkType,
options: const [
('全盘', '全盘'),
('抽盘', '抽盘'),
('循环盘点', '循环盘点'),
],
onChanged: (v) =>
setState(() => _checkType = v),
),
),
_InfoField(
label: '盘点日期',
child: InputDecorator(
decoration: const InputDecoration(),
child: Builder(builder: (ctx) {
final now = DateTime.now();
return Text(
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 13),
);
}),
),
),
],
),
],
),
),
),
_basicInfoCard(asyncWarehouses),
const SizedBox(height: 12),
// Check items table
Card(
@@ -408,6 +339,256 @@ class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
);
}
/// 盘点基本信息卡(桌面/移动共用):单号 / 仓库 / 类型 / 日期。
Widget _basicInfoCard(AsyncValue<List<Warehouse>> asyncWarehouses) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('盘点基本信息',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.tokens.primaryDark)),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
_InfoField(
label: '盘点单号',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(
_checkNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 13),
),
),
),
_InfoField(
label: '盘点仓库',
child: asyncWarehouses.when(
loading: () => const LinearProgressIndicator(),
error: (e, _) => Text('$e',
style: TextStyle(
color: context.tokens.danger, fontSize: 12)),
data: (warehouses) => DsSelect<Warehouse>(
value: _selectedWarehouse,
hint: '请选择仓库',
options: [
for (final w in warehouses) (w, w.name),
],
onChanged: (w) {
setState(() => _selectedWarehouse = w);
_loadInventory(w);
},
),
),
),
_InfoField(
label: '盘点类型',
child: DsSelect<String>(
value: _checkType,
options: const [
('全盘', '全盘'),
('抽盘', '抽盘'),
('循环盘点', '循环盘点'),
],
onChanged: (v) => setState(() => _checkType = v),
),
),
_InfoField(
label: '盘点日期',
child: InputDecorator(
decoration: const InputDecoration(),
child: Text(_dateStr, style: const TextStyle(fontSize: 13)),
),
),
],
),
],
),
),
);
}
// ── 窄屏(原型 m-inventory-check 粒度)──────────────────────────────
int get _totalDiff =>
_checkItems.fold(0, (sum, item) => sum + _getDiff(item));
String get _scopeLabel =>
'${_selectedWarehouse?.name ?? '未选仓库'} · $_checkType';
String _fmtDiff(int diff) => diff > 0 ? '+$diff' : '$diff';
Widget _buildMobile(AsyncValue<List<Warehouse>> asyncWarehouses) {
final t = context.tokens;
return Scaffold(
backgroundColor: t.bg,
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppDims.sp3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_basicInfoCard(asyncWarehouses),
// .m-section
Padding(
padding: const EdgeInsets.fromLTRB(2, 16, 2, 8),
child: Text('盘点单',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: t.muted)),
),
if (_loadingItems)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
)
else
_draftCard(t),
],
),
),
),
// 底部操作条(原型 .m-actionbar 形态:横排等分按钮)
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
decoration: BoxDecoration(
color: t.surface,
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(children: [
Expanded(
child: DsButton('取消',
onPressed: () => context.go('/inventory')),
),
const SizedBox(width: 10),
Expanded(
child: DsButton('提交盘点',
variant: DsBtnVariant.primary,
onPressed: (_submitting || _checkItems.isEmpty)
? null
: _submit),
),
]),
),
],
),
);
}
/// 盘点单卡(原型 .m-card:单号 / 范围·项数 / 状态徽章 + 差异 / 日期脚注)。
Widget _draftCard(dynamic t) {
final diff = _totalDiff;
return MobileListCard(
onTap: _openDraftSheet,
title: Text(_checkNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
subtitle: Text('$_scopeLabel · ${_checkItems.length}'),
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
DsBadge('进行中',
tone: DsBadgeTone.warn, icon: statusIcon('进行中')),
const SizedBox(height: 6),
// 「差异」为 CJK 走默认字体,数值走 monoJetBrains Mono 无 CJK 字形)
Text.rich(
TextSpan(children: [
TextSpan(
text: '差异 ',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
color: diff == 0 ? t.success : t.danger)),
TextSpan(
text: _fmtDiff(diff),
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w700,
fontSize: 13,
color: diff == 0 ? t.success : t.danger)),
]),
),
],
),
fields: [MobileCardField('日期', _dateStr)],
);
}
/// 盘点单详情 sheet(原型 openSheet.drow 键值行 + 状态徽章 + 继续录入)。
void _openDraftSheet() {
final t = context.tokens;
final diff = _totalDiff;
showMSheet<void>(
context,
title: '盘点单详情',
builder: (ctx) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_drow(ctx, '单号', value: _checkNo, mono: true),
_drow(ctx, '范围', value: _scopeLabel),
_drow(ctx, '商品项数', value: '${_checkItems.length}'),
_drow(ctx, '盈亏差异',
value: _fmtDiff(diff),
mono: true,
color: diff == 0 ? t.success : t.danger),
_drow(ctx, '状态',
child: DsBadge('进行中',
tone: DsBadgeTone.warn, icon: statusIcon('进行中')),
last: true),
],
),
actions: [
DsButton('继续录入',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(context).pop()),
],
);
}
/// 原型 .drowlabel(muted) + b(text 600)pad 11 0,下边 border-subtle。
Widget _drow(BuildContext ctx, String label,
{String? value,
Widget? child,
bool mono = false,
Color? color,
bool last = false}) {
final t = ctx.tokens;
return Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border:
last ? null : Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
const Spacer(),
child ??
Text(value ?? '',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
fontFamily: mono ? AppFonts.mono : null,
fontFamilyFallback: mono ? AppFonts.monoFallback : null,
color: color ?? t.text)),
]),
);
}
TableRow _buildCheckRow(int index) {
final item = _checkItems[index];
final diff = _getDiff(item);
@@ -21,6 +21,10 @@ import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/m_search_row.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef;
import '../../widgets/label_preview_dialog.dart';
@@ -172,6 +176,129 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// ── 窄屏筛选 sheet(原型移动无列头漏斗 → 详搜钮开底部 sheet)──
bool get _mobileFilterActive =>
_statusFilter != '全部' ||
_filterSpec.isNotEmpty ||
_filterSeries.isNotEmpty ||
_filterWarehouse.isNotEmpty;
void _openMobileFilterSheet(
List<String> specOptions, List<String> seriesOptions) {
showMSheet<void>(
context,
title: '筛选',
builder: (sheetCtx) => StatefulBuilder(builder: (sheetCtx, setSheet) {
Widget group(String label, List<Widget> chips) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: sheetCtx.tokens.muted)),
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: chips),
],
),
);
void toggleMulti(
String v,
Set<String> selected,
ValueChanged<Set<String>> apply,
) {
final next = Set.of(selected);
next.contains(v) ? next.remove(v) : next.add(v);
apply(next);
setSheet(() {});
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
group('状态', [
for (final s in _statusOptions)
DsChip(
label: s,
selected: _statusFilter == s,
caret: false,
onTap: () {
setState(() => _statusFilter = s);
setSheet(() {});
}),
]),
if (specOptions.isNotEmpty)
group('规格', [
for (final o in specOptions)
DsChip(
label: o,
selected: _filterSpec.contains(o),
caret: false,
onTap: () => toggleMulti(o, _filterSpec, (next) {
setState(() => _filterSpec = next);
ref
.read(inventoryListProvider.notifier)
.setSpec(next.toList());
})),
]),
if (seriesOptions.isNotEmpty)
group('系列', [
for (final o in seriesOptions)
DsChip(
label: o,
selected: _filterSeries.contains(o),
caret: false,
onTap: () => toggleMulti(o, _filterSeries, (next) {
setState(() => _filterSeries = next);
ref
.read(inventoryListProvider.notifier)
.setSeries(next.toList());
})),
]),
Row(children: [
Expanded(
child: DsButton('重置', onPressed: () {
setState(() {
_statusFilter = '全部';
_filterSpec = {};
_filterSeries = {};
_filterWarehouse = {};
});
final n = ref.read(inventoryListProvider.notifier);
n.setSpec([]);
n.setSeries([]);
setSheet(() {});
}),
),
const SizedBox(width: 10),
Expanded(
child: DsButton('完成',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(sheetCtx).pop()),
),
]),
],
);
}),
);
}
// ── 窄屏 KPIMKpiGrid):delta 箭头/色调映射(与 DsKpi 同规则)──
String _mDeltaText(String text, DsKpiDelta tone) => switch (tone) {
DsKpiDelta.up => '$text',
DsKpiDelta.down => '$text',
DsKpiDelta.neutral => text,
};
MKpiDeltaTone _mTone(DsKpiDelta tone) => switch (tone) {
DsKpiDelta.up => MKpiDeltaTone.up,
DsKpiDelta.down => MKpiDeltaTone.down,
DsKpiDelta.neutral => MKpiDeltaTone.normal,
};
/// 备注列展示:editable 时附带编辑图标(用于 WriteGuard 的可点子控件),
/// 否则纯文本(只读角色占位)。
Widget _remarkDisplay(Inventory item, {required bool editable}) {
@@ -549,13 +676,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
child: Column(
children: [
// 头部(原型 .head{margin-bottom:18px}):标题 + SKU 数 + 列设置/导出
// 窄屏隐藏(原型 m-inventory:标题在壳顶栏 m-top
if (!mobile)
Container(
width: double.infinity,
color: context.tokens.bg,
padding: mobile
? const EdgeInsets.fromLTRB(
AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2)
: const EdgeInsets.only(bottom: 18),
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
@@ -610,6 +736,47 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final (qtyDelta, qtyTone) = summary != null
? _momDelta(summary.inStockQty, summary.lastMonthQty)
: ('较上月 —', DsKpiDelta.neutral);
if (context.isMobile) {
// 原型 m-inventory .m-kpi2×2 网格(缺货卡 warn 色 + 点击筛选)
return Container(
color: context.tokens.bg,
padding: const EdgeInsets.all(AppDims.sp3),
child: MKpiGrid(items: [
MKpiItem(
label: 'SKU 总数',
value: NumberFormat.decimalPattern()
.format(summary?.skuCount ?? result.total),
delta: _mDeltaText(skuDelta, skuTone),
deltaTone: _mTone(skuTone),
onTap: _clearFilters,
),
MKpiItem(
label: '库存货值',
value: summary != null
? yuanWan(summary.stockValue)
: '',
delta: _mDeltaText(valDelta, valTone),
deltaTone: _mTone(valTone),
),
MKpiItem(
label: '在库数量',
value: NumberFormat.decimalPattern()
.format((summary?.inStockQty ?? 0).round()),
delta: _mDeltaText(qtyDelta, qtyTone),
deltaTone: _mTone(qtyTone),
),
MKpiItem(
label: '缺货预警',
value: '${summary?.shortageCount ?? 0}',
delta: '需补货 ${summary?.warningCount ?? 0}',
deltaTone: MKpiDeltaTone.warn,
selected: _statusFilter == '缺货',
onTap: () =>
setState(() => _statusFilter = '缺货'),
),
]),
);
}
final cards = <Widget>[
DsKpi(
title: 'SKU 总数',
@@ -651,36 +818,25 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
onTap: () => setState(() => _statusFilter = '缺货'),
),
];
final mobile = context.isMobile;
final row = <Widget>[];
for (var i = 0; i < cards.length; i++) {
if (i > 0) {
// 原型 .kpis{gap:14px}
row.add(SizedBox(width: mobile ? AppDims.sp3 : 14));
row.add(const SizedBox(width: 14));
}
row.add(mobile
? SizedBox(width: 160, child: cards[i])
: Expanded(child: cards[i]));
row.add(Expanded(child: cards[i]));
}
return Container(
color: context.tokens.bg,
// 原型 .kpis{margin-bottom:20px};左右留白由外层 .main 统一给
padding: mobile
? const EdgeInsets.all(AppDims.sp3)
: const EdgeInsets.only(bottom: 20),
child: mobile
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicHeight(child: Row(children: row)),
)
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: row)),
padding: const EdgeInsets.only(bottom: 20),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: row)),
);
}),
// 原型 KPI 与表格卡之间无分隔线(桌面);窄屏保留视觉分隔
if (mobile) const Divider(height: 1),
// 原型 KPI 与表格卡之间无分隔线(桌面/移动同
Expanded(
child: DsTable(
total: result.total,
@@ -707,11 +863,21 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final canCheck = !WriteGuard.isReadonly(ref);
if (isMobile) {
// 移动端:搜索独占一行 + 紧凑图标操作行(含 表格/列表 切换)
// 移动端:MSearchRow(原型 .m-search + 详搜钮)独占一行
// + 紧凑图标操作行(含 表格/列表 切换)
return Column(
mainAxisSize: MainAxisSize.min,
children: [
searchField,
MSearchRow(
controller: _searchCtrl,
hint: '商品名 / 拼音 / 编码',
onSubmitted: (v) => ref
.read(inventoryListProvider.notifier)
.setKeyword(v.trim()),
filterActive: _mobileFilterActive,
onFilterTap: () => _openMobileFilterSheet(
specOptions, seriesOptions),
),
const SizedBox(height: AppDims.sp2),
Row(
children: [
@@ -901,13 +1067,26 @@ class _InventoryStatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.tokens;
// 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底+圆点全走 token。
// 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底 + 图标变体
//2026-07-04 拍板:圆点 → 代表图标,映射走 status_icon_map)。
if (item.quantity == 0) {
return StatusPill(label: '缺货', color: t.danger, background: t.dangerBg);
return StatusPill(
label: '缺货',
color: t.danger,
background: t.dangerBg,
icon: statusIcon('缺货'));
}
if (item.minStock != null && item.quantity < item.minStock!) {
return StatusPill(label: '预警', color: t.warn, background: t.warnBg);
return StatusPill(
label: '预警',
color: t.warn,
background: t.warnBg,
icon: statusIcon('预警'));
}
return StatusPill(label: '在售', color: t.success, background: t.okSoft);
return StatusPill(
label: '在售',
color: t.success,
background: t.okSoft,
icon: statusIcon('在售'));
}
}