feat(client): 财务屏设计语言重建(KpiCard + StatusPill 徽章 + token 清扫)

- 3 张汇总卡 _SummaryCard → KpiCard(合计/未结清/已结清,info/warn/ok)
- _StatusBadge / _TypeBadge → StatusPill(结清状态 + 应收/应付/收款/付款,
  硬编码 E3F2FD/FFEBEE/E8F5E9/FFF3E0/F5F5F5 全清,走 token)
- _OfflineBanner 离线条 FFF8E1/F57F17 → warnBg/warn
- 整屏 golden ×三主题(桌面+移动)入回归闸;该屏 0 硬编码色残留

analyze 0 error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
wangjia
2026-06-25 13:38:41 +08:00
parent a357efc439
commit dee6f337bf
8 changed files with 117 additions and 127 deletions
+43 -127
View File
@@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/responsive/responsive.dart';
import '../../core/config/app_constants.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../models/finance.dart';
import '../../providers/finance_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/kpi_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/multi_select_dropdown.dart'
show ColDef, ColumnToggleButton, FilterableColumnHeader;
@@ -374,41 +376,46 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
children: [
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
Builder(builder: (context) {
final w = context.isMobile ? 150.0 : null;
final cards = [
_SummaryCard(
final mobile = context.isMobile;
final cards = <Widget>[
KpiCard(
title: '合计金额',
value: '¥${(totalAmount / 10000).toStringAsFixed(2)}',
icon: Icons.account_balance_wallet,
color: context.tokens.primary,
width: w,
tone: KpiTone.info,
),
const SizedBox(width: 12),
_SummaryCard(
KpiCard(
title: '未结清',
value: '¥${(openAmount / 10000).toStringAsFixed(2)}',
icon: Icons.pending_actions,
color: context.tokens.danger,
width: w,
tone: KpiTone.warn,
),
const SizedBox(width: 12),
_SummaryCard(
KpiCard(
title: '已结清',
value: '¥${(closedAmount / 10000).toStringAsFixed(2)}',
icon: Icons.check_circle,
color: context.tokens.success,
width: w,
tone: KpiTone.ok,
),
];
final row = <Widget>[];
for (var i = 0; i < cards.length; i++) {
if (i > 0) row.add(const SizedBox(width: AppDims.sp3));
row.add(mobile
? SizedBox(width: 160, child: cards[i])
: Expanded(child: cards[i]));
}
return Container(
color: context.tokens.bg,
padding: const EdgeInsets.all(12),
child: context.isMobile
padding: const EdgeInsets.all(AppDims.sp3),
child: mobile
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: cards),
child: IntrinsicHeight(child: Row(children: row)),
)
: Row(children: cards),
: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: row)),
);
}),
if (widget.typeFilter.isNotEmpty && records.isNotEmpty)
@@ -647,22 +654,10 @@ class _StatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isOpen = status == 'open';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isOpen ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(3),
),
child: Text(
isOpen ? '未结清' : '已结清',
style: TextStyle(
color: isOpen ? context.tokens.accent : context.tokens.muted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
final t = context.tokens;
return status == 'open'
? StatusPill(label: '未结清', color: t.warn, background: t.warnBg)
: StatusPill(label: '已结清', color: t.muted, background: t.infoSoft);
}
}
@@ -672,95 +667,15 @@ class _TypeBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
Color bg, fg;
switch (label) {
case '应收账款':
bg = const Color(0xFFE3F2FD);
fg = context.tokens.primary;
break;
case '应付账款':
bg = const Color(0xFFFFEBEE);
fg = context.tokens.danger;
break;
case '收款':
bg = const Color(0xFFE8F5E9);
fg = context.tokens.success;
break;
case '付款':
bg = const Color(0xFFFFF3E0);
fg = context.tokens.accent;
break;
default:
bg = const Color(0xFFF5F5F5);
fg = context.tokens.muted;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)),
child: Text(label,
style:
TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
);
}
}
class _SummaryCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
final double? width;
const _SummaryCard({
required this.title,
required this.value,
required this.icon,
required this.color,
this.width,
});
@override
Widget build(BuildContext context) {
final card = Container(
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: context.tokens.surface,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: context.tokens.border, width: 0.5),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(title,
style: TextStyle(
fontSize: 12, color: context.tokens.muted)),
const SizedBox(height: 4),
Text(value,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700, color: color)),
],
),
],
),
);
return width != null
? SizedBox(width: width, child: card)
: Expanded(child: card);
final t = context.tokens;
final (Color fg, Color bg) = switch (label) {
'应收账款' => (t.primary, t.infoSoft),
'应付账款' => (t.danger, t.dangerBg),
'收款' => (t.success, t.okSoft),
'付款' => (t.warn, t.warnBg),
_ => (t.muted, t.infoSoft),
};
return StatusPill(label: label, color: fg, background: bg);
}
}
@@ -809,22 +724,23 @@ class _OfflineBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
width: double.infinity,
color: const Color(0xFFFFF8E1),
color: t.warnBg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
children: [
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
Icon(Icons.cloud_off, size: 14, color: t.warn),
const SizedBox(width: 8),
const Expanded(
Expanded(
child: Text('网络不可用,当前显示离线缓存数据',
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
style: TextStyle(color: t.warn, fontSize: 12)),
),
TextButton(
onPressed: onRetry,
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFF57F17),
foregroundColor: t.warn,
padding: const EdgeInsets.symmetric(horizontal: 8)),
child: const Text('重试', style: TextStyle(fontSize: 12)),
),
@@ -0,0 +1,74 @@
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/finance.dart';
import 'package:jiu_client/providers/finance_provider.dart';
import 'package:jiu_client/repositories/finance_repository.dart';
import 'package:jiu_client/screens/finance/finance_screen.dart';
import '../support/golden_harness.dart';
/// design-distill 阶段4:财务整屏 golden × 三主题(桌面 + 移动)。
/// 验证类型徽章(应收/应付/收款/付款)+ 结清状态徽章 → StatusPill 随主题换色。
/// 更新基准:flutter test --update-goldens test/golden/finance_golden_test.dart
const _records = [
FinanceRecord(
id: 1, type: 'receivable', amount: 126800, balance: 126800,
status: 'open', partnerName: '鼎丰超市', recordDate: '2026-06-20'),
FinanceRecord(
id: 2, type: 'payable', amount: 152600, balance: 0,
status: 'closed', partnerName: '老窖酒水批发', recordDate: '2026-06-18'),
FinanceRecord(
id: 3, type: 'receipt', amount: 54200, balance: 0,
status: 'closed', partnerName: '金樽酒楼', recordDate: '2026-06-15'),
FinanceRecord(
id: 4, type: 'payment', amount: 88400, balance: 88400,
status: 'open', partnerName: '汾酒山西直营', recordDate: '2026-06-12'),
];
// 财务屏经 FutureBuilder 直读 repository,故覆盖 repoimplements + noSuchMethod
// 只实现 listRecords,其余方法 golden 静态渲染不会触达)。
class _FakeFinanceRepo implements FinanceRepository {
@override
Future<PageResult<FinanceRecord>> listRecords({
String? type,
String? month,
int page = 1,
int pageSize = 50,
}) async =>
const PageResult(data: _records, total: 4, page: 1, pageSize: 20);
@override
dynamic noSuchMethod(Invocation invocation) =>
super.noSuchMethod(invocation);
}
List<Override> _overrides() => [
financeRepositoryProvider.overrideWithValue(_FakeFinanceRepo()),
isReadonlyProvider.overrideWithValue(false),
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
goldenAcrossThemes(
'finance 整屏(桌面)',
goldenPrefix: 'finance',
child: () => const Scaffold(body: FinanceScreen()),
overrides: _overrides,
logical: const Size(1280, 900),
);
goldenAcrossThemes(
'finance 整屏(移动)',
goldenPrefix: 'finance_mobile',
child: () => const Scaffold(body: FinanceScreen()),
overrides: _overrides,
logical: const Size(390, 1300),
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB