feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
commit
56830e94ec
108 files changed
+9272
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../data/earnings_api.dart';
|
||||
import '../data/earnings_models.dart';
|
||||
|
||||
final earningsApiProvider = Provider<EarningsApi>(
|
||||
(ref) => EarningsApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
final earningsSummaryProvider =
|
||||
FutureProvider.autoDispose<EarningsSummary>((ref) async {
|
||||
final res = await ref.watch(earningsApiProvider).summary();
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
if (res.data == null) throw const ApiException('收益数据为空');
|
||||
return res.data!;
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'earnings_models.dart';
|
||||
|
||||
/// 收益接口,对应后端 `/api/worker-app/earnings/summary`。
|
||||
class EarningsApi {
|
||||
EarningsApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<ApiResponse<EarningsSummary>> summary() =>
|
||||
_client.get<EarningsSummary>(
|
||||
'/worker-app/earnings/summary',
|
||||
parse: (d) => EarningsSummary.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 收益流水,对应后端 `earningsSummary().data.transactions[]`。
|
||||
///
|
||||
/// 说明:后端没有提现记录来源,因此实际只会返回 `type == 'earning'` 的入账流水。
|
||||
class EarningTransaction {
|
||||
const EarningTransaction({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// earning | withdrawal
|
||||
final String type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double amount;
|
||||
|
||||
/// settled | pending | completed
|
||||
final String status;
|
||||
final String icon;
|
||||
|
||||
bool get isWithdrawal => type == 'withdrawal';
|
||||
|
||||
String get statusText => switch (status) {
|
||||
'settled' => '已结算',
|
||||
'pending' => '处理中',
|
||||
'completed' => '已完成',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
factory EarningTransaction.fromJson(Map<String, dynamic> json) =>
|
||||
EarningTransaction(
|
||||
id: json['id'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'earning',
|
||||
title: json['title'] as String? ?? '',
|
||||
subtitle: json['subtitle'] as String? ?? '',
|
||||
amount: toDoubleOr(json['amount'], 0),
|
||||
status: json['status'] as String? ?? 'settled',
|
||||
icon: json['icon'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 收益汇总,对应 `GET /api/worker-app/earnings/summary`。
|
||||
class EarningsSummary {
|
||||
const EarningsSummary({
|
||||
required this.totalBalance,
|
||||
required this.withdrawableAmount,
|
||||
required this.monthlyEarnings,
|
||||
required this.weeklyTrend,
|
||||
required this.transactions,
|
||||
});
|
||||
|
||||
final double totalBalance;
|
||||
final double withdrawableAmount;
|
||||
final double monthlyEarnings;
|
||||
|
||||
/// 近 7 天每日收益(index 6 = 今天)。
|
||||
final List<double> weeklyTrend;
|
||||
final List<EarningTransaction> transactions;
|
||||
|
||||
/// 柱状图高度百分比:以 7 日峰值为 100%,全 0 时给一个最小可见高度。
|
||||
List<double> get trendPercents {
|
||||
final max = weeklyTrend.isEmpty
|
||||
? 0.0
|
||||
: weeklyTrend.reduce((a, b) => a > b ? a : b);
|
||||
if (max <= 0) return List.filled(weeklyTrend.length, 8);
|
||||
return weeklyTrend.map((v) => (v / max * 100).clamp(8, 100).toDouble()).toList();
|
||||
}
|
||||
|
||||
factory EarningsSummary.fromJson(Map<String, dynamic> json) => EarningsSummary(
|
||||
totalBalance: toDoubleOr(json['totalBalance'], 0),
|
||||
withdrawableAmount: toDoubleOr(json['withdrawableAmount'], 0),
|
||||
monthlyEarnings: toDoubleOr(json['monthlyEarnings'], 0),
|
||||
weeklyTrend:
|
||||
(json['weeklyTrend'] as List?)?.map((e) => toDoubleOr(e, 0)).toList() ??
|
||||
const [0, 0, 0, 0, 0, 0, 0],
|
||||
transactions:
|
||||
(json['transactions'] as List?)
|
||||
?.map(
|
||||
(e) => EarningTransaction.fromJson(
|
||||
(e as Map).cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/earnings_providers.dart';
|
||||
import '../data/earnings_models.dart';
|
||||
|
||||
/// rgba(255,255,255,0.8) —— 余额卡内的次级文字。
|
||||
const _white80 = Color(0xCCFFFFFF);
|
||||
|
||||
/// 柱状图绘图区高度(趋势卡总高 160,余下留给 8px 间距与周标签)。
|
||||
const _barPlotHeight = 130.0;
|
||||
|
||||
/// 「收益」Tab,逐像素复刻源 `src/pages/earnings/index.vue`。
|
||||
///
|
||||
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 统一提供,故此处不渲染导航。
|
||||
class EarningsTab extends ConsumerWidget {
|
||||
const EarningsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(earningsSummaryProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 56,
|
||||
logoSize: 28,
|
||||
titleSize: 18,
|
||||
titleWeight: FontWeight.w600,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(earningsSummaryProvider);
|
||||
await ref.read(earningsSummaryProvider.future);
|
||||
},
|
||||
child: summary.when(
|
||||
loading: () => _placeholder('加载中...'),
|
||||
error: (error, _) => _placeholder(error.toString()),
|
||||
data: (data) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: XPageTitle(
|
||||
title: '收益中心',
|
||||
subtitle: '管理您的职业收入与提现详情',
|
||||
),
|
||||
),
|
||||
_balanceCard(data),
|
||||
_trendSection(data),
|
||||
_transactionsSection(data),
|
||||
_referralCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载 / 错误占位,保持可下拉(`AlwaysScrollableScrollPhysics`)。
|
||||
Widget _placeholder(String text) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [XPlaceholder(text: text)],
|
||||
);
|
||||
|
||||
/// 余额卡:渐变底 + 总余额 + 两列指标 + 白色提现按钮。
|
||||
Widget _balanceCard(EarningsSummary data) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primaryDeep, AppColors.primary],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'总余额',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _white80,
|
||||
letterSpacing: 0.24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'¥ ${_formatAmount(data.totalBalance)}',
|
||||
style: AppTypography.displayLg.copyWith(
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _balanceItem(
|
||||
'可提现金额',
|
||||
_formatAmount(data.withdrawableAmount),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _balanceItem(
|
||||
'本月累计收益',
|
||||
_formatAmount(data.monthlyEarnings),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => showAppToast('提现功能'),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
XIcon('earnings', size: 20, color: AppColors.primary),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'立即提现',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _balanceItem(String label, String value) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'¥ $value',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// 最近 7 天趋势:柱高取 `trendPercents`,高亮今天(末位)。
|
||||
Widget _trendSection(EarningsSummary data) {
|
||||
final percents = data.trendPercents;
|
||||
final labels = _weekdayLabels();
|
||||
final activeIndex = percents.isEmpty ? -1 : percents.length - 1;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'最近7天趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('近一周'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Row(
|
||||
children: [
|
||||
Text(
|
||||
'近一周',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
XIcon('arrow-down', size: 14, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
height: 160,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < percents.length; i++)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FractionallySizedBox(
|
||||
widthFactor: 0.8,
|
||||
child: Container(
|
||||
height: percents[i] / 100 * _barPlotHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: i == activeIndex
|
||||
? AppColors.primary
|
||||
: AppColors.primaryFixedDim,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
labels[i % labels.length],
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == activeIndex
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: i == activeIndex
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收益明细:入账 / 提现两种配色 + 金额与状态。
|
||||
Widget _transactionsSection(EarningsSummary data) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'收益明细',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('查看全部'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Text(
|
||||
'查看全部',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (var i = 0; i < data.transactions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
_transactionItem(data.transactions[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _transactionItem(EarningTransaction tx) {
|
||||
final withdrawal = tx.isWithdrawal;
|
||||
final settled = tx.status == 'settled' || tx.status == 'completed';
|
||||
final statusColor = settled ? AppColors.primary : AppColors.textMuted;
|
||||
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: withdrawal
|
||||
? AppColors.surfaceVariant
|
||||
: AppColors.secondaryFixed,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: XIcon(
|
||||
'lightning',
|
||||
size: 20,
|
||||
color: withdrawal
|
||||
? AppColors.onSurfaceVariant
|
||||
: AppColors.onSecondaryFixed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tx.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tx.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${withdrawal ? '-' : '+'}¥${_formatAmount(tx.amount.abs())}',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: withdrawal
|
||||
? AppColors.onSurface
|
||||
: AppColors.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
tx.statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 邀请好友卡(源里无实际跳转,点击仅提示)。
|
||||
Widget _referralCard() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => showAppToast('邀请好友加入'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryFixed,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
boxShadow: AppShadows.card,
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'邀请好友加入',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSecondaryFixed,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'每成功邀请一位专业电工,您将获得 ¥100 额外奖励。',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xB30C006A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
XIcon('star', size: 48, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `formatNumber`:千分位 + 两位小数,空值 / 0 回退 `0.00`(与源实现一致)。
|
||||
String _formatAmount(double? value) {
|
||||
if (value == null || value == 0) return '0.00';
|
||||
final parts = value.toStringAsFixed(2).split('.');
|
||||
final grouped = parts[0].replaceAllMapped(
|
||||
RegExp(r'\B(?=(\d{3})+(?!\d))'),
|
||||
(_) => ',',
|
||||
);
|
||||
return '$grouped.${parts[1]}';
|
||||
}
|
||||
|
||||
/// 周标签:index 6 为今天,向前回溯 6 天,保证标签与真实星期一致。
|
||||
List<String> _weekdayLabels() {
|
||||
const names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final today = DateTime.now();
|
||||
return [
|
||||
for (var i = 6; i >= 0; i--)
|
||||
names[today.subtract(Duration(days: i)).weekday - 1],
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user