979ca3c83b
- 出库详情抽屉(管理员):数量/成本价/售价/利润 + 合计金额/合计利润 双行; operator 见 售价/小计(服务端抹零+前端隐藏双保险);利润负数 danger - 出库建单:商品名称下带编码(字体同系列列);金额列→利润列(实时); 底栏合计利润(仅管理员);进价列仅管理员;提交发新 key(cost_price/sale_price) - 入库建单列头:进价(单瓶)/ 参考售价 / 总进价 - 退单弹窗金额改售价口径(与冲应收一致);打印保留待定价回退成本兼容 - models 字段随后端消歧改名;golden 重打;新增抽屉双形态 widget 测试 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
980 lines
31 KiB
Dart
980 lines
31 KiB
Dart
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../../core/responsive/responsive.dart';
|
||
import '../../core/theme/app_dims.g.dart';
|
||
import '../../core/theme/app_tokens.dart';
|
||
import '../../core/theme/context_tokens.dart';
|
||
import '../../core/utils/date_util.dart';
|
||
import '../../widgets/wheel_date_picker.dart';
|
||
import '../../core/theme/app_fonts.dart';
|
||
|
||
/// 录单页整体版式(对齐原型 stock-in.html:`.page-head` + `.form-wrap` + `.form-foot`)。
|
||
///
|
||
/// 出库 / 入库两屏共用此壳:固定顶部页头、可滚动的表单主区(提示条 + 单据信息 +
|
||
/// 商品明细)、固定底部合计栏。窄屏自动收窄内边距、页头/底栏动作切到 [mobileActions]。
|
||
class OrderFormShell extends StatelessWidget {
|
||
final String title;
|
||
final Widget? statusBadge;
|
||
final VoidCallback onBack;
|
||
|
||
/// 桌面页头 / 底栏动作(同一组按钮两处呈现,对齐原型 desk 组)。
|
||
final List<Widget> deskActions;
|
||
|
||
/// 窄屏页头动作(主操作 + 溢出菜单)。
|
||
final List<Widget> mobileActions;
|
||
|
||
final Widget? notice;
|
||
final Widget docHead;
|
||
final Widget detailHead;
|
||
final Widget detail;
|
||
final String rowsLabel; // 明细 N 行
|
||
final String totalText; // 已格式化 ¥
|
||
final String? profitText; // 合计利润(出库·仅管理员;null 不渲染)
|
||
final bool loading;
|
||
|
||
const OrderFormShell({
|
||
super.key,
|
||
required this.title,
|
||
required this.onBack,
|
||
required this.deskActions,
|
||
required this.mobileActions,
|
||
required this.docHead,
|
||
required this.detailHead,
|
||
required this.detail,
|
||
required this.rowsLabel,
|
||
required this.totalText,
|
||
this.profitText,
|
||
this.statusBadge,
|
||
this.notice,
|
||
this.loading = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final mobile = context.isMobile;
|
||
return Scaffold(
|
||
backgroundColor: t.bg,
|
||
body: Column(
|
||
children: [
|
||
// ── 页头(.page-head)──
|
||
Padding(
|
||
padding: mobile
|
||
? const EdgeInsets.fromLTRB(8, 13, 16, 10)
|
||
: const EdgeInsets.fromLTRB(18, 16, 26, 14),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(LucideIcons.chevronLeft, size: 22, color: t.muted),
|
||
onPressed: onBack,
|
||
tooltip: '返回',
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(title,
|
||
style: TextStyle(
|
||
fontSize: mobile ? AppDims.fsH2 : AppDims.fsH1,
|
||
fontWeight: FontWeight.w700,
|
||
color: t.title)),
|
||
if (statusBadge != null) ...[
|
||
const SizedBox(width: 12),
|
||
statusBadge!,
|
||
],
|
||
const Spacer(),
|
||
if (mobile)
|
||
...mobileActions
|
||
else
|
||
Wrap(spacing: 10, children: deskActions),
|
||
],
|
||
),
|
||
),
|
||
// ── 表单主区(.form-wrap,可滚动)──
|
||
Expanded(
|
||
child: loading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: SingleChildScrollView(
|
||
padding: mobile
|
||
? const EdgeInsets.fromLTRB(16, 0, 16, 14)
|
||
: const EdgeInsets.fromLTRB(26, 0, 26, 18),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (notice != null) notice!,
|
||
docHead,
|
||
const SizedBox(height: 4),
|
||
detailHead,
|
||
detail,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
// ── 底部合计栏(.form-foot)──
|
||
Container(
|
||
padding: mobile
|
||
? const EdgeInsets.symmetric(horizontal: 16, vertical: 12)
|
||
: const EdgeInsets.symmetric(horizontal: 26, vertical: 13),
|
||
decoration: BoxDecoration(
|
||
color: t.surface,
|
||
border: Border(top: BorderSide(color: t.border)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Text(rowsLabel,
|
||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||
const Spacer(),
|
||
if (profitText != null) ...[
|
||
Text('合计利润 ',
|
||
style:
|
||
TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
Text(profitText!,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsTitle,
|
||
color:
|
||
profitText!.contains('-') ? t.danger : t.success,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 18),
|
||
],
|
||
Text('合计金额 ',
|
||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
Text(totalText,
|
||
style: TextStyle(
|
||
fontSize: 24,
|
||
color: t.accent,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 0.3)),
|
||
if (!mobile) ...[
|
||
const SizedBox(width: 16),
|
||
Wrap(spacing: 10, children: deskActions),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单据信息卡(.dochead):可折叠,标题行 + 收起态摘要 + 4 列字段网格 + 备注。
|
||
class DocHeadCard extends StatefulWidget {
|
||
final List<Widget> fields; // 4 个 DocField
|
||
final Widget remark;
|
||
final String summary; // 折叠态摘要(仓库 · 往来单位 · 日期)
|
||
const DocHeadCard({
|
||
super.key,
|
||
required this.fields,
|
||
required this.remark,
|
||
required this.summary,
|
||
});
|
||
|
||
@override
|
||
State<DocHeadCard> createState() => _DocHeadCardState();
|
||
}
|
||
|
||
class _DocHeadCardState extends State<DocHeadCard> {
|
||
bool _folded = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final mobile = context.isMobile;
|
||
return Container(
|
||
margin: const EdgeInsets.only(top: 0, bottom: 14),
|
||
padding:
|
||
EdgeInsets.symmetric(horizontal: 18, vertical: _folded ? 13 : 16),
|
||
decoration: BoxDecoration(
|
||
color: t.surface,
|
||
border: Border.all(color: t.border),
|
||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text('单据信息',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsTitle,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.heading)),
|
||
if (_folded) ...[
|
||
const SizedBox(width: 18),
|
||
Expanded(
|
||
child: Text(widget.summary,
|
||
overflow: TextOverflow.ellipsis,
|
||
style:
|
||
TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
),
|
||
] else
|
||
const Spacer(),
|
||
InkWell(
|
||
onTap: () => setState(() => _folded = !_folded),
|
||
child: Padding(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Text(_folded ? '展开' : '收起',
|
||
style:
|
||
TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||
const SizedBox(width: 5),
|
||
Icon(
|
||
_folded
|
||
? LucideIcons.chevronDown
|
||
: LucideIcons.chevronUp,
|
||
size: 14,
|
||
color: t.muted),
|
||
]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (!_folded) ...[
|
||
const SizedBox(height: 14),
|
||
if (mobile)
|
||
Column(
|
||
children: [
|
||
for (final f in widget.fields)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 14), child: f),
|
||
],
|
||
)
|
||
else
|
||
LayoutBuilder(builder: (context, c) {
|
||
const gap = 14.0;
|
||
final w = (c.maxWidth - gap * 3) / 4;
|
||
return Wrap(
|
||
spacing: gap,
|
||
runSpacing: gap,
|
||
children: [
|
||
for (final f in widget.fields) SizedBox(width: w, child: f),
|
||
],
|
||
);
|
||
}),
|
||
const SizedBox(height: 14),
|
||
widget.remark,
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单据信息里单个字段(label + 必填星 + 控件),对齐 `.field`。
|
||
class DocField extends StatelessWidget {
|
||
final String label;
|
||
final bool required;
|
||
final Widget child;
|
||
const DocField(
|
||
{super.key,
|
||
required this.label,
|
||
required this.child,
|
||
this.required = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(children: [
|
||
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||
if (required)
|
||
Text(' *',
|
||
style: TextStyle(fontSize: AppDims.fsSm, color: t.danger)),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
child,
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 只读值框(.ro-val):入库员/出库员等只读展示。
|
||
class RoValue extends StatelessWidget {
|
||
final String text;
|
||
const RoValue(this.text, {super.key});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
height: 38,
|
||
alignment: Alignment.centerLeft,
|
||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||
decoration: BoxDecoration(
|
||
color: t.bg,
|
||
border: Border.all(color: t.borderSubtle),
|
||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||
),
|
||
child:
|
||
Text(text, style: TextStyle(fontSize: AppDims.fsBody, color: t.text)),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 明细区标题行(.detail-head):标题 + 键盘提示 + 右侧动作。
|
||
class DetailHead extends StatelessWidget {
|
||
final List<Widget> actions;
|
||
const DetailHead({super.key, required this.actions});
|
||
|
||
static String modKey() =>
|
||
(defaultTargetPlatform == TargetPlatform.macOS && !kIsWeb) ? '⌘' : 'Ctrl';
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final mobile = context.isMobile;
|
||
final mod = modKey();
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: Row(
|
||
children: [
|
||
Text('商品明细',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsTitle,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.heading)),
|
||
if (!mobile) ...[
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Wrap(
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
spacing: 6,
|
||
children: [
|
||
const _Kbd('Enter'),
|
||
_hintTxt(t, '下一格 · 末格自动加行 ·'),
|
||
_Kbd('$mod D'),
|
||
_hintTxt(t, '复制行 ·'),
|
||
_Kbd('$mod Enter'),
|
||
_hintTxt(t, '提交'),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
const Spacer(),
|
||
...actions,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _hintTxt(AppTokens t, String s) =>
|
||
Text(s, style: TextStyle(fontSize: AppDims.fsSm, color: t.faint));
|
||
}
|
||
|
||
class _Kbd extends StatelessWidget {
|
||
final String label;
|
||
const _Kbd(this.label);
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: t.bg,
|
||
border: Border.all(color: t.border),
|
||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||
),
|
||
child: Text(label,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
color: t.muted)),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 明细网格列描述(表头 + 宽度 + 对齐 + 必填星)。
|
||
class GridCol {
|
||
final String key;
|
||
final String label;
|
||
final double? width; // null → 占满剩余(grow)
|
||
final bool num;
|
||
final bool req;
|
||
const GridCol(this.key, this.label,
|
||
{this.width, this.num = false, this.req = false});
|
||
}
|
||
|
||
/// 明细网格表(.grid):粘性表头 + 行 + 底部「添加商品」。
|
||
/// 列内容由 [cellBuilder] 按 (rowIndex, colKey) 提供,两屏各自实现。
|
||
class OrderGridTable extends StatelessWidget {
|
||
final List<GridCol> columns;
|
||
final int rowCount;
|
||
final Widget Function(int rowIndex, String colKey) cellBuilder;
|
||
final bool Function(int rowIndex) rowHasError;
|
||
final VoidCallback? onAddRow;
|
||
const OrderGridTable({
|
||
super.key,
|
||
required this.columns,
|
||
required this.rowCount,
|
||
required this.cellBuilder,
|
||
required this.rowHasError,
|
||
this.onAddRow,
|
||
});
|
||
|
||
double get _minWidth {
|
||
double fixed = 0;
|
||
int grows = 0;
|
||
for (final c in columns) {
|
||
if (c.width != null) {
|
||
fixed += c.width!;
|
||
} else {
|
||
grows++;
|
||
}
|
||
}
|
||
return fixed + grows * 180;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: t.surface,
|
||
border: Border.all(color: t.border),
|
||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: LayoutBuilder(builder: (context, c) {
|
||
final overflow = c.maxWidth < _minWidth;
|
||
final width = overflow ? _minWidth : c.maxWidth;
|
||
final table = SizedBox(
|
||
width: width,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_header(t),
|
||
for (var i = 0; i < rowCount; i++) _row(t, i),
|
||
],
|
||
),
|
||
);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
overflow
|
||
? SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal, child: table)
|
||
: table,
|
||
if (onAddRow != null) _addRow(t),
|
||
],
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
Widget _cellWrap(GridCol col, Widget child) {
|
||
final aligned = Align(
|
||
alignment: col.num ? Alignment.centerRight : Alignment.centerLeft,
|
||
child: child,
|
||
);
|
||
if (col.width != null) return SizedBox(width: col.width, child: aligned);
|
||
return Expanded(child: aligned);
|
||
}
|
||
|
||
Widget _header(AppTokens t) {
|
||
return Container(
|
||
color: t.thBg,
|
||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||
child: Row(
|
||
children: [
|
||
for (final col in columns)
|
||
_cellWrap(
|
||
col,
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
child: Text.rich(
|
||
TextSpan(
|
||
children: [
|
||
TextSpan(text: col.label),
|
||
if (col.req)
|
||
TextSpan(text: ' *', style: TextStyle(color: t.danger)),
|
||
],
|
||
),
|
||
textAlign: col.num ? TextAlign.right : TextAlign.left,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsSm,
|
||
color: t.muted,
|
||
fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _row(AppTokens t, int i) {
|
||
final err = rowHasError(i);
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||
boxShadow: err
|
||
? [
|
||
BoxShadow(
|
||
color: t.danger,
|
||
offset: const Offset(3, 0),
|
||
spreadRadius: -2)
|
||
]
|
||
: null,
|
||
),
|
||
child: IntrinsicHeight(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
for (final col in columns)
|
||
_cellWrap(
|
||
col,
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: col.key == 'act' ? 0 : 6, vertical: 4),
|
||
child: cellBuilder(i, col.key),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _addRow(AppTokens t) {
|
||
return InkWell(
|
||
onTap: onAddRow,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||
decoration: BoxDecoration(
|
||
border: Border(top: BorderSide(color: t.borderSubtle))),
|
||
child: Row(children: [
|
||
Icon(LucideIcons.plus, size: 16, color: t.primary),
|
||
const SizedBox(width: 8),
|
||
Text('添加商品',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
color: t.primary,
|
||
fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 8),
|
||
Text('末格 Enter 自动加行',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
color: t.faint)),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 网格内文本输入(.gci):透明、32 高、聚焦描边。数值列右对齐等宽。
|
||
class GciField extends StatelessWidget {
|
||
final TextEditingController controller;
|
||
final FocusNode? focusNode;
|
||
final bool num;
|
||
final bool money;
|
||
final String hint;
|
||
final bool hasError;
|
||
final bool enabled;
|
||
final ValueChanged<String>? onChanged;
|
||
final VoidCallback? onEnter; // Enter 推进下一格
|
||
final bool Function({required bool backward})? onTab;
|
||
const GciField({
|
||
super.key,
|
||
required this.controller,
|
||
this.focusNode,
|
||
this.num = false,
|
||
this.money = false,
|
||
this.hint = '',
|
||
this.hasError = false,
|
||
this.enabled = true,
|
||
this.onChanged,
|
||
this.onEnter,
|
||
this.onTab,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Focus(
|
||
focusNode: focusNode,
|
||
onKeyEvent: (node, e) {
|
||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||
if (e.logicalKey == LogicalKeyboardKey.tab) {
|
||
final back = HardwareKeyboard.instance.isShiftPressed;
|
||
final handled = onTab?.call(backward: back) ?? false;
|
||
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
},
|
||
child: Builder(builder: (context) {
|
||
return TextField(
|
||
controller: controller,
|
||
enabled: enabled,
|
||
textAlign: num ? TextAlign.right : TextAlign.left,
|
||
keyboardType:
|
||
num ? const TextInputType.numberWithOptions(decimal: true) : null,
|
||
inputFormatters: num
|
||
? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))]
|
||
: null,
|
||
onChanged: onChanged,
|
||
onSubmitted: (_) => onEnter?.call(),
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
color: t.text,
|
||
fontFamily: num ? 'monospace' : null),
|
||
cursorColor: t.primary,
|
||
decoration: InputDecoration(
|
||
isCollapsed: true,
|
||
contentPadding:
|
||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||
hintText: hint,
|
||
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
|
||
prefixText: money ? '¥' : null,
|
||
prefixStyle: TextStyle(fontSize: AppDims.fsBody, color: t.muted),
|
||
filled: false,
|
||
border: _b(Colors.transparent),
|
||
enabledBorder: _b(hasError ? t.danger : Colors.transparent),
|
||
focusedBorder: _b(t.primary),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
OutlineInputBorder _b(Color c) => OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(5),
|
||
borderSide: BorderSide(color: c),
|
||
);
|
||
}
|
||
|
||
/// 只读单元格文本(.ro):系列/规格/成本价等出库只读列。
|
||
class RoCell extends StatelessWidget {
|
||
final String text;
|
||
final bool num;
|
||
final Color? color;
|
||
const RoCell(this.text, {super.key, this.num = false, this.color});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
child: Text(text.isEmpty ? '—' : text,
|
||
textAlign: num ? TextAlign.right : TextAlign.left,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
color: color ?? (text.isEmpty ? t.faint : t.muted),
|
||
fontFamily: num ? 'monospace' : null)),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 金额单元格(.amt):正常等宽粗体;[pending] 时橙色「待定价」。
|
||
class AmountCell extends StatelessWidget {
|
||
final double amount;
|
||
final bool pending;
|
||
const AmountCell({super.key, required this.amount, this.pending = false});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
if (pending) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Text('待定价',
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
color: t.warn,
|
||
fontWeight: FontWeight.w600)),
|
||
);
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
child: Text('¥${amount.toStringAsFixed(2)}',
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
color: t.heading,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
fontWeight: FontWeight.w600)),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 行操作图标组(.gact-in):展开(可选)/复制/删除。
|
||
class RowActions extends StatelessWidget {
|
||
final bool? expanded; // null → 不显示展开
|
||
final VoidCallback? onToggleExpand;
|
||
final bool expandHighlight;
|
||
final VoidCallback onCopy;
|
||
final VoidCallback? onDelete;
|
||
const RowActions({
|
||
super.key,
|
||
this.expanded,
|
||
this.onToggleExpand,
|
||
this.expandHighlight = false,
|
||
required this.onCopy,
|
||
this.onDelete,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (expanded != null)
|
||
_icon(
|
||
expanded! ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||
expandHighlight ? t.primary : t.muted,
|
||
onToggleExpand,
|
||
'选填项',
|
||
),
|
||
_icon(LucideIcons.copy, t.muted, onCopy, '复制本行'),
|
||
_icon(LucideIcons.trash2, onDelete == null ? t.faint : t.muted,
|
||
onDelete, '删除'),
|
||
],
|
||
);
|
||
}
|
||
|
||
// 紧凑图标按钮:24×24 命中区,不走 IconButton 的 48px 触摸目标(会撑破窄操作列)。
|
||
Widget _icon(IconData ic, Color color, VoidCallback? onTap, String tip) =>
|
||
Tooltip(
|
||
message: tip,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: SizedBox(
|
||
width: 24,
|
||
height: 24,
|
||
child: Icon(ic, size: 16, color: color),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开日历,值等宽显示。
|
||
class DsDateCell extends StatefulWidget {
|
||
final String? value; // yyyy-MM-dd
|
||
final ValueChanged<String?> onChanged;
|
||
final FocusNode? focusNode;
|
||
final bool cell; // true=.gci-date 网格样式;false=.datefield 独立样式
|
||
final bool hasError;
|
||
final bool enabled;
|
||
final VoidCallback? onPicked; // 选完推进下一格
|
||
final bool Function({required bool backward})? onTab;
|
||
const DsDateCell({
|
||
super.key,
|
||
required this.value,
|
||
required this.onChanged,
|
||
this.focusNode,
|
||
this.cell = false,
|
||
this.hasError = false,
|
||
this.enabled = true,
|
||
this.onPicked,
|
||
this.onTab,
|
||
});
|
||
|
||
@override
|
||
State<DsDateCell> createState() => _DsDateCellState();
|
||
}
|
||
|
||
class _DsDateCellState extends State<DsDateCell> {
|
||
// 浮层锚定到字段下方(对齐原型 datewheel.js:pop.style.top = field.bottom + 4),
|
||
// 非居中弹窗,无遮罩——点字段外任意处收起(TapRegion.onTapOutside)。
|
||
final _link = LayerLink();
|
||
OverlayEntry? _entry;
|
||
bool get _open => _entry != null;
|
||
|
||
@override
|
||
void dispose() {
|
||
_removeOverlay();
|
||
super.dispose();
|
||
}
|
||
|
||
void _toggle() {
|
||
if (_open) {
|
||
_close();
|
||
} else {
|
||
_openOverlay();
|
||
}
|
||
}
|
||
|
||
void _openOverlay() {
|
||
if (_open || !widget.enabled) return;
|
||
final init = parseYmd(widget.value) ?? DateTime.now();
|
||
_entry = OverlayEntry(
|
||
builder: (_) => Positioned(
|
||
width: 248,
|
||
child: CompositedTransformFollower(
|
||
link: _link,
|
||
showWhenUnlinked: false,
|
||
targetAnchor: Alignment.bottomLeft,
|
||
followerAnchor: Alignment.topLeft,
|
||
offset: const Offset(0, 4),
|
||
child: TapRegion(
|
||
onTapOutside: (_) => _close(),
|
||
child: WheelDatePanel(
|
||
initial: init,
|
||
onCommit: (d) {
|
||
widget.onChanged(formatYmd(d));
|
||
_close();
|
||
widget.onPicked?.call();
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
Overlay.of(context).insert(_entry!);
|
||
setState(() {});
|
||
}
|
||
|
||
void _close() {
|
||
_removeOverlay();
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
void _removeOverlay() {
|
||
_entry?.remove();
|
||
_entry = null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.tokens;
|
||
final has = widget.value != null && widget.value!.isNotEmpty;
|
||
final height = widget.cell ? 32.0 : 38.0;
|
||
return CompositedTransformTarget(
|
||
link: _link,
|
||
child: Focus(
|
||
focusNode: widget.focusNode,
|
||
onKeyEvent: (node, e) {
|
||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||
final k = e.logicalKey;
|
||
if (k == LogicalKeyboardKey.enter ||
|
||
k == LogicalKeyboardKey.numpadEnter ||
|
||
k == LogicalKeyboardKey.space) {
|
||
_toggle();
|
||
return KeyEventResult.handled;
|
||
}
|
||
if (k == LogicalKeyboardKey.escape) {
|
||
if (_open) {
|
||
_close();
|
||
return KeyEventResult.handled;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
}
|
||
if (k == LogicalKeyboardKey.tab) {
|
||
final back = HardwareKeyboard.instance.isShiftPressed;
|
||
if (_open) _close();
|
||
final handled = widget.onTab?.call(backward: back) ?? false;
|
||
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
},
|
||
child: Builder(builder: (context) {
|
||
final active = Focus.of(context).hasFocus || _open;
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: widget.enabled
|
||
? () {
|
||
widget.focusNode?.requestFocus();
|
||
_toggle();
|
||
}
|
||
: null,
|
||
child: Container(
|
||
height: height,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
decoration: BoxDecoration(
|
||
color: widget.cell
|
||
? (active ? t.surface : Colors.transparent)
|
||
: t.surface,
|
||
border: Border.all(
|
||
color: widget.hasError
|
||
? t.danger
|
||
: (active
|
||
? t.primary
|
||
: (widget.cell ? Colors.transparent : t.border)),
|
||
),
|
||
borderRadius:
|
||
BorderRadius.circular(widget.cell ? 5 : AppDims.rMd),
|
||
),
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: Text(
|
||
has ? widget.value! : '选择日期',
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
color: has ? t.text : t.faint,
|
||
fontFamily: has ? 'monospace' : null),
|
||
),
|
||
),
|
||
Icon(LucideIcons.calendar, size: 15, color: t.faint),
|
||
]),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 键盘流:跨格推进 / 回退 ─────────────────────────────────────────────────
|
||
/// 从 [fromField] 之后找本行第一个空的必填格聚焦;本行满足则找后续行;
|
||
/// 末行末格则 [onAddRow](新行自动聚焦首格)。对齐原型 advance()。
|
||
void advanceFocus({
|
||
required int rowIndex,
|
||
required String fromField,
|
||
required int rowCount,
|
||
required List<String> fields, // 可聚焦字段顺序
|
||
required bool Function(String field) isRequired,
|
||
required bool Function(int row, String field) isEmpty,
|
||
required void Function(int row, String field) focusCell,
|
||
required VoidCallback onAddRow,
|
||
}) {
|
||
final start = fields.indexOf(fromField) + 1;
|
||
for (var k = start; k < fields.length; k++) {
|
||
if (isRequired(fields[k]) && isEmpty(rowIndex, fields[k])) {
|
||
focusCell(rowIndex, fields[k]);
|
||
return;
|
||
}
|
||
}
|
||
for (var i = rowIndex + 1; i < rowCount; i++) {
|
||
for (final f in fields) {
|
||
if (isRequired(f) && isEmpty(i, f)) {
|
||
focusCell(i, f);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (rowIndex == rowCount - 1) {
|
||
onAddRow();
|
||
} else {
|
||
focusCell(rowIndex + 1, fields.first);
|
||
}
|
||
}
|
||
|
||
/// 回退到上一格(本行前一格,或上一行末格)。对齐原型 retreat()。
|
||
void retreatFocus({
|
||
required int rowIndex,
|
||
required String fromField,
|
||
required List<String> fields,
|
||
required void Function(int row, String field) focusCell,
|
||
}) {
|
||
final k = fields.indexOf(fromField) - 1;
|
||
if (k >= 0) {
|
||
focusCell(rowIndex, fields[k]);
|
||
} else if (rowIndex > 0) {
|
||
focusCell(rowIndex - 1, fields.last);
|
||
}
|
||
}
|