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
@@ -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);