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:
@@ -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,故覆盖 repo(implements + 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 |
Reference in New Issue
Block a user