Files
jiu/client/lib/screens/finance/finance_screen.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

649 lines
23 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.
// screens/finance/finance_screen.dart — 财务管理(照原型 finance.html 重建)。
// 版式:head + 时间范围 chips + KPI 4 卡 + 收支趋势柱状图 + 应收/应付汇总表
// + 收支流水表 + 往来抽屉。无分页(原型无 .pager,流水按当前范围一次取)。
// 已知差异(记 design/CONTRACT.md):KPI3/4 delta 用「未结清 N 笔」(原型 逾期/到期额
// 无数据源);流水类型 chips 扩到 5(含应收/应付)并追加 状态/操作 列(结清能力不回退);
// 时间范围真实过滤流水(原型只改文案)。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/clock.dart';
import '../../core/utils/export_util.dart';
import '../../models/finance.dart';
import '../../providers/finance_provider.dart';
import '../../providers/stock_in_provider.dart';
import '../../providers/stock_out_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_bar_chart.dart';
import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/finance_entry_dialog.dart';
import '../../widgets/finance_partner_drawer.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
class FinanceScreen extends ConsumerStatefulWidget {
const FinanceScreen({super.key});
@override
ConsumerState<FinanceScreen> createState() => _FinanceScreenState();
}
class _FinanceScreenState extends ConsumerState<FinanceScreen> {
// 时间范围(原型 .timerow chips):真实过滤流水表;KPI 恒本月、趋势恒近 6 月
String _range = '本月';
String _rangeLabel = '本月';
// 流水类型筛选(原型 3 chips 扩到 5)
String _flowChip = '全部';
static const _flowChips = ['全部', '应收', '应付', '收款', '付款'];
static const _chipToType = {
'全部': '',
'应收': 'receivable',
'应付': 'payable',
'收款': 'receipt',
'付款': 'payment',
};
static final _ymd = DateFormat('yyyy-MM-dd');
@override
void initState() {
super.initState();
// 默认本月(与 KPI 口径一致)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _applyRange('本月');
});
}
Future<void> _applyRange(String range) async {
final now = appNow();
final notifier = ref.read(financeListProvider.notifier);
switch (range) {
case '本月':
final start = DateTime(now.year, now.month, 1);
notifier.setRange(_ymd.format(start), _ymd.format(now));
setState(() {
_range = range;
_rangeLabel = '本月';
});
case '本季':
final qMonth = ((now.month - 1) ~/ 3) * 3 + 1;
final start = DateTime(now.year, qMonth, 1);
notifier.setRange(_ymd.format(start), _ymd.format(now));
setState(() {
_range = range;
_rangeLabel = '本季';
});
case '自定义':
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(now.year - 3),
lastDate: now,
initialDateRange:
DateTimeRange(start: DateTime(now.year, now.month, 1), end: now),
);
if (picked == null) return;
notifier.setRange(_ymd.format(picked.start), _ymd.format(picked.end));
setState(() {
_range = range;
_rangeLabel =
'${_ymd.format(picked.start)} ~ ${_ymd.format(picked.end)}';
});
}
}
void _reload() {
ref.read(financeListProvider.notifier).reload();
ref.invalidate(financePartnerRowsProvider);
ref.invalidate(financeTrendProvider);
ref.invalidate(stockInSummaryProvider);
ref.invalidate(stockOutSummaryProvider);
}
// ── 金额格式 ──
String _yuan(double v) => '¥${NumberFormat.decimalPattern().format(
v == v.roundToDouble() ? v.round() : v,
)}';
/// KPI 大数:¥186万 / ¥48.6万(<1万显示整数元)。
String _yuanWan(double v) {
if (v >= 10000) {
final wan = v / 10000;
return '¥${wan == wan.roundToDouble() ? wan.round() : wan.toStringAsFixed(1)}';
}
return _yuan(v);
}
(String, DsKpiDelta) _momDelta(double cur, double last) {
if (last <= 0) return ('较上月 —', DsKpiDelta.neutral);
final pct = (cur - last) / last * 100;
if (pct == 0) return ('与上月持平', DsKpiDelta.neutral);
return (
'${pct.abs().toStringAsFixed(1)}% 较上月',
pct > 0 ? DsKpiDelta.up : DsKpiDelta.down,
);
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
final flowsAsync = ref.watch(financeListProvider);
final flows = flowsAsync.valueOrNull?.data ?? const <FinanceRecord>[];
final content = Container(
color: t.bg,
child: SingleChildScrollView(
padding: mobile
? const EdgeInsets.all(AppDims.sp4)
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_head(t, mobile, flows),
_timeRow(t, mobile),
_kpis(t, mobile),
_secHead(t, '收支趋势', '近 6 个月 · 单位 万元'),
_trendChart(),
const SizedBox(height: 22),
_secHead(t, '应收 / 应付 汇总', '按往来单位 · 点击行查看流水'),
_summaryTable(t, mobile),
const SizedBox(height: 22),
_secHead(t, '收支流水', _flowSub(flows)),
_flowTable(t, mobile, flows, flowsAsync.valueOrNull?.total ?? 0),
],
),
),
);
return Stack(children: [
content,
if (flowsAsync.isLoading) const Positioned.fill(child: DsLoadingScrim()),
]);
}
// ── head ──
Widget _head(dynamic t, bool mobile, List<FinanceRecord> flows) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('财务管理',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(width: AppDims.sp3),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('应收/应付概览 · $_rangeLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
),
if (!mobile) ...[
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload),
const SizedBox(width: 10),
DsButton('导出报表',
icon: LucideIcons.download, onPressed: () => _export(flows)),
const SizedBox(width: 10),
WriteGuard(
child: DsButton('登记收支',
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => showFinanceEntryDialog(context)),
),
],
],
),
);
}
void _export(List<FinanceRecord> flows) {
exportExcel(
filename: '财务流水',
headers: ['日期', '类型', '往来单位', '金额', '关联单据', '状态', '备注'],
rows: flows
.map((r) => [
(r.recordDate ?? '').split('T').first,
r.typeLabel,
r.partnerName ?? '',
r.amount,
r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '',
r.status == 'open' ? '未结清' : '已结清',
r.remark ?? '',
])
.toList(),
);
}
// ── 时间范围 chips(原型 .timerow)──
Widget _timeRow(dynamic t, bool mobile) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Row(children: [
Text('时间范围', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(width: 12),
for (final r in const ['本月', '本季', '自定义']) ...[
DsChip(
label: r,
selected: _range == r,
caret: r == '自定义',
onTap: () => _applyRange(r)),
const SizedBox(width: 10),
],
if (mobile) ...[
const Spacer(),
WriteGuard(
child: DsButton('登记',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => showFinanceEntryDialog(context)),
),
],
]),
);
}
// ── KPI 4 卡 ──
Widget _kpis(dynamic t, bool mobile) {
final outSum = ref.watch(stockOutSummaryProvider).valueOrNull;
final inSum = ref.watch(stockInSummaryProvider).valueOrNull;
final rows = ref.watch(financePartnerRowsProvider).valueOrNull ?? const [];
var recv = 0.0, pay = 0.0, openCount = 0;
for (final r in rows) {
recv += r.recv;
pay += r.pay;
openCount += r.openCount;
}
final (saleDelta, saleTone) = outSum != null
? _momDelta(outSum.monthAmount, outSum.lastMonthAmount)
: ('较上月 —', DsKpiDelta.neutral);
final (buyDelta, buyTone) = inSum != null
? _momDelta(inSum.monthAmount, inSum.lastMonthAmount)
: ('较上月 —', DsKpiDelta.neutral);
final cards = <Widget>[
DsKpi(
title: '本月销售额',
value: outSum != null ? _yuanWan(outSum.monthAmount) : '',
icon: LucideIcons.box,
tone: DsKpiTone.info,
delta: saleDelta,
deltaTone: saleTone,
),
DsKpi(
title: '本月采购额',
value: inSum != null ? _yuanWan(inSum.monthAmount) : '',
icon: LucideIcons.shoppingCart,
tone: DsKpiTone.blue,
delta: buyDelta,
deltaTone: buyTone,
),
// 原型 delta「逾期 ¥9.2万」无数据源 → 未结清笔数(已知差异)
DsKpi(
title: '应收账款 · 待收',
value: _yuanWan(recv),
icon: LucideIcons.circleDollarSign,
tone: DsKpiTone.alert,
delta: '未结清 $openCount',
deltaTone: DsKpiDelta.down,
),
DsKpi(
title: '应付账款',
value: _yuanWan(pay),
icon: LucideIcons.receipt,
tone: DsKpiTone.ok,
delta: '按到期及时结清',
deltaTone: DsKpiDelta.up,
),
];
if (mobile) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicHeight(
child: Row(children: [
for (var i = 0; i < cards.length; i++) ...[
if (i > 0) const SizedBox(width: AppDims.sp3),
SizedBox(width: 170, child: cards[i]),
],
]),
),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < cards.length; i++) ...[
if (i > 0) const SizedBox(width: 14),
Expanded(child: cards[i]),
],
],
),
),
);
}
/// 原型 .sec-headh2 17/700 + 副标 fs-xs muted,下距 12。
Widget _secHead(dynamic t, String title, String sub) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(title,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(width: 10),
Padding(
padding: const EdgeInsets.only(bottom: 1),
child: Text(sub,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
),
],
),
);
// ── 收支趋势 ──
Widget _trendChart() {
final t = context.tokens;
final trend = ref.watch(financeTrendProvider);
Widget placeholder(Widget child) => Container(
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: child,
);
return trend.when(
loading: () => placeholder(const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))),
error: (e, _) => placeholder(Text('趋势加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
data: (points) => DsBarChart(
groups: [
for (final p in points)
DsBarGroup(_monthLabel(p.month), p.inAmount, p.outAmount),
],
legendA: '收入',
legendB: '支出',
// 柱顶标签:万元取整(原型 TREND 值即万元整数)
format: (v) => '${(v / 10000).round()}',
),
);
}
String _monthLabel(String ym) {
final parts = ym.split('-');
return parts.length == 2 ? '${int.tryParse(parts[1]) ?? parts[1]}' : ym;
}
// ── 应收/应付汇总表 ──
Widget _summaryTable(dynamic t, bool mobile) {
final async = ref.watch(financePartnerRowsProvider);
return async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Row(children: [
Text('汇总加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
const SizedBox(width: 10),
DsButton('重试',
small: true,
onPressed: () => ref.invalidate(financePartnerRowsProvider)),
]),
data: (rows) => DsTable(
shrinkWrap: true,
emptyText: '暂无未结清的应收 / 应付',
mobileCards: rows
.map((r) => MobileListCard(
title: Text(r.name),
trailing: Text(signedYuan(r.net),
style: TextStyle(
fontWeight: FontWeight.w600,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: r.net > 0
? t.success
: (r.net < 0 ? t.danger : t.text))),
fields: [
MobileCardField('应收', r.recv > 0 ? _yuan(r.recv) : ''),
MobileCardField('应付', r.pay > 0 ? _yuan(r.pay) : ''),
],
onTap: () => showFinancePartnerDrawer(context, row: r),
))
.toList(),
columns: const [
DsColumn('name', '往来单位'),
DsColumn('recv', '应收', numeric: true),
DsColumn('pay', '应付', numeric: true),
DsColumn('net', '净额', numeric: true),
DsColumn('actions', '操作', action: true),
],
rows: rows.map((r) {
return DsRow(
onTap: () => showFinancePartnerDrawer(context, row: r),
cells: [
Text(r.name,
style:
TextStyle(fontWeight: FontWeight.w600, color: t.heading)),
Text(r.recv > 0 ? _yuan(r.recv) : '',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text(r.pay > 0 ? _yuan(r.pay) : '',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text(signedYuan(r.net),
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600,
color: r.net > 0
? t.success
: (r.net < 0 ? t.danger : t.text))),
InkWell(
onTap: () => showFinancePartnerDrawer(context, row: r),
child: Text('查看',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.primary)),
),
],
);
}).toList(),
),
);
}
String _flowSub(List<FinanceRecord> flows) {
final receipts = flows.where((r) => r.type == 'receipt').length;
final payments = flows.where((r) => r.type == 'payment').length;
return '$_rangeLabel ${flows.length} 笔 · 收 $receipts / 付 $payments';
}
// ── 收支流水表 ──
Widget _flowTable(
dynamic t, bool mobile, List<FinanceRecord> flows, int total) {
final chips = [
for (final c in _flowChips) ...[
DsChip(
label: c,
selected: _flowChip == c,
caret: false,
onTap: () => _setFlowChip(c)),
const SizedBox(width: 10),
],
];
final toolbar = mobile
? SingleChildScrollView(
scrollDirection: Axis.horizontal, child: Row(children: chips))
: Row(children: [
Text('类型',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(width: 12),
...chips,
const Spacer(),
Text('$total',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
]);
return DsTable(
shrinkWrap: true,
emptyText: '没有匹配的流水记录',
toolbar: toolbar,
mobileCards: flows.map((r) => _flowCard(t, r)).toList(),
columns: const [
DsColumn('date', '日期'),
DsColumn('type', '类型'),
DsColumn('partner', '往来单位'),
DsColumn('amount', '金额', numeric: true),
DsColumn('ref', '关联单号'),
DsColumn('remark', '备注'),
DsColumn('status', '状态'),
DsColumn('actions', '操作', action: true),
],
rows: flows.map((r) {
final isIn = r.type == 'receipt' || r.type == 'receivable';
return DsRow(cells: [
Text((r.recordDate ?? '').split('T').first,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
financeTypeBadge(r),
Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : ''),
// 原型 .qty+/- 前缀、恒 heading 色(不上正负色)
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600,
color: t.heading)),
Text(
r.refType != null && r.refId != null
? '${r.docTitle} #${r.refId}'
: '',
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm,
color: t.muted)),
Text((r.remark?.isNotEmpty == true) ? r.remark! : '',
style: TextStyle(color: t.muted)),
r.status == 'open'
? const DsBadge('未结清', tone: DsBadgeTone.warn)
: const DsBadge('已结清', tone: DsBadgeTone.muted),
_closeAction(t, r),
]);
}).toList(),
);
}
void _setFlowChip(String c) {
setState(() => _flowChip = c);
ref.read(financeListProvider.notifier).setType(_chipToType[c] ?? '');
}
Widget _closeAction(dynamic t, FinanceRecord r) {
final closable =
r.status == 'open' && (r.type == 'receivable' || r.type == 'payable');
if (!closable) return Text('', style: TextStyle(color: t.faint));
return WriteGuard(
child: InkWell(
onTap: () => _confirmClose(r),
child: Text('结清',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.primary)),
),
);
}
Future<void> _confirmClose(FinanceRecord r) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认结清'),
content: Text('确认将「${r.partnerName ?? ''} ${r.typeLabel} '
'${_yuan(r.amount.abs())}」标记为已结清?'),
actions: [
DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)),
DsButton('结清',
variant: DsBtnVariant.primary,
onPressed: () => Navigator.of(ctx).pop(true)),
],
),
);
if (ok != true || !mounted) return;
try {
await ref.read(financeRepositoryProvider).close(r.id);
ref.read(financeListProvider.notifier).reload();
ref.invalidate(financePartnerRowsProvider);
if (mounted) {
showDsToast(context, '已结清 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '操作失败:$e', bg: context.tokens.danger);
}
}
}
Widget _flowCard(dynamic t, FinanceRecord r) {
final isIn = r.type == 'receipt' || r.type == 'receivable';
return MobileListCard(
title: Text(
(r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel),
subtitle: Text((r.recordDate ?? '').split('T').first),
trailing: financeTypeBadge(r),
fields: [
MobileCardField('金额', '${isIn ? '+' : '-'}${_yuan(r.amount.abs())}'),
MobileCardField('状态', r.status == 'open' ? '未结清' : '已结清'),
if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark),
],
actions: (r.status == 'open' &&
(r.type == 'receivable' || r.type == 'payable'))
? [
WriteGuard(
child: TextButton(
onPressed: () => _confirmClose(r),
child: const Text('结清', style: TextStyle(fontSize: 13)),
),
),
]
: null,
);
}
}