feat(client): 出入库列表窄屏对齐移动原型(KPI 筛选/搜索行/状态与详搜 sheet/图标徽章)

- 窄屏隐藏页内大标题头与新增/导出/打印入口(移动端无建单与打印——拍板)
- KPI 改 MKpiGrid 2×2:待审核/草稿卡可点切换状态筛选(再点取消,selected 联动)
- 工具栏改 MSearchRow:搜索(provider 内置防抖)+ 纯文字状态钮 + 图标详搜钮
- 状态筛选/详细搜索改 showMSheet 底部 sheet(详搜字段与桌面完全同集,重置/应用在 actions)
- 卡片流徽章换图标变体(含派生态待定价/部分退单/已退单)+ › 箭头,行内操作收进详情 sheet
- 确认进价/确认售价窄屏走 showMSheet;窄屏详情操作组不出打印
- 新增 390×844 三主题 golden(m_stock_in_list/m_stock_out_list);重生成既有 390×1300 移动 golden 基准(桌面 golden 零漂移)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 12:17:31 +08:00
parent 4287f5a876
commit ff7b4194ec
16 changed files with 1583 additions and 340 deletions
@@ -17,6 +17,10 @@ import '../../providers/stock_in_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
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_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';
@@ -32,7 +36,6 @@ import '../../providers/finance_provider.dart'
show financeRepositoryProvider, financePartnerRowsProvider;
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/order_row_actions.dart';
import '../../widgets/order_return_dialog.dart';
import '../../widgets/order_detail_drawer.dart';
import '../../widgets/wheel_date_picker.dart';
@@ -265,24 +268,47 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
);
Future<void> _openAdvSearch() async {
final res = await showAppDialog<_AdvResult>(
context: context,
builder: (_) => _AdvSearchDialog(
initialDetail: _detail,
initialStatus: _statusFilter,
initialRange: _dateRange,
),
);
if (res == null || !mounted) return;
final _AdvResult? res;
if (context.isMobile) {
// 窄屏:底部 sheet 竖排单列(字段与桌面同集,原型 drawAdv)。
final formKey = GlobalKey<_AdvSearchSheetState>();
res = await showMSheet<_AdvResult>(
context,
title: '详细搜索',
builder: (_) => _AdvSearchSheet(
key: formKey,
initialDetail: _detail,
initialStatus: _statusFilter,
initialRange: _dateRange,
),
actions: [
DsButton('重置', onPressed: () => formKey.currentState?.reset()),
DsButton('应用',
variant: DsBtnVariant.primary,
onPressed: () => formKey.currentState?.apply()),
],
);
} else {
res = await showAppDialog<_AdvResult>(
context: context,
builder: (_) => _AdvSearchDialog(
initialDetail: _detail,
initialStatus: _statusFilter,
initialRange: _dateRange,
),
);
}
final r = res;
if (r == null || !mounted) return;
setState(() {
_detail = res.detail;
_statusFilter = res.status;
_dateRange = res.dateRange;
_detail = r.detail;
_statusFilter = r.status;
_dateRange = r.dateRange;
});
final n = ref.read(stockInListProvider.notifier);
n.setStatus(res.status);
n.setStatus(r.status);
n.setDateRange(_startDate, _endDate);
n.setDetail(res.detail);
n.setDetail(r.detail);
}
int get _advCount {
@@ -346,16 +372,25 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final summary = ref.watch(stockInSummary30Provider).valueOrNull;
final mobile = context.isMobile;
final content = Container(
color: context.tokens.bg,
padding: context.isMobile
padding: mobile
? EdgeInsets.zero
// 原型 .main{padding:22px 26px}
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
children: [
_buildHeader(result.total, summary),
_buildKpis(summary, result.total),
// 窄屏对齐移动原型 m-stock-in-list:标题在顶栏、正文从 KPI 起;
// 无页头按钮(移动端无建单/导出/打印入口——用户拍板)。
if (!mobile) ...[
_buildHeader(result.total, summary),
_buildKpis(summary, result.total),
] else ...[
_buildMobileKpis(summary, result.total),
_buildMobileSearchRow(),
_buildMobileSection(result.total),
],
Expanded(
child: DsTable(
total: result.total,
@@ -366,12 +401,14 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onPageSizeChanged: (s) =>
ref.read(stockInListProvider.notifier).setPageSize(s),
emptyText: '没有匹配的入库单 · 试试调整筛选或搜索',
toolbar: _buildToolbar(
orders: orders,
warehouseOptions: warehouseOptions,
supplierOptions: supplierOptions,
selectedSupplierId: selectedSupplierId,
),
toolbar: mobile
? null
: _buildToolbar(
orders: orders,
warehouseOptions: warehouseOptions,
supplierOptions: supplierOptions,
selectedSupplierId: selectedSupplierId,
),
mobileCards:
orders.map((o) => _orderCard(context, o)).toList(),
columns: _colDefs
@@ -534,6 +571,178 @@ 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
? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}'
: '¥${v.toStringAsFixed(0)}';
String pct(double? p) => p == null
? '较上期 —'
: '${p >= 0 ? '' : ''} ${p.abs().toStringAsFixed(1)}% 较上期';
MKpiDeltaTone tone(double? p) => p == null
? MKpiDeltaTone.normal
: (p >= 0 ? MKpiDeltaTone.up : MKpiDeltaTone.down);
final pendingCount = summary?.pendingCount ?? 0;
final draftCount = summary?.draftCount ?? 0;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: MKpiGrid(items: [
MKpiItem(
label: '近30天入库笔数',
value: NumberFormat.decimalPattern()
.format(summary?.monthCount ?? total),
icon: LucideIcons.download,
delta: pct(summary?.countDeltaPct),
deltaTone: tone(summary?.countDeltaPct)),
MKpiItem(
label: '近30天入库金额',
value: summary != null ? yuanWan(summary.monthAmount) : '',
icon: LucideIcons.wallet,
delta: pct(summary?.amountDeltaPct),
deltaTone: tone(summary?.amountDeltaPct)),
MKpiItem(
label: '待审核 · 点击筛选',
value: '$pendingCount',
icon: LucideIcons.clock,
delta: pendingCount > 0 ? '需尽快处理' : '点击筛选',
deltaTone:
pendingCount > 0 ? MKpiDeltaTone.warn : MKpiDeltaTone.normal,
selected: _statusFilter == 'pending',
onTap: () =>
_setStatus(_statusFilter == 'pending' ? '' : 'pending')),
MKpiItem(
label: '草稿 · 点击筛选',
value: '$draftCount',
icon: LucideIcons.fileText,
delta: draftCount > 0 ? '待提交审核' : '点击筛选',
selected: _statusFilter == 'draft',
onTap: () => _setStatus(_statusFilter == 'draft' ? '' : 'draft')),
]),
);
}
/// 详细搜索是否有生效条件(不含状态钮自己的 return_state)→ 详搜钮高亮。
bool get _mobileAdvActive =>
_detail.entries
.any((e) => e.key != 'return_state' && e.value.trim().isNotEmpty) ||
_dateRange != null;
/// 原型搜索区:搜索框 + 纯文字状态钮 + 图标详搜钮。
Widget _buildMobileSearchRow() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: MSearchRow(
controller: _searchCtrl,
hint: '搜索单号 / 供应商 / 酒名',
// setKeyword 内置 350ms 防抖 + 同词去重,oninput 直连即可(对齐原型)。
onChanged: (v) => ref.read(stockInListProvider.notifier).setKeyword(v),
onSubmitted: (v) =>
ref.read(stockInListProvider.notifier).setKeyword(v),
statusLabel: _statusLabelOf,
statusActive: _statusFilter.isNotEmpty,
onStatusTap: _openStatusSheet,
filterActive: _mobileAdvActive,
onFilterTap: _openAdvSearch,
),
);
}
/// 原型 .m-section:入库单 · 共 N。
Widget _buildMobileSection(int total) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text('入库单 · 共 $total',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: context.tokens.muted)),
),
);
}
/// 状态筛选底部 sheet(原型 openStatusSheet 的 m-opt 列表:图标 + 状态词 + 勾)。
Future<void> _openStatusSheet() async {
final sel = await showMSheet<String>(
context,
title: '单据状态',
builder: (ctx) {
final t = ctx.tokens;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _statusOptions.length; i++)
InkWell(
onTap: () => Navigator.of(ctx).pop(_statusOptions[i].$1),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
decoration: BoxDecoration(
border: i < _statusOptions.length - 1
? Border(bottom: BorderSide(color: t.borderSubtle))
: null,
),
child: Row(children: [
if (_statusOptions[i].$2 != '全部') ...[
_icoBadge(_statusOptions[i].$2),
const SizedBox(width: 10),
],
Text(_statusOptions[i].$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: _statusOptions[i].$1 == _statusFilter
? FontWeight.w600
: FontWeight.w400,
color: _statusOptions[i].$1 == _statusFilter
? t.primary
: t.text)),
const Spacer(),
if (_statusOptions[i].$1 == _statusFilter)
Icon(LucideIcons.check, size: 18, color: t.primary),
]),
),
),
],
);
},
);
if (sel == null || !mounted) return;
_setStatus(sel);
}
// ── 工具栏(原型 .toolbar)─────────────────────────────────────────
Widget _buildToolbar({
required List<StockInOrder> orders,
@@ -542,8 +751,6 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
required int? selectedSupplierId,
}) {
return Builder(builder: (ctx) {
final isMobile = ctx.isMobile;
final searchField = DsSearchBox(
controller: _searchCtrl,
hint: '单号 / 酒名 / 商品编码',
@@ -613,49 +820,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onPressed: _resetFilters,
);
if (isMobile) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
searchField,
const SizedBox(height: AppDims.sp2),
Wrap(
spacing: AppDims.sp2,
runSpacing: AppDims.sp2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (!WriteGuard.isReadonly(ref))
WriteGuard(
child: DsButton(
'新增',
icon: LucideIcons.plus,
small: true,
variant: DsBtnVariant.primary,
onPressed: () => context.go('/stock-in/new'),
),
),
supplierField,
warehouseChip,
statusChip,
dateChip,
advBtn,
resetBtn,
DsButton('导出',
icon: LucideIcons.download,
small: true,
onPressed: _doExport),
DsButton('刷新',
icon: LucideIcons.refreshCw,
small: true,
onPressed: () =>
ref.read(stockInListProvider.notifier).reload()),
],
),
],
);
}
// 桌面:筛选左侧 Wrap(避免 1280 溢出),重置推到最右(对齐原型 .sp 布局)。
// 窄屏不再走此工具栏(对齐移动原型:MSearchRow + 状态/详搜 sheet)。
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -755,70 +921,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
}
/// 操作按钮列表,表格与移动端卡片共用。入库特有的「打标签」通过 afterPrint 注入
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
return buildOrderRowActions(
context: context,
readonly: WriteGuard.isReadonly(ref),
canWithdraw: ref.watch(isAdminProvider) ||
(o.operatorId != null &&
o.operatorId == ref.watch(currentUserIdProvider)),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
onPrint: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (context.mounted) {
await showStockInOrderPrint(context, ref, order);
}
},
afterPrint: [
TextButton(
onPressed: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
final labels = order.items
.map((item) => LabelData(
productId: item.productId,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
))
.toList();
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(
labels: labels,
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
),
);
},
child: Text('打标签',
style: TextStyle(fontSize: 12, color: context.tokens.primary)),
),
],
onSettle: () => _confirmSettle(context, o.id, 'stock_in'),
onEdit: () => context.go('/stock-in/edit/${o.id}'),
onDelete: () => _confirmDelete(context, o),
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
// 入库退单:仅管理员/超管
canReturn: ref.watch(isAdminProvider),
onReturn: () => _confirmReturn(context, o),
);
}
/// 入库单:窄屏卡片
/// 入库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)
Widget _orderCard(BuildContext context, StockInOrder o) {
final hasPending = o.items.any((it) => it.costPrice == 0);
return MobileListCard(
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
@@ -827,21 +932,30 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 14)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
StatusBadge(_apiStatusToEnum(o.status)),
if (returnStateBadge(context, o.returnState) != null) ...[
const SizedBox(width: 4),
returnStateBadge(context, o.returnState)!,
_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 ?? '-'),
],
actions: _orderActions(context, o),
);
}
@@ -989,17 +1103,20 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
]));
}
groups.add(DrawerActionGroup('打印', [
b('打印标签', () {
close();
_printLabels(o);
}),
b('打印单据', () async {
close();
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (mounted) await showStockInOrderPrint(context, ref, order);
}),
]));
// 窄屏详情不出打印入口(移动端无打印——用户拍板)。
if (!context.isMobile) {
groups.add(DrawerActionGroup('打印', [
b('打印标签', () {
close();
_printLabels(o);
}),
b('打印单据', () async {
close();
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (mounted) await showStockInOrderPrint(context, ref, order);
}),
]));
}
return groups;
}
@@ -1038,52 +1155,102 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final controllers = {
for (final it in pending) it.id!: TextEditingController()
};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认进价'),
content: SizedBox(
width: context.dialogWidth(440),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: pending.map((it) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: TextField(
controller: controllers[it.id],
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
prefixText: '¥', hintText: '进价', isDense: true),
),
),
],
),
);
}).toList(),
final bool? ok;
if (context.isMobile) {
// 窄屏:底部 sheet(对齐原型 openConfirm)。
ok = await showMSheet<bool>(
context,
title: '确认进价',
builder: (ctx) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'调货「先卖后定价」:填写真实进价后,系统前向补齐库存成本、已出库成本快照,并按差额补一条应付流水——不反审核、不影响售价 / 应收。',
style: TextStyle(
fontSize: AppDims.fsSm, height: 1.6, color: ctx.tokens.muted),
),
),
const SizedBox(height: 14),
for (final it in pending)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} · 数量 ${it.quantity.toStringAsFixed(0)}',
style: TextStyle(
fontSize: AppDims.fsSm, color: ctx.tokens.muted)),
const SizedBox(height: 6),
TextField(
controller: controllers[it.id],
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
prefixText: '¥',
hintText: '真实进价 0.00',
isDense: true),
),
],
),
),
],
),
actions: [
DsButton('取消', onPressed: () => Navigator.pop(ctx, false)),
DsButton('确认',
DsButton('取消', onPressed: () => Navigator.of(context).pop(false)),
DsButton('确认并补差额',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.pop(ctx, true)),
onPressed: () => Navigator.of(context).pop(true)),
],
),
);
);
} else {
ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认进价'),
content: SizedBox(
width: context.dialogWidth(440),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: pending.map((it) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: TextField(
controller: controllers[it.id],
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
prefixText: '¥', hintText: '进价', isDense: true),
),
),
],
),
);
}).toList(),
),
),
),
actions: [
DsButton('取消', onPressed: () => Navigator.pop(ctx, false)),
DsButton('确认',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.pop(ctx, true)),
],
),
);
}
final items = <Map<String, dynamic>>[];
if (ok == true) {
for (final it in pending) {
@@ -1792,3 +1959,315 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> {
);
}
}
// ── 详细搜索(窄屏底部 sheet,竖排单列;字段与桌面 _AdvSearchDialog 完全同集,
// 对齐原型 drawAdv;重置/应用按钮由 showMSheet actions 注入)──────────────
class _AdvSearchSheet extends ConsumerStatefulWidget {
final Map<String, String> initialDetail;
final String initialStatus;
final DateTimeRange? initialRange;
const _AdvSearchSheet({
super.key,
required this.initialDetail,
required this.initialStatus,
required this.initialRange,
});
@override
ConsumerState<_AdvSearchSheet> createState() => _AdvSearchSheetState();
}
class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
late final TextEditingController _orderNoCtrl;
late final TextEditingController _productCtrl;
late final TextEditingController _productCodeCtrl;
late final TextEditingController _batchCtrl;
int? _partnerId;
String? _seriesName;
String? _specName;
int? _operatorId;
int? _reviewerId;
late String _status;
DateTimeRange? _range;
static const _statusCodes = ['', 'draft', 'pending', 'approved', 'rejected'];
static const _statusNames = ['全部', '草稿', '待审核', '已审核', '已拒绝'];
@override
void initState() {
super.initState();
final d = widget.initialDetail;
_orderNoCtrl = TextEditingController(text: d['order_no'] ?? '');
_productCtrl = TextEditingController(text: d['product'] ?? '');
_productCodeCtrl = TextEditingController(text: d['product_code'] ?? '');
_batchCtrl = TextEditingController(text: d['batch'] ?? '');
_partnerId = int.tryParse(d['partner_id'] ?? '');
_seriesName = d['series'];
_specName = d['spec'];
_operatorId = int.tryParse(d['operator_id'] ?? '');
_reviewerId = int.tryParse(d['reviewer_id'] ?? '');
_status = widget.initialStatus;
_range = widget.initialRange;
}
@override
void dispose() {
_orderNoCtrl.dispose();
_productCtrl.dispose();
_productCodeCtrl.dispose();
_batchCtrl.dispose();
super.dispose();
}
/// 重置(sheet actions「重置」调用)。
void reset() {
setState(() {
_orderNoCtrl.clear();
_productCtrl.clear();
_productCodeCtrl.clear();
_batchCtrl.clear();
_partnerId = null;
_seriesName = null;
_specName = null;
_operatorId = null;
_reviewerId = null;
_status = '';
_range = null;
});
}
/// 应用(sheet actions「应用」调用):组装结果并关闭 sheet。
void apply() {
final d = <String, String>{};
void put(String k, String? v) {
if (v != null && v.trim().isNotEmpty) d[k] = v.trim();
}
put('order_no', _orderNoCtrl.text);
put('partner_id', _partnerId?.toString());
put('product', _productCtrl.text);
put('product_code', _productCodeCtrl.text);
put('series', _seriesName);
put('spec', _specName);
put('batch', _batchCtrl.text);
put('operator_id', _operatorId?.toString());
put('reviewer_id', _reviewerId?.toString());
Navigator.of(context).pop(_AdvResult(d, _status, _range));
}
Future<void> _pickDate() async {
final range = await showWheelDateRange(context, initial: _range);
if (range != null) setState(() => _range = range);
}
List<OptionItem> _supplierOpts() =>
ref
.watch(allPartnersProvider)
.valueOrNull
?.data
.map((p) => OptionItem(id: p.id, name: p.name))
.toList() ??
const [];
List<OptionItem> _seriesOpts() =>
ref
.watch(productSeriesListProvider)
.valueOrNull
?.map((s) => OptionItem(id: s.id, name: s.name))
.toList() ??
const [];
List<OptionItem> _specOpts() =>
ref
.watch(productSpecListProvider)
.valueOrNull
?.map((s) => OptionItem(id: s.id, name: s.name))
.toList() ??
const [];
List<OptionItem> _userOpts() =>
ref
.watch(userListProvider)
.valueOrNull
?.map((u) => OptionItem(
id: u.id,
name: (u.realName != null && u.realName!.isNotEmpty)
? u.realName!
: u.username))
.toList() ??
const [];
int? _idByName(List<OptionItem> opts, String? name) {
if (name == null || name.isEmpty) return null;
return opts.where((o) => o.name == name).map((o) => o.id).firstOrNull;
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final seriesOpts = _seriesOpts();
final specOpts = _specOpts();
// 原型 .m-fieldlabel + 控件竖排,单列。
Widget field(String label, Widget child) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
child,
],
),
);
// 原型 .m-inputh42 带框文本输入。
Widget textField(TextEditingController c, String hint) => Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: TextField(
controller: c,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
filled: false,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: hint,
hintStyle: TextStyle(color: t.faint, fontSize: AppDims.fsBody),
),
),
);
Widget combo({
required List<OptionItem> opts,
required int? selectedId,
required String hint,
required ValueChanged<int?> onChanged,
}) =>
ComboSearchField(
options: opts,
selectedId: selectedId,
hint: hint,
onChanged: onChanged,
height: 42,
fillColor: t.surface,
);
final statusItems = [
for (var i = 0; i < _statusNames.length; i++)
OptionItem(id: i, name: _statusNames[i]),
];
final statusSelIdx = _statusCodes.indexOf(_status);
final dateHas = _range != null;
final dateField = InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _range = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
field('单据编号', textField(_orderNoCtrl, '如 RK20260401001')),
field(
'供应商',
combo(
opts: _supplierOpts(),
selectedId: _partnerId,
hint: '全部供应商',
onChanged: (v) => setState(() => _partnerId = v))),
field('商品名', textField(_productCtrl, '输入酒名')),
field('商品编码', textField(_productCodeCtrl, '输入商品编码')),
field(
'系列',
combo(
opts: seriesOpts,
selectedId: _idByName(seriesOpts, _seriesName),
hint: '全部系列',
onChanged: (v) => setState(() => _seriesName = v == null
? null
: seriesOpts
.where((o) => o.id == v)
.map((o) => o.name)
.firstOrNull))),
field(
'规格',
combo(
opts: specOpts,
selectedId: _idByName(specOpts, _specName),
hint: '全部规格',
onChanged: (v) => setState(() => _specName = v == null
? null
: specOpts
.where((o) => o.id == v)
.map((o) => o.name)
.firstOrNull))),
field('批次号', textField(_batchCtrl, '输入批次号')),
field(
'状态',
ComboSearchField(
options: statusItems,
selectedId: statusSelIdx <= 0 ? null : statusSelIdx,
hint: '全部',
height: 42,
fillColor: t.surface,
onChanged: (id) => setState(
() => _status = (id == null) ? '' : _statusCodes[id]),
)),
field(
'入库员',
combo(
opts: _userOpts(),
selectedId: _operatorId,
hint: '全部入库员',
onChanged: (v) => setState(() => _operatorId = v))),
field(
'审核人',
combo(
opts: _userOpts(),
selectedId: _reviewerId,
hint: '全部审核人',
onChanged: (v) => setState(() => _reviewerId = v))),
field('单据日期', dateField),
],
);
}
}
@@ -15,6 +15,7 @@ import '../../core/utils/print_util.dart' show LabelData;
import '../../core/auth/auth_state.dart'
show isAdminProvider, currentUserIdProvider;
import '../../models/stock_out.dart';
import '../../models/stock_summary.dart';
import '../../providers/stock_out_provider.dart';
import '../../providers/finance_provider.dart'
show financeRepositoryProvider, financePartnerRowsProvider;
@@ -30,12 +31,15 @@ import '../../widgets/order_detail_drawer.dart';
import '../../widgets/ds/ds_atoms.dart';
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_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';
import '../../widgets/order_print_preview_dialog.dart';
import '../../widgets/order_row_actions.dart';
import '../../widgets/order_return_dialog.dart';
import '../../widgets/wheel_date_picker.dart';
import '../../widgets/write_guard.dart';
@@ -324,16 +328,25 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
.toList();
}
final mobile = context.isMobile;
return Container(
color: context.tokens.bg,
padding: context.isMobile
padding: mobile
? EdgeInsets.zero
// 原型 .main{padding:22px 26px}
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
children: [
_buildHeader(total),
_buildKpis(),
// 窄屏对齐移动原型 m-stock-out-list:标题在顶栏、正文从 KPI 起;
// 无页头按钮(移动端无建单/导出/打印入口——用户拍板)。
if (!mobile) ...[
_buildHeader(total),
_buildKpis(),
] else ...[
_buildMobileKpis(total),
_buildMobileSearchRow(),
_buildMobileSection(total),
],
Expanded(
child: DsTable(
total: total,
@@ -343,7 +356,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ref.read(stockOutListProvider.notifier).setPage(p),
onPageSizeChanged: (s) =>
ref.read(stockOutListProvider.notifier).setPageSize(s),
toolbar: _buildToolbar(orders),
toolbar: mobile ? null : _buildToolbar(orders),
emptyText: '没有匹配的出库单 · 试试调整筛选或搜索',
columns: const [
DsColumn('order_no', '单号'),
@@ -571,13 +584,215 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
);
}
// ── 窄屏形态(对齐移动原型 m-stock-out-list.html)─────────────────
/// 状态设置(主状态 / ret:退单态互斥),KPI 卡与状态 sheet 共用。
void _setStatus(String code) {
final n = ref.read(stockOutListProvider.notifier);
if (code.startsWith('ret:')) {
setState(() {
_statusFilter = code;
_returnState = code.substring(4);
});
n.setStatus('');
_applyDetail();
} else {
setState(() {
_statusFilter = code;
_returnState = '';
});
n.setStatus(code);
_applyDetail();
}
}
/// 状态词 → (前景, 软底):镜像原型 .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 =
ref.watch(stockOutSummary30Provider).valueOrNull;
String pct(double? p) => p == null
? '较上期 —'
: '${p >= 0 ? '' : ''} ${p.abs().toStringAsFixed(1)}% 较上期';
MKpiDeltaTone tone(double? p) => p == null
? MKpiDeltaTone.normal
: (p >= 0 ? MKpiDeltaTone.up : MKpiDeltaTone.down);
final pendingCount = summary?.pendingCount ?? 0;
final draftCount = summary?.draftCount ?? 0;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: MKpiGrid(items: [
MKpiItem(
label: '近30天出库笔数',
value: NumberFormat.decimalPattern()
.format(summary?.monthCount ?? total),
icon: LucideIcons.upload,
delta: pct(summary?.countDeltaPct),
deltaTone: tone(summary?.countDeltaPct)),
MKpiItem(
label: '近30天出库金额',
value: summary != null ? _yuanWan(summary.monthAmount) : '',
icon: LucideIcons.banknote,
delta: pct(summary?.amountDeltaPct),
deltaTone: tone(summary?.amountDeltaPct)),
MKpiItem(
label: '待审核 · 点击筛选',
value: '$pendingCount',
icon: LucideIcons.clock,
delta: pendingCount > 0 ? '需尽快处理' : '点击筛选',
deltaTone:
pendingCount > 0 ? MKpiDeltaTone.warn : MKpiDeltaTone.normal,
selected: _statusFilter == 'pending',
onTap: () =>
_setStatus(_statusFilter == 'pending' ? '' : 'pending')),
MKpiItem(
label: '草稿 · 点击筛选',
value: '$draftCount',
icon: LucideIcons.fileText,
delta: draftCount > 0 ? '待提交审核' : '点击筛选',
selected: _statusFilter == 'draft',
onTap: () => _setStatus(_statusFilter == 'draft' ? '' : 'draft')),
]),
);
}
/// 详细搜索是否有生效条件(不含状态钮自己的态)→ 详搜钮高亮。
bool get _mobileAdvActive =>
_orderNo.isNotEmpty ||
_productInfo.isNotEmpty ||
_productCode.isNotEmpty ||
_batch.isNotEmpty ||
_series.isNotEmpty ||
_spec.isNotEmpty ||
_partnerId != null ||
_operatorId != null ||
_reviewerId != null ||
_dateRange != null;
/// 原型搜索区:搜索框 + 纯文字状态钮 + 图标详搜钮。
Widget _buildMobileSearchRow() {
final statusLabel = _statusFilter.isEmpty
? '全部'
: _statusOptions
.firstWhere((e) => e.$1 == _statusFilter, orElse: () => ('', '全部'))
.$2;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 0),
child: MSearchRow(
controller: _searchCtrl,
hint: '搜索单号 / 客户 / 酒名',
// setKeyword 内置 350ms 防抖 + 同词去重,oninput 直连即可(对齐原型)。
onChanged: (v) => ref.read(stockOutListProvider.notifier).setKeyword(v),
onSubmitted: (v) =>
ref.read(stockOutListProvider.notifier).setKeyword(v),
statusLabel: statusLabel,
statusActive: _statusFilter.isNotEmpty,
onStatusTap: _openStatusSheet,
filterActive: _mobileAdvActive,
onFilterTap: _openAdvSearch,
),
);
}
/// 原型 .m-section:出库单 · 共 N。
Widget _buildMobileSection(int total) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text('出库单 · 共 $total',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: context.tokens.muted)),
),
);
}
/// 状态筛选底部 sheet(原型 openStatusSheet 的 m-opt 列表:图标 + 状态词 + 勾)。
Future<void> _openStatusSheet() async {
final sel = await showMSheet<String>(
context,
title: '单据状态',
builder: (ctx) {
final t = ctx.tokens;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _statusOptions.length; i++)
InkWell(
onTap: () => Navigator.of(ctx).pop(_statusOptions[i].$1),
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 13, horizontal: 4),
decoration: BoxDecoration(
border: i < _statusOptions.length - 1
? Border(bottom: BorderSide(color: t.borderSubtle))
: null,
),
child: Row(children: [
if (_statusOptions[i].$2 != '全部') ...[
_icoBadge(_statusOptions[i].$2),
const SizedBox(width: 10),
],
Text(_statusOptions[i].$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: _statusOptions[i].$1 == _statusFilter
? FontWeight.w600
: FontWeight.w400,
color: _statusOptions[i].$1 == _statusFilter
? t.primary
: t.text)),
const Spacer(),
if (_statusOptions[i].$1 == _statusFilter)
Icon(LucideIcons.check, size: 18, color: t.primary),
]),
),
),
],
);
},
);
if (sel == null || !mounted) return;
_setStatus(sel);
}
// ── 工具栏(原型 .toolbar)─────────────────────────────────────
Widget _buildToolbar(List<StockOutOrder> orders) {
final isMobile = context.isMobile;
// 仅桌面调用(窄屏走 MSearchRow + 状态/详搜 sheet,不再进此工具栏)。
final searchField = SizedBox(
width: isMobile ? double.infinity : 260,
width: 260,
child: DsSearchBox(
controller: _searchCtrl,
hint: '单号 / 酒名 / 商品编码',
@@ -597,7 +812,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
options: customers,
selectedId: _partnerId,
hint: '客户',
width: isMobile ? 320 : 180,
width: 180,
onChanged: (id) {
setState(() => _partnerId = id);
_applyDetail();
@@ -680,33 +895,15 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onPressed: _resetFilters,
);
// 筛选 chip(不含重置)——重置在桌面推到最右、移动端并入 Wrap 末尾(对齐原型 .sp)。
// 筛选 chip(不含重置)——重置推到最右(对齐原型 .sp)。
final filterChips = <Widget>[
if (!isMobile) customerField,
customerField,
warehouseChip,
statusChip,
dateChip,
advBtn,
];
if (isMobile) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
searchField,
const SizedBox(height: AppDims.sp2),
customerField,
const SizedBox(height: AppDims.sp2),
Wrap(
spacing: AppDims.sp2,
runSpacing: AppDims.sp2,
crossAxisAlignment: WrapCrossAlignment.center,
children: [...filterChips, resetBtn],
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -744,75 +941,63 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
// ── 详细搜索模态(11 字段)──────────────────────────────────────
Future<void> _openAdvSearch() async {
final result = await showAppDialog<_AdvResult>(
context: context,
builder: (_) => _AdvancedSearchDialog(
initial: _AdvResult(
orderNo: _orderNo,
productInfo: _productInfo,
productCode: _productCode,
batch: _batch,
partnerId: _partnerId,
series: _series,
spec: _spec,
operatorId: _operatorId,
reviewerId: _reviewerId,
status: _statusFilter,
dateRange: _dateRange,
),
),
final initial = _AdvResult(
orderNo: _orderNo,
productInfo: _productInfo,
productCode: _productCode,
batch: _batch,
partnerId: _partnerId,
series: _series,
spec: _spec,
operatorId: _operatorId,
reviewerId: _reviewerId,
status: _statusFilter,
dateRange: _dateRange,
);
if (result == null || !mounted) return;
final _AdvResult? result;
if (context.isMobile) {
// 窄屏:底部 sheet 竖排单列(字段与桌面同集,原型 drawAdv)。
final formKey = GlobalKey<_AdvSearchSheetState>();
result = await showMSheet<_AdvResult>(
context,
title: '详细搜索',
builder: (_) => _AdvSearchSheet(key: formKey, initial: initial),
actions: [
DsButton('重置', onPressed: () => formKey.currentState?.reset()),
DsButton('应用',
variant: DsBtnVariant.primary,
onPressed: () => formKey.currentState?.apply()),
],
);
} else {
result = await showAppDialog<_AdvResult>(
context: context,
builder: (_) => _AdvancedSearchDialog(initial: initial),
);
}
final r = result;
if (r == null || !mounted) return;
setState(() {
_orderNo = result.orderNo;
_productInfo = result.productInfo;
_productCode = result.productCode;
_batch = result.batch;
_partnerId = result.partnerId;
_series = result.series;
_spec = result.spec;
_operatorId = result.operatorId;
_reviewerId = result.reviewerId;
_statusFilter = result.status;
_dateRange = result.dateRange;
_orderNo = r.orderNo;
_productInfo = r.productInfo;
_productCode = r.productCode;
_batch = r.batch;
_partnerId = r.partnerId;
_series = r.series;
_spec = r.spec;
_operatorId = r.operatorId;
_reviewerId = r.reviewerId;
_statusFilter = r.status;
_dateRange = r.dateRange;
});
_applyAll();
}
// ── 行操作 / 卡片 ─────────────────────────────────────────────
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
return buildOrderRowActions(
context: context,
readonly: WriteGuard.isReadonly(ref),
canWithdraw: ref.watch(isAdminProvider) ||
(o.operatorId != null &&
o.operatorId == ref.watch(currentUserIdProvider)),
status: o.status,
orderId: o.id,
onDetail: () => _showDetail(context, o.id),
onPrint: () async {
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
if (context.mounted) {
await showStockOutOrderPrint(context, ref, order);
}
},
onSettle: () => _confirmSettle(context, o.id, 'stock_out'),
onEdit: () => context.go('/stock-out/edit/${o.id}'),
onDelete: () => _confirmDelete(context, o),
onSubmit: () => _confirmSubmit(context, o),
onApprove: () => _confirmApprove(context, o),
onReject: () => _confirmReject(context, o),
onWithdraw: () => _confirmWithdraw(context, o),
// 出库退单:管理员/超管 或 本人单
canReturn: ref.watch(isAdminProvider) ||
(o.operatorId != null &&
o.operatorId == ref.watch(currentUserIdProvider)),
onReturn: () => _confirmReturn(context, o),
);
}
// ── 窄屏卡片 ─────────────────────────────────────────────────
/// 出库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。
Widget _orderCard(BuildContext context, StockOutOrder o) {
final hasPending = o.items.any((it) => it.salePrice <= 0);
return MobileListCard(
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
@@ -820,7 +1005,22 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 14)),
trailing: _cellStatus(o),
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 ?? '-'),
@@ -828,7 +1028,6 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
o.saleTotal != null ? '¥${o.saleTotal!.toStringAsFixed(2)}' : '-'),
MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'),
],
actions: _orderActions(context, o),
);
}
@@ -984,17 +1183,20 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
]));
}
groups.add(DrawerActionGroup('打印', [
b('打印标签', () {
close();
_printLabels(o);
}),
b('打印单据', () async {
close();
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
if (mounted) await showStockOutOrderPrint(context, ref, order);
}),
]));
// 窄屏详情不出打印入口(移动端无打印——用户拍板)。
if (!context.isMobile) {
groups.add(DrawerActionGroup('打印', [
b('打印标签', () {
close();
_printLabels(o);
}),
b('打印单据', () async {
close();
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
if (mounted) await showStockOutOrderPrint(context, ref, order);
}),
]));
}
return groups;
}
@@ -1033,61 +1235,112 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
final controllers = {
for (final it in pending) it.id!: TextEditingController()
};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认售价'),
content: SizedBox(
width: context.dialogWidth(440),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'「先后定价」:填写真实售价后,系统据此结算应收(售价 × 数量并补一条应收流水——不影响成本 / 库存,不反审核',
style: TextStyle(fontSize: 12, color: context.tokens.muted),
),
const SizedBox(height: 12),
...pending.map((it) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
SizedBox(
width: 130,
child: TextField(
controller: controllers[it.id],
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
prefixText: '¥',
hintText: '真实售价',
isDense: true),
),
),
],
),
);
}),
],
final bool? ok;
if (context.isMobile) {
// 窄屏:底部 sheet(对齐原型 openConfirm)。
ok = await showMSheet<bool>(
context,
title: '确认售价',
builder: (ctx) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'「先后定价」:填写真实售价后,系统售价 × 数量结算应收并补一条应收流水——不反审核、不影响成本 / 应付',
style: TextStyle(
fontSize: AppDims.fsSm, height: 1.6, color: ctx.tokens.muted),
),
),
const SizedBox(height: 14),
for (final it in pending)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} · 数量 ${it.quantity.toStringAsFixed(0)}',
style: TextStyle(
fontSize: AppDims.fsSm, color: ctx.tokens.muted)),
const SizedBox(height: 6),
TextField(
controller: controllers[it.id],
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
prefixText: '¥',
hintText: '真实售价 0.00',
isDense: true),
),
],
),
),
],
),
actions: [
DsButton('取消', onPressed: () => Navigator.pop(ctx, false)),
DsButton('取消', onPressed: () => Navigator.of(context).pop(false)),
DsButton('确认并补应收',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.pop(ctx, true)),
onPressed: () => Navigator.of(context).pop(true)),
],
),
);
);
} else {
ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认售价'),
content: SizedBox(
width: context.dialogWidth(440),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'「先出后定价」:填写真实售价后,系统据此结算应收(售价 × 数量)并补一条应收流水——不影响成本 / 库存,不反审核。',
style: TextStyle(fontSize: 12, color: context.tokens.muted),
),
const SizedBox(height: 12),
...pending.map((it) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 13),
),
),
const SizedBox(width: 8),
SizedBox(
width: 130,
child: TextField(
controller: controllers[it.id],
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(
prefixText: '¥',
hintText: '真实售价',
isDense: true),
),
),
],
),
);
}),
],
),
),
),
actions: [
DsButton('取消', onPressed: () => Navigator.pop(ctx, false)),
DsButton('确认并补应收',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.pop(ctx, true)),
],
),
);
}
final items = <Map<String, dynamic>>[];
if (ok == true) {
for (final it in pending) {
@@ -1795,3 +2048,274 @@ class _MenuChip extends StatelessWidget {
);
}
}
// ── 详细搜索(窄屏底部 sheet,竖排单列;字段与桌面 _AdvancedSearchDialog 完全
// 同集,对齐原型 drawAdv;重置/应用按钮由 showMSheet actions 注入)────────
class _AdvSearchSheet extends ConsumerStatefulWidget {
final _AdvResult initial;
const _AdvSearchSheet({super.key, required this.initial});
@override
ConsumerState<_AdvSearchSheet> createState() => _AdvSearchSheetState();
}
class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
late final TextEditingController _orderNoCtrl;
late final TextEditingController _productCtrl;
late final TextEditingController _productCodeCtrl;
late final TextEditingController _batchCtrl;
late int? _partnerId;
late String _series;
late String _spec;
late int? _operatorId;
late int? _reviewerId;
late String _status;
DateTimeRange? _dateRange;
static const _statusCodes = ['', 'draft', 'pending', 'approved', 'rejected'];
static const _statusNames = ['全部', '草稿', '待审核', '已审核', '已拒绝'];
@override
void initState() {
super.initState();
final i = widget.initial;
_orderNoCtrl = TextEditingController(text: i.orderNo);
_productCtrl = TextEditingController(text: i.productInfo);
_productCodeCtrl = TextEditingController(text: i.productCode);
_batchCtrl = TextEditingController(text: i.batch);
_partnerId = i.partnerId;
_series = i.series;
_spec = i.spec;
_operatorId = i.operatorId;
_reviewerId = i.reviewerId;
_status = i.status;
_dateRange = i.dateRange;
}
@override
void dispose() {
_orderNoCtrl.dispose();
_productCtrl.dispose();
_productCodeCtrl.dispose();
_batchCtrl.dispose();
super.dispose();
}
/// 重置(sheet actions「重置」调用)。
void reset() {
setState(() {
_orderNoCtrl.clear();
_productCtrl.clear();
_productCodeCtrl.clear();
_batchCtrl.clear();
_partnerId = null;
_series = '';
_spec = '';
_operatorId = null;
_reviewerId = null;
_status = '';
_dateRange = null;
});
}
/// 应用(sheet actions「应用」调用):组装结果并关闭 sheet。
void apply() {
Navigator.of(context).pop(_AdvResult(
orderNo: _orderNoCtrl.text.trim(),
productInfo: _productCtrl.text.trim(),
productCode: _productCodeCtrl.text.trim(),
batch: _batchCtrl.text.trim(),
partnerId: _partnerId,
series: _series,
spec: _spec,
operatorId: _operatorId,
reviewerId: _reviewerId,
status: _status,
dateRange: _dateRange,
));
}
Future<void> _pickDate() async {
final range = await showWheelDateRange(context, initial: _dateRange);
if (range != null) setState(() => _dateRange = range);
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final customers =
(ref.watch(allPartnersProvider).valueOrNull?.data ?? const [])
.map((p) => OptionItem(id: p.id, name: p.name, code: p.code))
.toList();
final seriesOpts =
(ref.watch(productSeriesListProvider).valueOrNull ?? const [])
.map((s) => OptionItem(id: s.id, name: s.name))
.toList();
final specOpts =
(ref.watch(productSpecListProvider).valueOrNull ?? const [])
.map((s) => OptionItem(id: s.id, name: s.name))
.toList();
final users = (ref.watch(userListProvider).valueOrNull ?? const [])
.map((u) => OptionItem(id: u.id, name: u.realName ?? u.username))
.toList();
int? idByName(List<OptionItem> opts, String name) =>
opts.where((o) => o.name == name).firstOrNull?.id;
// 原型 .m-fieldlabel + 控件竖排,单列。
Widget field(String label, Widget child) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
child,
],
),
);
// 原型 .m-inputh42 带框文本输入。
Widget textField(TextEditingController c, String hint) => Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: TextField(
controller: c,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
filled: false,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: hint,
hintStyle: TextStyle(color: t.faint, fontSize: AppDims.fsBody),
),
),
);
Widget combo({
required List<OptionItem> opts,
required int? selectedId,
required String hint,
required ValueChanged<int?> onChanged,
}) =>
ComboSearchField(
options: opts,
selectedId: selectedId,
hint: hint,
onChanged: onChanged,
height: 42,
fillColor: t.surface,
);
final statusItems = [
for (var i = 0; i < _statusNames.length; i++)
OptionItem(id: i, name: _statusNames[i]),
];
final statusSelIdx = _statusCodes.indexOf(_status);
final dateHas = _dateRange != null;
final dateField = InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _dateRange = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
field('单据编号', textField(_orderNoCtrl, '如 CK20260401001')),
field(
'客户',
combo(
opts: customers,
selectedId: _partnerId,
hint: '全部客户',
onChanged: (v) => setState(() => _partnerId = v))),
field('商品名', textField(_productCtrl, '输入酒名')),
field('商品编码', textField(_productCodeCtrl, '输入商品编码')),
field(
'系列',
combo(
opts: seriesOpts,
selectedId: idByName(seriesOpts, _series),
hint: '全部系列',
onChanged: (id) => setState(() => _series = id == null
? ''
: seriesOpts.firstWhere((o) => o.id == id).name))),
field(
'规格',
combo(
opts: specOpts,
selectedId: idByName(specOpts, _spec),
hint: '全部规格',
onChanged: (id) => setState(() => _spec = id == null
? ''
: specOpts.firstWhere((o) => o.id == id).name))),
field('批次号', textField(_batchCtrl, '输入批次号')),
field(
'状态',
ComboSearchField(
options: statusItems,
selectedId: statusSelIdx <= 0 ? null : statusSelIdx,
hint: '全部',
height: 42,
fillColor: t.surface,
onChanged: (id) => setState(
() => _status = (id == null) ? '' : _statusCodes[id]),
)),
field(
'出库员',
combo(
opts: users,
selectedId: _operatorId,
hint: '全部出库员',
onChanged: (id) => setState(() => _operatorId = id))),
field(
'审核人',
combo(
opts: users,
selectedId: _reviewerId,
hint: '全部审核人',
onChanged: (id) => setState(() => _reviewerId = id))),
field('单据日期', dateField),
],
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 159 KiB

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/auth/auth_state.dart';
import 'package:jiu_client/models/stock_in.dart';
import 'package:jiu_client/models/stock_summary.dart';
import 'package:jiu_client/providers/stock_in_provider.dart';
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
import '../support/golden_harness.dart';
/// P1 单据域:入库列表窄屏 golden × 三主题(390×844 @2x)——对齐移动原型
/// m-stock-in-list.htmlKPI 2×2(待审核/草稿可点筛选)+ MSearchRow(搜索 +
/// 状态钮 + 详搜钮)+ .m-section + 卡片流(图标徽章含派生态 + › 箭头)。
/// 更新基准:flutter test --update-goldens test/golden/stock_in_list_mobile_golden_test.dart
const _orders = [
// 已审核 + 部分退单 + 待定价(三徽章派生态齐全)
StockInOrder(
id: 1,
orderNo: 'RK-20260620-014',
warehouseId: 1,
warehouseName: '主仓',
partnerName: '贵州茅台经销',
operatorName: '王经理',
reviewerName: '张管理',
status: 'approved',
returnState: 'partial',
orderDate: '2026-06-20',
costTotal: 34320,
items: [
StockInItem(productId: 1, quantity: 6, costPrice: 0, costAmount: 0),
StockInItem(
productId: 2, quantity: 6, costPrice: 2680, costAmount: 16080),
]),
StockInOrder(
id: 2,
orderNo: 'RK-20260618-009',
warehouseId: 1,
warehouseName: '主仓',
partnerName: '宜宾五粮液',
operatorName: '李销售',
status: 'pending',
orderDate: '2026-06-18',
costTotal: 6700),
StockInOrder(
id: 3,
orderNo: 'RK-20260615-003',
warehouseId: 1,
warehouseName: '二号仓',
partnerName: '泸州老窖',
operatorName: '王经理',
status: 'draft',
orderDate: '2026-06-15',
costTotal: 5000),
StockInOrder(
id: 4,
orderNo: 'RK-20260612-001',
warehouseId: 1,
warehouseName: '主仓',
partnerName: '绵竹剑南春',
operatorName: '李销售',
reviewerName: '张管理',
status: 'rejected',
orderDate: '2026-06-12',
costTotal: 3500),
];
class _FakeStockInNotifier extends StockInListNotifier {
@override
Future<PageResult<StockInOrder>> build() async =>
const PageResult(data: _orders, total: 4, page: 1, pageSize: 20);
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setStatus(String status) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@override
void setKeyword(String keyword) {}
@override
void reload() {}
}
List<Override> _overrides() => [
stockInListProvider.overrideWith(() => _FakeStockInNotifier()),
isReadonlyProvider.overrideWithValue(false),
// KPI 2×2(近30天笔数/金额/待审核/草稿):对齐原型示例数值。
stockInSummary30Provider.overrideWith((ref) => const StockSummary(
monthCount: 28,
monthAmount: 1860000,
pendingCount: 4,
draftCount: 2,
lastMonthCount: 25,
lastMonthAmount: 1715000,
)),
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
goldenAcrossThemes(
'stock-in list 窄屏(移动原型)',
goldenPrefix: 'm_stock_in_list',
child: () => const Scaffold(body: StockInListScreen()),
overrides: _overrides,
logical: const Size(390, 844),
dpr: 2,
);
}
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/auth/auth_state.dart';
import 'package:jiu_client/models/stock_out.dart';
import 'package:jiu_client/models/stock_summary.dart';
import 'package:jiu_client/providers/stock_out_provider.dart';
import 'package:jiu_client/screens/stock_out/stock_out_list_screen.dart';
import '../support/golden_harness.dart';
/// P1 单据域:出库列表窄屏 golden × 三主题(390×844 @2x)——对齐移动原型
/// m-stock-out-list.htmlKPI 2×2(待审核/草稿可点筛选)+ MSearchRow(搜索 +
/// 状态钮 + 详搜钮)+ .m-section + 卡片流(图标徽章含派生态 + › 箭头)。
/// 更新基准:flutter test --update-goldens test/golden/stock_out_list_mobile_golden_test.dart
const _orders = [
// 已审核 + 部分退单(图标徽章派生态)
StockOutOrder(
id: 1,
orderNo: 'CK-20260620-031',
warehouseId: 1,
warehouseName: '主仓',
partnerName: '鼎丰超市',
operatorName: '李销售',
reviewerName: '张管理',
status: 'approved',
returnState: 'partial',
orderDate: '2026-06-20',
saleTotal: 12800,
items: [
StockOutItem(
productId: 1,
quantity: 4,
salePrice: 3200,
saleAmount: 12800,
costPrice: 2680,
costAmount: 10720),
]),
// 已审核 + 待定价(先卖后定价)
StockOutOrder(
id: 2,
orderNo: 'CK-20260619-026',
warehouseId: 1,
warehouseName: '名酒仓',
partnerName: '金樽会所',
operatorName: '王经理',
reviewerName: '张管理',
status: 'approved',
orderDate: '2026-06-19',
saleTotal: 28000,
items: [
StockOutItem(productId: 2, quantity: 13, salePrice: 0, saleAmount: 0),
]),
StockOutOrder(
id: 3,
orderNo: 'CK-20260618-022',
warehouseId: 1,
warehouseName: '主仓',
partnerName: '金樽酒楼',
operatorName: '张前台',
status: 'pending',
orderDate: '2026-06-18',
saleTotal: 5400),
StockOutOrder(
id: 4,
orderNo: 'CK-20260615-017',
warehouseId: 1,
warehouseName: '二号仓',
partnerName: '恒昌名酒行',
operatorName: '李销售',
status: 'draft',
orderDate: '2026-06-15',
saleTotal: 3200),
];
class _FakeStockOutNotifier extends StockOutListNotifier {
@override
Future<PageResult<StockOutOrder>> build() async =>
const PageResult(data: _orders, total: 4, page: 1, pageSize: 20);
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setStatus(String status) {}
@override
void setDateRange(String? startDate, String? endDate) {}
@override
void setKeyword(String keyword) {}
@override
void setProductCode(String code) {}
@override
void reload() {}
}
List<Override> _overrides() => [
stockOutListProvider.overrideWith(() => _FakeStockOutNotifier()),
isReadonlyProvider.overrideWithValue(false),
// KPI 2×2(近30天笔数/金额/待审核/草稿):对齐原型示例数值。
stockOutSummary30Provider.overrideWith((ref) => const StockSummary(
monthCount: 35,
monthAmount: 2160000,
pendingCount: 3,
draftCount: 1,
lastMonthCount: 32,
lastMonthAmount: 2022000,
)),
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
goldenAcrossThemes(
'stock-out list 窄屏(移动原型)',
goldenPrefix: 'm_stock_out_list',
child: () => const Scaffold(body: StockOutListScreen()),
overrides: _overrides,
logical: const Size(390, 844),
dpr: 2,
);
}