feat(client): 库存/出入库移动卡片与详情 sheet 逐一对齐原型结构

- 新增 MCard(原型 .m-card 双栏:mc-main 标题+副行 mono | mc-right 徽章行+
  金额 mono | › | mc-foot ⓘ脚注)+ DsIconBadge(.badge.ico 22px 纯图标)/
  DsTextIconBadge(.badge.bi 图标+文字) + statusToneOf 状态词配色表——
  替换原 MobileListCard「标签:值」布局与各屏本地 _icoBadge/_statusColors
- 出入库卡:单号 + 供应商/客户·N 项 + 状态/退单/待定价图标徽章 + 合计金额 +
  日期·经手人·审核 脚注;库存卡:品名 + 编码·规格 + 状态徽章 + 数量(缺货红/
  预警黄);移动卡不再有行内操作(对齐原型,操作统一在详情 sheet;打标签=
  打印,移动端裁剪)
- DsTable 窄屏卡片流去外层大卡容器(卡直接铺页面底色,对齐原型 m-scroll)——
  往来/基础数据等全部卡片流屏同步受益
- 出入库详情窄屏改 sheet 专属布局(原型 openOrder):drow 字段行 + 明细
  pe-card(头行 图标+「明细 · N 项」+ 行=品名+系列·×数量 | 金额/待定价徽章)+
  合计 drow + 等分按钮行(无组标签);桌面右滑抽屉布局不变
- 相关移动 golden 全量重生成(出入库/库存/往来/基础数据 ×3 主题),
  桌面 golden 零漂移;283 测试全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 19:30:40 +08:00
parent 145bf06ff8
commit 980e38e020
39 changed files with 509 additions and 263 deletions
@@ -8,26 +8,19 @@ import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/storage/column_prefs.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/kpi_card.dart' show StatusPill; // 移动卡片状态药丸
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_card.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';
import '../../widgets/product_editor_drawer.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
@@ -363,33 +356,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}
}
Future<void> _printLabel(BuildContext context, Inventory item) async {
if (item.productId == null) return;
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!context.mounted) return;
final label = LabelData(
productId: item.productId,
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
void _exportInventory(List<Inventory> items) {
exportExcel(
filename: '库存查询',
@@ -551,41 +517,40 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
};
}
/// 库存查询:窄屏卡片
/// 库存查询:窄屏卡片(原型 m-inventory .m-card 双栏:
/// 品名+编码·规格 | 状态徽章+数量 | ›;操作统一收进详情 sheet,移动无打印)。
Widget _inventoryCard(Inventory item) {
return MobileListCard(
final t = context.tokens;
final status = _statusOf(item);
final qtyColor = switch (status) {
'缺货' => t.danger,
'预警' => t.warn,
_ => t.heading,
};
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
].join(' · ');
return MCard(
onTap: item.productId != null ? () => _openEditor(item) : null,
title: Text(item.productName.isEmpty ? '-' : item.productName),
subtitle: item.productCode.isEmpty ? null : Text(item.productCode),
trailing: _InventoryStatusBadge(item),
fields: [
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
MobileCardField(
'仓库', item.warehouseName.isEmpty ? '-' : item.warehouseName),
MobileCardField(
'单价',
item.unitPrice != null
? '¥${item.unitPrice!.toStringAsFixed(2)}'
: '待定价'),
if (item.supplierName.isNotEmpty)
MobileCardField('供应商', item.supplierName),
],
actions: [
if (!WriteGuard.isReadonly(ref))
WriteGuard(
child: TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
),
if (item.productId != null)
TextButton(
onPressed: () => _printLabel(context, item),
child: const Text('打标签', style: TextStyle(fontSize: 13)),
),
],
nm: item.productName.isEmpty ? '-' : item.productName,
sub: sub.isEmpty ? null : sub,
badges: [DsTextIconBadge(status)],
amtWidget: Text.rich(
TextSpan(children: [
TextSpan(
text: item.quantity.toStringAsFixed(0),
style: TextStyle(color: qtyColor)),
TextSpan(
text: ' ${item.unit}'.trimRight(),
style: TextStyle(color: t.heading, fontWeight: FontWeight.w400)),
]),
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback),
),
);
}
@@ -678,46 +643,46 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
// 头部(原型 .head{margin-bottom:18px}):标题 + SKU 数 + 列设置/导出
// 窄屏隐藏(原型 m-inventory:标题在壳顶栏 m-top
if (!mobile)
Container(
width: double.infinity,
color: context.tokens.bg,
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('库存管理',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: context.tokens.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个 SKU · 数据实时',
Container(
width: double.infinity,
color: context.tokens.bg,
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('库存管理',
style: TextStyle(
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
const Spacer(),
if (!context.isMobile) ...[
Builder(
builder: (bctx) => DsButton(
'列设置',
icon: LucideIcons.alignLeft,
onPressed: () => _openColMenu(bctx),
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: context.tokens.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个 SKU · 数据实时',
style: TextStyle(
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
const Spacer(),
if (!context.isMobile) ...[
Builder(
builder: (bctx) => DsButton(
'列设置',
icon: LucideIcons.alignLeft,
onPressed: () => _openColMenu(bctx),
),
),
),
const SizedBox(width: AppDims.sp2),
DsButton(
'导出',
icon: LucideIcons.download,
onPressed: () => _exportInventory(filteredItems),
),
const SizedBox(width: AppDims.sp2),
DsButton(
'导出',
icon: LucideIcons.download,
onPressed: () => _exportInventory(filteredItems),
),
],
],
],
),
),
),
// KPI 卡(还原原型 .kpi):宽屏 4 等分,窄屏横向滚动
Builder(builder: (context) {
// KPI 走全店汇总接口(不随分页),环比对月初快照。
@@ -771,8 +736,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
delta: '需补货 ${summary?.warningCount ?? 0}',
deltaTone: MKpiDeltaTone.warn,
selected: _statusFilter == '缺货',
onTap: () =>
setState(() => _statusFilter = '缺货'),
onTap: () => setState(() => _statusFilter = '缺货'),
),
]),
);
@@ -1059,34 +1023,3 @@ class _RowActEyeState extends State<_RowActEye> {
);
}
}
class _InventoryStatusBadge extends StatelessWidget {
final Inventory item;
const _InventoryStatusBadge(this.item);
@override
Widget build(BuildContext context) {
final t = context.tokens;
// 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底 + 图标变体
//2026-07-04 拍板:圆点 → 代表图标,映射走 status_icon_map)。
if (item.quantity == 0) {
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,
icon: statusIcon('预警'));
}
return StatusPill(
label: '在售',
color: t.success,
background: t.okSoft,
icon: statusIcon('在售'));
}
}
@@ -19,9 +19,8 @@ import '../../widgets/ds/ds_kpi.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_card.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/searchable_option_field.dart';
import '../../widgets/combo_search_field.dart';
import '../../widgets/status_badge.dart';
@@ -577,34 +576,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
// ── 窄屏形态(对齐移动原型 m-stock-in-list.html)──────────────────
/// 状态词 → (前景, 软底):镜像原型 .badge.b-*,全走 token。
(Color, Color) _statusColors(String label) {
final t = context.tokens;
return switch (label) {
'待审核' => (t.warn, t.warnBg),
'已审核' => (t.success, t.successBg),
'已拒绝' => (t.danger, t.dangerBg),
'部分退单' => (t.warn, t.warnBg),
'已退单' => (t.danger, t.dangerBg),
'待定价' => (t.warn, t.warnBg),
_ => (t.muted, t.infoSoft), // 草稿
};
}
/// 原型 .badge.ico:纯图标小徽章(卡片右上态标 / 状态 sheet 选项行)。
Widget _icoBadge(String label) {
final (fg, bg) = _statusColors(label);
return Container(
height: 22,
width: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Icon(statusIcon(label), size: 12, color: fg),
);
}
/// 原型 .m-kpi 2×2:近30天笔数/金额 + 可点筛选的待审核/草稿(再点取消)。
Widget _buildMobileKpis(StockSummary? summary, int total) {
String yuanWan(double v) => v >= 10000
@@ -720,7 +692,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
),
child: Row(children: [
if (_statusOptions[i].$2 != '全部') ...[
_icoBadge(_statusOptions[i].$2),
DsIconBadge(_statusOptions[i].$2),
const SizedBox(width: 10),
],
Text(_statusOptions[i].$2,
@@ -924,41 +896,29 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
}
/// 入库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。
/// 入库单:窄屏卡片(原型 .m-card 双栏:单号+供应商·N 项 | 图标徽章+金额 | ›;
/// mc-foot = 日期 · 经手人 · 审核)。
Widget _orderCard(BuildContext context, StockInOrder o) {
final hasPending = o.items.any((it) => it.costPrice == 0);
return MobileListCard(
final foot = [
if (o.orderDate != null) o.orderDate!.substring(0, 10),
if (o.operatorName != null) '经手人 ${o.operatorName}',
if (o.reviewerName != null) '审核 ${o.reviewerName}',
].join(' · ');
return MCard(
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 14)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
_icoBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial') ...[
const SizedBox(width: 5),
_icoBadge('部分退单'),
] else if (o.returnState == 'full') ...[
const SizedBox(width: 5),
_icoBadge('已退单'),
],
if (hasPending) ...[
const SizedBox(width: 5),
_icoBadge('待定价'),
],
const SizedBox(width: 6),
Icon(LucideIcons.chevronRight, size: 18, color: context.tokens.faint),
]),
fields: [
MobileCardField('供应商', o.partnerName ?? '-'),
MobileCardField('仓库', o.warehouseName ?? '-'),
MobileCardField('合计金额',
o.costTotal != null ? '¥${o.costTotal!.toStringAsFixed(2)}' : '-'),
MobileCardField('入库时间', o.orderDate?.substring(0, 10) ?? '-'),
MobileCardField('入库员', o.operatorName ?? '-'),
MobileCardField('审核员', o.reviewerName ?? '-'),
nm: o.orderNo,
sub: '${o.partnerName ?? ''} · ${o.items.length}',
badges: [
DsIconBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial')
const DsIconBadge('部分退单')
else if (o.returnState == 'full')
const DsIconBadge('已退单'),
if (hasPending) const DsIconBadge('待定价'),
],
amt: o.costTotal != null ? _money.format(o.costTotal) : '',
foot: foot.isEmpty ? null : foot,
);
}
@@ -33,9 +33,8 @@ import '../../widgets/ds/ds_kpi.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_card.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/status_badge.dart';
import '../../widgets/searchable_option_field.dart';
import '../../widgets/combo_search_field.dart';
@@ -607,34 +606,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
/// 状态词 → (前景, 软底):镜像原型 .badge.b-*,全走 token。
(Color, Color) _statusColors(String label) {
final t = context.tokens;
return switch (label) {
'待审核' => (t.warn, t.warnBg),
'已审核' => (t.success, t.successBg),
'已拒绝' => (t.danger, t.dangerBg),
'部分退单' => (t.warn, t.warnBg),
'已退单' => (t.danger, t.dangerBg),
'待定价' => (t.warn, t.warnBg),
_ => (t.muted, t.infoSoft), // 草稿
};
}
/// 原型 .badge.ico:纯图标小徽章(卡片右上态标 / 状态 sheet 选项行)。
Widget _icoBadge(String label) {
final (fg, bg) = _statusColors(label);
return Container(
height: 22,
width: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Icon(statusIcon(label), size: 12, color: fg),
);
}
/// 原型 .m-kpi 2×2:近30天笔数/金额 + 可点筛选的待审核/草稿(再点取消)。
Widget _buildMobileKpis(int total) {
final StockSummary? summary =
@@ -761,7 +733,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
),
child: Row(children: [
if (_statusOptions[i].$2 != '全部') ...[
_icoBadge(_statusOptions[i].$2),
DsIconBadge(_statusOptions[i].$2),
const SizedBox(width: 10),
],
Text(_statusOptions[i].$2,
@@ -998,39 +970,29 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
// ── 窄屏卡片 ─────────────────────────────────────────────────
/// 出库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。
/// 出库单:窄屏卡片(原型 .m-card 双栏:单号+客户·N 项 | 图标徽章+金额 | ›;
/// mc-foot = 日期 · 经手人 · 审核)。
Widget _orderCard(BuildContext context, StockOutOrder o) {
final hasPending = o.items.any((it) => it.salePrice <= 0);
return MobileListCard(
final foot = [
if (o.orderDate != null) o.orderDate!.substring(0, 10),
if (o.operatorName != null) '经手人 ${o.operatorName}',
if (o.reviewerName != null) '审核 ${o.reviewerName}',
].join(' · ');
return MCard(
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 14)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
_icoBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial') ...[
const SizedBox(width: 5),
_icoBadge('部分退单'),
] else if (o.returnState == 'full') ...[
const SizedBox(width: 5),
_icoBadge('已退单'),
],
if (hasPending) ...[
const SizedBox(width: 5),
_icoBadge('待定价'),
],
const SizedBox(width: 6),
Icon(LucideIcons.chevronRight, size: 18, color: context.tokens.faint),
]),
fields: [
MobileCardField('客户', o.partnerName ?? '-'),
MobileCardField('仓库', o.warehouseName ?? '-'),
MobileCardField('金额',
o.saleTotal != null ? '¥${o.saleTotal!.toStringAsFixed(2)}' : '-'),
MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'),
nm: o.orderNo,
sub: '${o.partnerName ?? ''} · ${o.items.length}',
badges: [
DsIconBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial')
const DsIconBadge('部分退单')
else if (o.returnState == 'full')
const DsIconBadge('已退单'),
if (hasPending) const DsIconBadge('待定价'),
],
amt: o.saleTotal != null ? _money.format(o.saleTotal) : '',
foot: foot.isEmpty ? null : foot,
);
}