Files
jiu/client/lib/widgets/order_detail_drawer.dart
T
wangjia 2bf5b98ba2 feat(client): 草稿 KPI 卡(暗黄)+ 出库详情总售价列 + 明细去¥缩号 + 建单默认值 + 若干列名
- 出入库列表加草稿 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
2026-07-03 14:31:50 +08:00

469 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
],
);
}
}