2bf5b98ba2
- 出入库列表加草稿 KPI 卡(DsKpiTone.warn 新档,审核卡后,点击筛选草稿) - 出库详情 7 列:售价后加总售价;明细行内价格去 ¥ 且缩一号(合计保留 ¥); 5/7 列宽重排(系列/数量左移、进价(单瓶)不折行) - 出库建单:数量默认=可用数量、售价默认=参考售价(product.sale_price) - 入库详情列名:进价(单瓶)/总价;库存列表「单价」→「总价」(库存×成本); 商品抽屉:成本价(单瓶)/总成本价 - 公开商品预览页加返回按钮(仅 App 内 canPop 时显示,扫码直达保持纯净) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
469 lines
17 KiB
Dart
469 lines
17 KiB
Dart
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../core/theme/context_tokens.dart';
|
||
import '../core/theme/app_dims.g.dart';
|
||
import '../core/theme/app_fonts.dart';
|
||
|
||
/// 右侧滑入抽屉呈现(对齐原型 .drawer / .drawer-mask)。
|
||
/// useRootNavigator:false → 只覆盖内容区(分支 Navigator),不盖侧栏/顶栏。
|
||
const _kDrawerMask = Color(0x52000000); // ds-ignore: .drawer-mask 遮罩固定色(黑 32%)
|
||
|
||
Future<T?> showOrderDetailDrawer<T>(
|
||
BuildContext context, {
|
||
required WidgetBuilder builder,
|
||
}) {
|
||
return showGeneralDialog<T>(
|
||
context: context,
|
||
useRootNavigator: false,
|
||
barrierDismissible: true,
|
||
barrierLabel: '关闭',
|
||
// .drawer-mask scrim(抽屉遮罩固定色)
|
||
barrierColor: _kDrawerMask,
|
||
transitionDuration: const Duration(milliseconds: 250),
|
||
pageBuilder: (ctx, _, __) {
|
||
final w = math.min(600.0, MediaQuery.of(ctx).size.width * 0.94);
|
||
return Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Material(
|
||
color: ctx.tokens.surface,
|
||
elevation: 16,
|
||
// 抽屉在 app_shell 的全局 SelectionArea 之外(独立 overlay)→ 自带一层,文字可选中复制。
|
||
child: SelectionArea(
|
||
child: SizedBox(
|
||
width: w, height: double.infinity, child: builder(ctx)),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
transitionBuilder: (ctx, anim, _, child) => SlideTransition(
|
||
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero)
|
||
.animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
|
||
child: child,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 抽屉明细行(对齐原型 .lines .lr,5 列:商品·编码 / 系列·规格 / 数量 / 单价 / 金额)。
|
||
class OrderLine {
|
||
final String name;
|
||
final String code;
|
||
final String series;
|
||
final String spec;
|
||
final String qty;
|
||
final String price; // 已算好文案(或「待定价」)
|
||
final String amount;
|
||
// 出库管理员 6 列形态(2026-07 定价重设计):成本价 + 利润;
|
||
// 两者非空时明细表渲染 数量/成本价/售价(price)/利润 四数值列,amount 不用。
|
||
final String? cost;
|
||
final String? profit;
|
||
final bool pending; // 单价<=0:单价/金额显示待定价样式
|
||
const OrderLine({
|
||
required this.name,
|
||
required this.code,
|
||
required this.series,
|
||
required this.spec,
|
||
required this.qty,
|
||
required this.price,
|
||
this.amount = '',
|
||
this.cost,
|
||
this.profit,
|
||
this.pending = false,
|
||
});
|
||
}
|
||
|
||
/// 抽屉底部操作分组(对齐原型 .dact-group:标签 + 一排按钮)。
|
||
class DrawerActionGroup {
|
||
final String label;
|
||
final List<Widget> buttons;
|
||
const DrawerActionGroup(this.label, this.buttons);
|
||
}
|
||
|
||
/// 详情抽屉骨架(对齐原型 openDetail 布局):
|
||
/// 头(单号+X) / 信息行(.drow) / 明细标题 / 明细表(.lines) / 合计(.ltotal) / 操作组(.dactions)。
|
||
class OrderDetailDrawer extends StatelessWidget {
|
||
final String title;
|
||
final List<({String label, Widget value})> infoRows;
|
||
final String linesLabel; // 入库明细 / 出库明细
|
||
final List<OrderLine> lines;
|
||
final String totalText; // 合计金额(已格式化)
|
||
final String? profitText; // 合计利润(仅出库·管理员传入;null 不渲染)
|
||
final String priceLabel; // 单价列头(入库=进价(单瓶)/出库=售价)
|
||
final String amountLabel; // 金额列头(入库=总进价/出库 operator=小计)
|
||
final List<DrawerActionGroup> actionGroups;
|
||
|
||
const OrderDetailDrawer({
|
||
super.key,
|
||
required this.title,
|
||
required this.infoRows,
|
||
required this.linesLabel,
|
||
required this.lines,
|
||
required this.totalText,
|
||
this.profitText,
|
||
this.priceLabel = '单价',
|
||
this.amountLabel = '金额',
|
||
required this.actionGroups,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// ── 头:单号 + 关闭 ──(.drawer-head)
|
||
Container(
|
||
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: t.border))),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(title,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsH2,
|
||
fontWeight: FontWeight.w700,
|
||
color: t.heading)),
|
||
),
|
||
IconButton(
|
||
icon: Icon(LucideIcons.x, size: 22, color: t.muted),
|
||
splashRadius: 20,
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// ── 信息行(.drow)──
|
||
for (final r in infoRows)
|
||
_DrawerRow(label: r.label, value: r.value),
|
||
// ── 明细标题 ──
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(0, 16, 0, 8),
|
||
child: Text(linesLabel,
|
||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||
),
|
||
_LinesTable(
|
||
lines: lines,
|
||
priceLabel: priceLabel,
|
||
amountLabel: amountLabel),
|
||
// ── 合计(.ltotal)──
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Text('合计金额 ',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody, color: t.muted)),
|
||
const SizedBox(width: 6),
|
||
Text(totalText,
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: FontWeight.w700,
|
||
color: t.accent)),
|
||
],
|
||
),
|
||
),
|
||
if (profitText != null)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
Text('合计利润 ',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody, color: t.muted)),
|
||
const SizedBox(width: 6),
|
||
Text(profitText!,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsTitle,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: FontWeight.w700,
|
||
color: profitText!.startsWith('-')
|
||
? t.danger
|
||
: t.success)),
|
||
],
|
||
),
|
||
),
|
||
// ── 操作组(.dactions)──
|
||
if (actionGroups.isNotEmpty) const SizedBox(height: 18),
|
||
for (var i = 0; i < actionGroups.length; i++) ...[
|
||
if (i > 0) const SizedBox(height: 14),
|
||
_ActionGroup(group: actionGroups[i]),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DrawerRow extends StatelessWidget {
|
||
final String label;
|
||
final Widget value;
|
||
const _DrawerRow({required this.label, required this.value});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: t.borderSubtle))),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label,
|
||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
const Spacer(),
|
||
DefaultTextStyle(
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.text),
|
||
child: value,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _LinesTable extends StatelessWidget {
|
||
final List<OrderLine> lines;
|
||
final String priceLabel;
|
||
final String amountLabel;
|
||
const _LinesTable(
|
||
{required this.lines,
|
||
required this.priceLabel,
|
||
required this.amountLabel});
|
||
|
||
// 任一行带成本/利润 → 出库管理员 6 列形态
|
||
bool get _withProfit => lines.any((l) => l.cost != null || l.profit != null);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: t.border),
|
||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// 表头
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: t.thBg,
|
||
borderRadius:
|
||
const BorderRadius.vertical(top: Radius.circular(6)),
|
||
),
|
||
child: _lineRow(
|
||
context,
|
||
c1: Text('商品 · 编码',
|
||
style: _hStyle(t), overflow: TextOverflow.ellipsis),
|
||
c2: Text('系列 / 规格', style: _hStyle(t)),
|
||
c3: Text('数量', textAlign: TextAlign.right, style: _hStyle(t)),
|
||
c4: _withProfit
|
||
? Text('成本价', textAlign: TextAlign.right, style: _hStyle(t))
|
||
: Text(priceLabel,
|
||
textAlign: TextAlign.right, style: _hStyle(t)),
|
||
c5: _withProfit
|
||
? Text('售价', textAlign: TextAlign.right, style: _hStyle(t))
|
||
: Text(amountLabel,
|
||
textAlign: TextAlign.right, style: _hStyle(t)),
|
||
c6: _withProfit
|
||
? Text('总售价', textAlign: TextAlign.right, style: _hStyle(t))
|
||
: null,
|
||
c7: _withProfit
|
||
? Text('利润', textAlign: TextAlign.right, style: _hStyle(t))
|
||
: null,
|
||
),
|
||
),
|
||
for (var i = 0; i < lines.length; i++)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
border: i == 0
|
||
? null
|
||
: Border(top: BorderSide(color: t.borderSubtle)),
|
||
),
|
||
child: _lineRow(
|
||
context,
|
||
c1: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(lines[i].name,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.text)),
|
||
Text(lines[i].code,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
color: t.faint)),
|
||
],
|
||
),
|
||
c2: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(lines[i].series,
|
||
style:
|
||
TextStyle(fontSize: AppDims.fsSm, color: t.text)),
|
||
Text(lines[i].spec,
|
||
style:
|
||
TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
|
||
],
|
||
),
|
||
c3: Text(lines[i].qty,
|
||
textAlign: TextAlign.right,
|
||
style: const TextStyle(
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback)),
|
||
c4: _withProfit
|
||
? _priceCell(
|
||
context, lines[i].cost ?? '—', lines[i].pending)
|
||
: _priceCell(context, lines[i].price, lines[i].pending),
|
||
c5: _withProfit
|
||
? _priceCell(context, lines[i].price, lines[i].pending)
|
||
: _priceCell(context, lines[i].amount, lines[i].pending,
|
||
accent: true),
|
||
c6: _withProfit
|
||
? _priceCell(context, lines[i].amount, lines[i].pending,
|
||
accent: true)
|
||
: null,
|
||
c7: _withProfit
|
||
? _profitCell(
|
||
context, lines[i].profit ?? '—', lines[i].pending)
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
TextStyle _hStyle(dynamic t) => TextStyle(
|
||
fontSize: AppDims.fsSm, color: t.muted, fontWeight: FontWeight.w600);
|
||
|
||
Widget _priceCell(BuildContext context, String text, bool pending,
|
||
{bool accent = false}) {
|
||
final t = context.tokens;
|
||
if (pending) {
|
||
return Text(text,
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
color: t.warn,
|
||
fontWeight: FontWeight.w600));
|
||
}
|
||
return Text(text,
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: accent ? FontWeight.w700 : FontWeight.w400,
|
||
color: accent ? t.text : t.text));
|
||
}
|
||
|
||
// 5 列布局(对齐原型 grid 1fr 124 40 64 78);c6/c7 非空时为出库 7 列形态
|
||
// (数量/成本价/售价/总售价/利润,数值列等宽收窄)。
|
||
Widget _lineRow(BuildContext context,
|
||
{required Widget c1,
|
||
required Widget c2,
|
||
required Widget c3,
|
||
required Widget c4,
|
||
required Widget c5,
|
||
Widget? c6,
|
||
Widget? c7}) {
|
||
final wide = c6 != null;
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(child: c1),
|
||
const SizedBox(width: 8),
|
||
// 5 列(入库)时中间列加宽左移:商品列不再吃掉全部余宽,
|
||
// 「进价(单瓶)」表头一行放得下(2026-07-03 用户反馈)。
|
||
SizedBox(width: wide ? 100 : 124, child: c2),
|
||
const SizedBox(width: 8),
|
||
SizedBox(width: wide ? 32 : 40, child: c3),
|
||
const SizedBox(width: 8),
|
||
SizedBox(width: wide ? 62 : 88, child: c4),
|
||
const SizedBox(width: 8),
|
||
SizedBox(width: wide ? 62 : 88, child: c5),
|
||
if (c6 != null) ...[
|
||
const SizedBox(width: 8),
|
||
SizedBox(width: 62, child: c6),
|
||
],
|
||
if (c7 != null) ...[
|
||
const SizedBox(width: 8),
|
||
SizedBox(width: 62, child: c7),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
// 利润列:负数染 danger,正常 success;待定价沿 warn 样式
|
||
Widget _profitCell(BuildContext context, String text, bool pending) {
|
||
final t = context.tokens;
|
||
if (pending) {
|
||
return Text(text,
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
color: t.warn,
|
||
fontWeight: FontWeight.w600));
|
||
}
|
||
return Text(text,
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
// 明细数值列缩小一号(2026-07-03 用户反馈:列多时拥挤)
|
||
fontSize: AppDims.fsSm,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: FontWeight.w600,
|
||
color: text.startsWith('-') ? t.danger : t.success));
|
||
}
|
||
}
|
||
|
||
class _ActionGroup extends StatelessWidget {
|
||
final DrawerActionGroup group;
|
||
const _ActionGroup({required this.group});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(group.label,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
color: t.muted,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.4)),
|
||
const SizedBox(height: 8),
|
||
Wrap(spacing: 10, runSpacing: 10, children: group.buttons),
|
||
],
|
||
);
|
||
}
|
||
}
|